掘金文章爬虫前端项目实现教程 #
本教程将带你循序渐进地实现一个掘金文章爬虫的前端项目。该项目使用Next.js框架构建,结合了Milvus向量数据库进行文章的语义搜索功能。
目录 #
1. 项目概述与技术栈 #
项目介绍 #
本项目是一个掘金文章浏览和搜索系统的前端部分,能够展示从掘金网站爬取的文章,并提供基于语义的搜索功能。通过向量化文章标题,我们可以实现比传统关键词匹配更智能的文章搜索体验。
技术栈 #
- 前端框架:Next.js 15
- UI库:Tailwind CSS 4
- 向量数据库:Milvus
- 向量化服务:OpenAI Embedding API
- HTTP客户端:Axios
- UI组件:Headless UI, Heroicons
2. 环境准备 #
安装Node.js和npm #
确保你的开发环境已安装最新版本的Node.js和npm。
安装Milvus #
本项目依赖Milvus向量数据库,你可以通过Docker快速安装:
docker run -d --name milvus -p 19530:19530 -p 19531:19531 -p 9091:9091 milvusdb/milvus:v2.3.3获取OpenAI API密钥 #
向量化服务需要OpenAI API密钥,请在OpenAI官网注册并获取API密钥。
3. 项目初始化 #
创建Next.js项目 #
首先,我们使用Create Next App创建一个新的Next.js项目:
npx create-next-app@latest my-juejin-crawler
cd my-juejin-crawler安装依赖 #
接下来,安装所需的依赖包:
npm install @zilliz/milvus2-sdk-node axios @headlessui/react @heroicons/react classnames
npm install tailwindcss @tailwindcss/vite配置Tailwind CSS #
在 Next.js 项目中配置 Tailwind CSS v4.0:
/** @type {import('next').NextConfig} */
import tailwindcss from '@tailwindcss/vite';
const nextConfig = {
webpack: (config) => {
return config;
},
experimental: {
plugins: [
tailwindcss(),
],
},
};
export default nextConfig;在 src/app/globals.css 中添加 Tailwind CSS 导入:
@import "tailwindcss";
/* 您的其他全局样式 */4. 基础架构搭建 #
创建项目结构 #
建立基本的项目目录结构:
src/
├── app/
│ ├── api/
│ │ └── articles/
│ │ └── route.js
│ ├── components/
│ │ ├── ArticleList.js
│ │ └── SearchBar.js
│ ├── utils/
│ │ ├── error-handler.js
│ │ ├── milvus-config.js
│ │ └── milvus-service.js
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.js
│ └── page.js
├── public/实现基础布局 #
创建src/app/layout.js文件,设置全局布局:
import './globals.css'
export const metadata = {
title: '掘金文章浏览器',
description: '基于语义搜索的掘金文章浏览工具',
}
export default function RootLayout({ children }) {
return (
<html lang="zh">
<body>
{children}
</body>
</html>
)
}配置全局样式 #
在src/app/globals.css中添加Tailwind指令和全局样式:
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-rgb: 255, 255, 255;
}
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
}5. Milvus服务集成 #
配置Milvus连接 #
创建src/app/utils/milvus-config.js文件,存储Milvus和OpenAI的配置:
export const milvusConfig = {
milvus: {
address: 'localhost',
port: '19530',
database: 'default',
collection: 'juejin_articles'
},
embedding: {
url: 'https://api.openai.com/v1/embeddings',
model: 'text-embedding-3-small',
apiKey: process.env.OPENAI_API_KEY || 'your-api-key' // 实际部署时应使用环境变量
}
};创建错误处理工具 #
创建src/app/utils/error-handler.js文件,用于统一错误处理:
/**
* 格式化错误消息
*/
export function formatErrorMessage(error) {
if (typeof error === 'string') {
return error;
}
if (error.message) {
return error.message;
}
return '发生未知错误';
}
/**
* 记录服务器错误
*/
export function logServerError(source, error) {
console.error(`[${source}] 错误:`, error);
// 提取和记录关键错误信息
const errorInfo = {
message: error.message || '未知错误',
stack: error.stack,
timestamp: new Date().toISOString()
};
console.error(`详细错误信息:`, JSON.stringify(errorInfo, null, 2));
}实现Milvus服务类 #
创建src/app/utils/milvus-service.js文件,封装与Milvus数据库的交互:
import { MilvusClient, DataType } from '@zilliz/milvus2-sdk-node';
import { milvusConfig } from './milvus-config';
import axios from 'axios';
/**
* 文本转向量函数
* @param {string} text - 需要转换为向量的文本
* @returns {Promise<Array<number>>} - 返回向量数组
*/
export async function textToVector(text) {
const response = await axios.post(milvusConfig.embedding.url, {
model: milvusConfig.embedding.model,
input: text,
encoding_format: 'float'
}, {
headers: {
'Authorization': `Bearer ${milvusConfig.embedding.apiKey}`,
'Content-Type': 'application/json'
}
});
return response.data.data[0].embedding;
}
/**
* Milvus服务类 - 处理与Milvus的所有交互
*/
export class MilvusService {
constructor() {
this.client = null;
this.isConnected = false;
}
/**
* 连接到Milvus数据库
*/
async connect() {
if (this.isConnected) return;
try {
this.client = new MilvusClient({
address: `${milvusConfig.milvus.address}:${milvusConfig.milvus.port}`,
username: '',
password: '',
ssl: false,
loadProto: true,
useIdentity: false
});
await this.client.use({
db_name: milvusConfig.milvus.database
});
// 检查集合是否存在
const hasCollection = await this.client.hasCollection({
collection_name: milvusConfig.milvus.collection
});
if (!hasCollection.value) {
throw new Error(`集合 ${milvusConfig.milvus.collection} 不存在`);
}
// 加载集合,确保可以搜索
await this.client.loadCollection({
collection_name: milvusConfig.milvus.collection
});
this.isConnected = true;
} catch (error) {
console.error('连接Milvus失败:', error);
throw new Error('无法连接到Milvus数据库');
}
}
/**
* 搜索文章
* @param {string} searchTerm - 搜索关键词
* @param {number} limit - 结果数量限制
* @returns {Promise<Array>} - 搜索结果
*/
async searchArticles(searchTerm, limit = 10) {
await this.connect();
try {
// 将搜索词转换为向量
const searchVector = await textToVector(searchTerm);
// 搜索相似向量
const searchResults = await this.client.search({
collection_name: milvusConfig.milvus.collection,
data: [searchVector],
anns_field: 'title_vector',
param: {
metric_type: 'COSINE',
params: { ef: 64 }
},
limit: limit,
output_fields: ['rank', 'title', 'url', 'likes', 'views']
});
// 处理搜索结果
let processedResults = [];
if (searchResults.results && Array.isArray(searchResults.results)) {
processedResults = searchResults.results.map(result => {
return {
id: result.id || '',
score: typeof result.score === 'number' ? result.score : 0,
rank: result.entity.rank || 0,
title: result.entity.title || '',
url: result.entity.url || '',
likes: result.entity.likes || 0,
views: result.entity.views || 0
};
});
}
return processedResults;
} catch (error) {
console.error('搜索文章失败:', error);
throw error;
}
}
/**
* 获取所有文章
* @param {number} limit - 结果数量限制
* @returns {Promise<Array>} - 文章列表
*/
async getAllArticles(limit = 100) {
await this.connect();
try {
const result = await this.client.query({
collection_name: milvusConfig.milvus.collection,
output_fields: ['rank', 'title', 'url', 'likes', 'views'],
limit: limit
});
if (!result.data) {
return [];
}
return result.data.map(item => ({
id: item.id || '',
rank: item.rank || 0,
title: item.title || '',
url: item.url || '',
likes: item.likes || 0,
views: item.views || 0
}));
} catch (error) {
console.error('获取所有文章失败:', error);
throw error;
}
}
/**
* 关闭Milvus连接
*/
async close() {
if (this.client) {
try {
// Milvus SDK没有显式的close方法,但我们可以将client设为null
this.client = null;
this.isConnected = false;
} catch (error) {
console.error('关闭Milvus连接失败:', error);
}
}
}
}6. 界面组件开发 #
实现搜索栏组件 #
创建src/app/components/SearchBar.js文件:
'use client';
import { useState } from 'react';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
export default function SearchBar({ onSearch }) {
const [searchQuery, setSearchQuery] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSearch(searchQuery);
};
return (
<div className="w-full">
<form onSubmit={handleSubmit} className="relative">
<div className="relative">
<input
type="text"
className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="搜索文章..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
</div>
<button
type="submit"
className="absolute right-2.5 bottom-2.5 bg-blue-500 text-white rounded-lg px-4 py-1 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400"
>
搜索
</button>
</form>
</div>
);
}实现文章列表组件 #
创建src/app/components/ArticleList.js文件:
'use client';
import { HeartIcon, EyeIcon } from '@heroicons/react/24/outline';
export default function ArticleList({ articles, loading }) {
if (loading) {
return (
<div className="mt-8 w-full">
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
</div>
</div>
);
}
if (!articles || articles.length === 0) {
return (
<div className="mt-8 text-center p-8 bg-gray-50 rounded-lg">
<p className="text-gray-500">没有找到符合条件的文章</p>
</div>
);
}
return (
<div className="mt-8 w-full">
<h2 className="text-xl font-semibold mb-4">文章列表 ({articles.length})</h2>
<div className="space-y-4">
{articles.map((article, index) => (
<div
key={article.id || index}
className="p-4 border border-gray-200 rounded-lg hover:shadow-md transition-shadow"
>
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<h3 className="text-lg font-medium text-blue-600 hover:underline">
{article.title}
</h3>
<div className="mt-2 flex items-center text-sm text-gray-500">
<div className="flex items-center mr-4">
<HeartIcon className="h-4 w-4 mr-1 text-red-500" />
<span>{article.likes || 0} 喜欢</span>
</div>
<div className="flex items-center">
<EyeIcon className="h-4 w-4 mr-1 text-gray-500" />
<span>{article.views || 0} 浏览</span>
</div>
{article.score !== undefined && (
<div className="ml-auto">
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded">
相关度: {(article.score * 100).toFixed(1)}%
</span>
</div>
)}
</div>
</a>
</div>
))}
</div>
</div>
);
}7. API接口实现 #
实现文章API路由 #
创建src/app/api/articles/route.js文件:
import { NextResponse } from 'next/server';
import { MilvusService } from '../../utils/milvus-service';
import { formatErrorMessage, logServerError } from '../../utils/error-handler';
/**
* GET处理函数 - 获取所有文章或搜索文章
*/
export async function GET(request) {
const milvusService = new MilvusService();
const { searchParams } = new URL(request.url);
const query = searchParams.get('query') || '';
try {
let articles;
if (query) {
// 如果有查询参数,执行搜索
articles = await milvusService.searchArticles(query);
// 搜索结果已经在MilvusService中按相关度排序
} else {
// 否则获取所有文章
articles = await milvusService.getAllArticles();
// 无搜索时按排名排序
articles.sort((a, b) => a.rank - b.rank);
}
// 直接返回文章数组
return NextResponse.json(articles);
} catch (error) {
logServerError('API', error);
return NextResponse.json({
error: formatErrorMessage(error)
}, { status: 500 });
} finally {
await milvusService.close();
}
}实现主页组件 #
创建src/app/page.js文件,整合各个组件:
'use client';
import { useState, useEffect } from 'react';
import SearchBar from './components/SearchBar';
import ArticleList from './components/ArticleList';
export default function Home() {
const [query, setQuery] = useState('');
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// 获取文章的方法
const fetchArticles = async (searchQuery = '') => {
try {
setLoading(true);
console.log('开始获取文章, 查询:', searchQuery);
// 构建API请求URL
const url = searchQuery
? `/api/articles?query=${encodeURIComponent(searchQuery)}`
: '/api/articles';
const response = await fetch(url, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache'
}
});
if (!response.ok) {
throw new Error(`获取文章失败: ${response.statusText}`);
}
const data = await response.json();
// 确保articles是数组
if (Array.isArray(data)) {
setArticles(data);
} else if (data.error) {
// 如果返回了错误信息
throw new Error(data.error);
} else {
// 如果返回了不期望的数据结构
setArticles([]);
throw new Error('服务器返回了不正确的数据格式');
}
setError(null);
} catch (err) {
console.error('获取文章出错:', err);
setError(err.message);
setArticles([]);
} finally {
setLoading(false);
}
};
// 首次加载时获取所有文章
useEffect(() => {
fetchArticles();
}, []);
// 处理搜索
const handleSearch = async (searchQuery) => {
setQuery(searchQuery);
await fetchArticles(searchQuery);
};
return (
<main className="flex min-h-screen flex-col items-center p-8">
<div className="w-full max-w-3xl mx-auto">
<h1 className="text-3xl font-bold text-center mb-6">掘金文章浏览器</h1>
<SearchBar onSearch={handleSearch} />
{error && (
<div className="mt-4 p-4 bg-red-50 text-red-600 rounded-lg border border-red-200">
错误: {error}
</div>
)}
<ArticleList
articles={articles}
loading={loading}
/>
</div>
</main>
);
}8. 集成测试 #
创建启动脚本 #
为了方便Windows用户启动项目,创建start.bat文件:
@echo off
echo =============================
echo 掘金文章浏览器启动脚本
echo =============================
echo.
echo 正在安装依赖...
call npm install
echo.
echo 正在启动开发服务器...
echo.
echo 提示: 服务器启动后,请在浏览器访问 http://localhost:3000
echo.
echo 按 Ctrl+C 可停止服务器
echo.
call npm run dev
pause测试流程 #
- 确保Milvus服务已经启动并包含文章数据
- 运行
start.bat脚本启动开发服务器 - 在浏览器中访问
http://localhost:3000 - 测试搜索功能是否正常工作
- 检查文章列表展示是否正确
9. 项目优化 #
性能优化 #
- 图片优化:使用Next.js的Image组件优化图片加载
- 组件懒加载:对大型组件使用动态导入实现懒加载
- 缓存策略:实现API响应的缓存机制
用户体验优化 #
- 加载状态:添加骨架屏提升加载体验
- 错误处理:优化错误提示,使用户更容易理解问题
- 响应式设计:确保在各种设备上都有良好的显示效果
10. 部署上线 #
构建生产版本 #
运行以下命令构建生产版本:
npm run build部署选项 #
Vercel部署:
vercel服务器部署:
npm run build npm run startDocker部署: 创建
Dockerfile:FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build EXPOSE 3000 CMD ["npm", "start"]构建并运行Docker镜像:
docker build -t juejin-crawler-front . docker run -p 3000:3000 juejin-crawler-front
总结 #
通过本教程,我们已经完成了一个功能完整的掘金文章浏览器前端项目。该项目融合了现代前端技术和向量数据库技术,实现了基于语义的文章搜索功能。
关键技术点包括:
- Next.js应用程序的搭建
- Milvus向量数据库的集成
- 文本向量化与语义搜索的实现
- 响应式UI组件的开发
- 前后端结合的API设计
希望本教程能帮助你理解如何构建一个现代化的前端应用,并为你的项目提供参考。