446 lines
16 KiB
Markdown
446 lines
16 KiB
Markdown
# 资讯采集模块技术文档
|
||
|
||
## 1. 概述
|
||
|
||
资讯采集模块允许管理员从外部网页采集文章内容,自动转换为 Markdown 格式存入草稿,经人工编辑后发布。当前系统支持 **两种采集方式**:
|
||
|
||
| 采集方式 | 适用场景 | 入口 |
|
||
|---------|---------|------|
|
||
| **普通采集** | 技术博客、新闻网站等一般网页 | `POST /api/crawl` |
|
||
| **微信采集** | 微信公众号文章(含搜狗链接) | `POST /api/wechat-crawl/*` |
|
||
|
||
此外,`batch_crawl.py` 提供针对特定站点(niaodaifu.cn)的批量采集脚本,采用 Playwright 直接翻页抓取全站文章。
|
||
|
||
---
|
||
|
||
## 2. 架构总览
|
||
|
||
```
|
||
┌──────────────────────┐
|
||
│ 管理员输入URL │
|
||
└──────────┬───────────┘
|
||
│
|
||
┌────────────────┼────────────────┐
|
||
▼ ▼
|
||
┌─────────────────┐ ┌─────────────────┐
|
||
│ 普通采集 /api/crawl │ │ 微信采集 /api/ │
|
||
│ │ │ wechat-crawl/* │
|
||
│ Requests 优先 │ │ │
|
||
│ Playwright 兜底 │ │ Playwright + │
|
||
│ + Stealth 可选 │ │ Stealth 必需 │
|
||
└────────┬──────────┘ │ + Sogou 会话预热 │
|
||
│ └────────┬──────────┘
|
||
│ │
|
||
▼ ▼
|
||
┌─────────────────┐ ┌─────────────────┐
|
||
│ HTML → Markdown │ │ HTML → Markdown │
|
||
│ 图片下载到临时目录│ │ 图片下载到正式目录│
|
||
│ 数学公式修复 │ │ 直接创建文章入库 │
|
||
└────────┬──────────┘ └─────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────┐
|
||
│ 返回前端预览 │
|
||
│ 用户编辑后保存 │
|
||
└─────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 普通采集 (crawl.py)
|
||
|
||
**文件**: `backend/app/routers/crawl.py`
|
||
|
||
### 3.1 API 接口
|
||
|
||
**POST /api/crawl**
|
||
|
||
```json
|
||
{
|
||
"url": "https://juejin.cn/post/xxx",
|
||
"use_playwright": false
|
||
}
|
||
```
|
||
|
||
返回采集结果(标题、Markdown 正文、已下载的图片路径),不直接入库,由前端展示预览后用户决定是否保存。
|
||
|
||
### 3.2 双模式采集策略
|
||
|
||
| 模式 | 适用场景 | 优点 | 缺点 |
|
||
|------|---------|------|------|
|
||
| Requests | 普通静态网站(博客园、菜鸟教程等) | 速度快、资源占用低 | 无法执行 JS |
|
||
| Playwright | SPA / 反爬网站 | 可执行 JS、绕过 WAF | 启动慢、内存占用高 |
|
||
|
||
**强制使用 Playwright 的平台:**
|
||
|
||
- **微信公众号** (`mp.weixin.qq.com`) — Requests 会被拦截
|
||
- **掘金** (`juejin.cn`) — 返回混淆代码
|
||
- **阿里云开发者社区** (`developer.aliyun.com`) — 语雀编辑器懒加载
|
||
|
||
**Playwright 自动兜底触发条件:**
|
||
|
||
- HTTP 状态码 468 / 403 / 429 / 503(被 WAF 拦截)
|
||
- 页面文本内容 < 200 字符(检测到 SPA 骨架)
|
||
- `is_spa_site()` 检测到 `#root`、`#app`、`data-reactroot` 等特征
|
||
|
||
### 3.3 反爬虫配置
|
||
|
||
Playwright 启动时注入以下配置绕过常见反爬:
|
||
|
||
```python
|
||
browser = p.chromium.launch(
|
||
headless=True,
|
||
args=[
|
||
'--disable-blink-features=AutomationControlled',
|
||
'--disable-web-security',
|
||
'--disable-features=IsolateOrigins,site-per-process',
|
||
'--no-sandbox',
|
||
'--disable-setuid-sandbox',
|
||
]
|
||
)
|
||
|
||
context.add_init_script("""
|
||
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
|
||
Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]});
|
||
window.chrome = { runtime: {} };
|
||
""")
|
||
```
|
||
|
||
若安装了 `playwright-stealth`,自动启用 `stealth_sync(page)` 进一步增强隐匿。
|
||
|
||
> **注意**:普通采集使用同步 Playwright API(`sync_playwright`),避免 Windows 下 asyncio 子进程兼容问题。
|
||
|
||
### 3.4 各平台特殊处理
|
||
|
||
| 平台 | 等待策略 | 标题选择器 | 正文选择器 |
|
||
|------|---------|-----------|-----------|
|
||
| 掘金 | `article` 出现(15s) | `article h1` → `h1` | `.markdown-body` → `article` |
|
||
| 阿里云 | `.article-content` + 滚动5次 | `h1` | `.article-content` → `.article-detail` |
|
||
| SegmentFault | `networkidle` + 8s | `h1.article-title` → `h1` | `.article-content` → `.markdown-body` → `article` |
|
||
| 腾讯云 | `[class*="markdown"]` | `h1` → `.article-title` | `.mod-content__markdown` |
|
||
|
||
### 3.5 标题提取策略
|
||
|
||
按优先级依次尝试(`extract_title_from_html`):
|
||
|
||
1. **Open Graph** `og:title` meta 标签
|
||
2. **博客园** `h1.postTitle` / `a#cb_post_title_url`
|
||
3. **人人都是产品经理** `.article--title`
|
||
4. **菜鸟教程** `.article-intro h1`
|
||
5. **`<title>` 标签**(清理 `| 站点名` 等后缀)
|
||
6. **`<h1>` 标签**
|
||
7. **内容区 `<h2>` / `<h3>`**
|
||
8. **Twitter Card** `twitter:title`
|
||
9. 回退 `"未命名文章"`
|
||
|
||
### 3.6 内容提取策略
|
||
|
||
**BeautifulSoup 优先选择器(按优先级):**
|
||
|
||
```
|
||
.article--content.grap # 人人都是产品经理
|
||
#cnblogs_post_body # 博客园
|
||
.article-intro # 菜鸟教程
|
||
article, main # 通用
|
||
.post-content / .article-content / .entry-content
|
||
#content / #main-content
|
||
```
|
||
|
||
BS4 不可用时回退正则匹配。
|
||
|
||
### 3.7 HTML 转 Markdown
|
||
|
||
- `<img>` → ``(URL 自动补全为绝对路径,负责处理 `//`、`/` 前缀)
|
||
- `<h1~h6>` → `# ~ ######`
|
||
- `<pre><code>` → 代码块(自动识别 `language-xxx` / `lang-xxx` 类名)
|
||
- `<code>` → 行内代码
|
||
- `<strong>` / `<b>` → `**粗体**`
|
||
- `<em>` / `<i>` → `*斜体*`
|
||
- 空行合并、HTML 实体解码
|
||
|
||
### 3.8 数学公式修复
|
||
|
||
`fix_math_formulas()` 处理掘金等平台采集到的 LaTeX 公式格式混乱:
|
||
|
||
- 检测块级公式关键词(`\begin{array}`、`\sum_`、`\int_` 等),自动包裹 `$$...$$`
|
||
- 行内公式检测 `\[a-zA-Z]+` 模式,包裹 `\(...\)`
|
||
- 规范化反斜杠(双重转义 → 单重)
|
||
- 清理已渲染的数学符号(上标、下标 Unicode 字符)
|
||
|
||
### 3.9 图片处理
|
||
|
||
**下载流程:**
|
||
1. 从 Markdown 提取 `` 中的图片 URL
|
||
2. 下载到临时目录(Linux: `/tmp/news_crawl/`)
|
||
3. 防盗链检测:文件 < 1KB 则丢弃
|
||
4. 图片完整性校验:PIL `verify()`
|
||
5. 生成缩略图到临时目录 `thumbnails/` 子目录
|
||
6. 替换 Markdown 中的远程 URL 为临时路径
|
||
|
||
**关键请求头:**
|
||
|
||
```python
|
||
headers = {
|
||
'User-Agent': 'Mozilla/5.0 ...',
|
||
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||
'Referer': request.url, # 模拟来源页面
|
||
}
|
||
# 微信图片特化处理
|
||
if 'mmbiz.qpic.cn' in img_url:
|
||
headers['Referer'] = 'https://mp.weixin.qq.com/'
|
||
```
|
||
|
||
**保存时迁移**:调用 `create_post` 时,临时图片被识别并迁移到正式目录 `storage/admin/images/`。
|
||
|
||
---
|
||
|
||
## 4. 微信公众号采集 (wechat_crawl.py)
|
||
|
||
**文件**: `backend/app/routers/wechat_crawl.py`
|
||
|
||
> 微信采集与普通采集是两个独立的路由模块。微信采集使用**异步 Playwright + Stealth**,并提供完整的**预览 → 保存**工作流,直接创建文章入库。
|
||
|
||
### 4.1 API 接口
|
||
|
||
| 端点 | 方法 | 说明 |
|
||
|------|------|------|
|
||
| `/api/wechat-crawl/preview` | POST | 预览文章(采集内容但不入库) |
|
||
| `/api/wechat-crawl/save` | POST | 保存文章(入库 + 下载图片) |
|
||
| `/api/wechat-crawl/columns` | POST | 获取可用栏目列表 |
|
||
|
||
### 4.2 支持的链接格式
|
||
|
||
- **微信公众号直链**: `https://mp.weixin.qq.com/s/...`
|
||
- **搜狗微信链接**: `https://weixin.sogou.com/...`(自动会话预热绕反爬)
|
||
|
||
### 4.3 搜狗链接反爬绕过机制
|
||
|
||
搜狗对直接访问的请求会 302 跳转到验证码页面。绕过方法如下:
|
||
|
||
```python
|
||
# 同一浏览器会话:先访问搜狗搜索页面激活 session
|
||
await page.goto(
|
||
"https://weixin.sogou.com/weixin?type=2&query=test",
|
||
wait_until="networkidle", timeout=30000,
|
||
)
|
||
await asyncio.sleep(2)
|
||
|
||
# 再访问目标链接(此时携带有 SNUID/SUID cookie)
|
||
await page.goto(url, wait_until="networkidle", timeout=30000)
|
||
```
|
||
|
||
搜狗链接最终会重定向到 `mp.weixin.qq.com` 的真实文章页面,后续提取流程与直链一致。
|
||
|
||
### 4.4 Stealth 配置
|
||
|
||
微信采集对反爬要求更高,强制使用 `playwright_stealth`:
|
||
|
||
```python
|
||
async with async_playwright() as p:
|
||
browser = await p.chromium.launch(headless=True, args=[...])
|
||
context = await browser.new_context(
|
||
viewport={"width": 1920, "height": 1080},
|
||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64)...",
|
||
locale="zh-CN",
|
||
)
|
||
page = await context.new_page()
|
||
if STEALTH_AVAILABLE:
|
||
await stealth_async(page) # 异步 Stealth
|
||
```
|
||
|
||
### 4.5 内容提取流程
|
||
|
||
1. **处理弹窗**: 检测微信 PC 端二维码弹窗 `#js_pc_qr_code_img`,自动点击关闭按钮
|
||
2. **标题**: `page.title()`,去除 ` - 公众号名` 后缀
|
||
3. **正文选择器**: `#js_content` → `.rich_media_content` → `#js_article` → `body`
|
||
4. **作者**: `#js_name` / `.rich_media_meta_nickname` / `.profile_nickname`
|
||
5. **摘要**: `#js_description` / `.rich_media_meta_desc`
|
||
6. **封面图**: `meta[property="og:image"]` → `#js_cover` → `.rich_media_cover img`
|
||
|
||
### 4.6 HTML 修复 (fix_wechat_html)
|
||
|
||
微信公众号 HTML 有特殊格式问题,需要专项修复:
|
||
|
||
- **图片 `data-src`**: 微信用 `data-src` 存真实 URL,`src` 常为占位图,需对调
|
||
- **代码块**: `<pre class="code-snippet__js">` 内每行是一个 `<span class="code-snippet__line">`,需合并为纯文本
|
||
- **空标签清理**: 移除不含文本也不含图片的空 `<p>` / `<div>` / `<section>`
|
||
|
||
### 4.7 HTML → Markdown 转换
|
||
|
||
使用 `markdownify` 库:
|
||
|
||
```python
|
||
md = markdownify.markdownify(
|
||
html,
|
||
heading_style="ATX",
|
||
bullets="-",
|
||
strip=["script", "style"],
|
||
)
|
||
```
|
||
|
||
### 4.8 保存流程
|
||
|
||
`POST /api/wechat-crawl/save` 一次性完成:
|
||
|
||
1. 校验栏目是否存在
|
||
2. 下载封面图 → `storage/admin/images/{YYYYMM}/{uuid}.jpg`
|
||
3. 从 Markdown 提取所有图片 URL,逐一下载并替换为本地路径
|
||
4. 图片记录写入 `images` 表
|
||
5. 生成 slug(`pypinyin` 中文转拼音,回退 `hashlib.md5`)
|
||
6. 创建 `Article` 记录(状态 `draft`)
|
||
|
||
**图片存储**:统一按年月子目录存储(如 `storage/admin/images/202605/xxx.jpg`),不直接放根目录。
|
||
|
||
---
|
||
|
||
## 5. 批量采集 (batch_crawl.py)
|
||
|
||
**文件**: `backend/batch_crawl.py`
|
||
|
||
独立的命令行脚本,用于批量采集 niaodaifu.cn 健康资讯栏目的全部文章。
|
||
|
||
### 5.1 工作原理
|
||
|
||
```
|
||
Playwright 打开首页 → 遍历栏目列表页 → 翻页获取全部文章 URL
|
||
→ 逐篇打开详情页 → 提取标题/正文/图片 → 下载图片 → HTML→MD
|
||
→ 直接写入 SQLite (news_posts / images 表)
|
||
```
|
||
|
||
### 5.2 栏目映射 (MENU_TO_CATEGORY)
|
||
|
||
```python
|
||
MENU_TO_CATEGORY = {
|
||
15: (2, "科普文章"),
|
||
7: (3, "肾友"),
|
||
8: (4, "糖友"),
|
||
6: (5, "健康"),
|
||
}
|
||
```
|
||
|
||
### 5.3 关键差异
|
||
|
||
与普通采集的区别:
|
||
|
||
- 直接操作 `news_posts` SQLite 表(不走 FastAPI ORM)
|
||
- 无需用户交互,全自动翻页抓取
|
||
- 图片存储在 `storage/admin/images/{YYYYMM}/`
|
||
- slug 用 `hashlib.md5(url)` 生成,支持去重
|
||
- 包含专用 `html_to_markdown()` 递归实现(处理嵌套 HTML 结构)
|
||
|
||
---
|
||
|
||
## 6. 前端 Markdown 渲染
|
||
|
||
### 6.1 访客端渲染架构
|
||
|
||
访客端文章详情页 (`/news/:slug`) 采用 **纯手写正则替换 + KaTeX 公式渲染** 的轻量方案。
|
||
|
||
```
|
||
Markdown 原文
|
||
│
|
||
▼
|
||
renderMarkdown() ──▶ HTML 字符串
|
||
│
|
||
▼
|
||
dangerouslySetInnerHTML ──▶ DOM 插入
|
||
│
|
||
▼
|
||
KaTeX auto-render ──▶ 数学公式渲染
|
||
```
|
||
|
||
### 6.2 renderMarkdown 处理顺序
|
||
|
||
关键:代码块在行内代码之前处理,图片在链接之前处理:
|
||
|
||
```
|
||
```lang\ncode``` → <pre><code>
|
||
#### ~ # → <h4> ~ <h1>
|
||
**bold** → <strong>
|
||
*italic* → <em>
|
||
~~del~~ → <del>
|
||
`code` → <code>
|
||
> quote → <blockquote>
|
||
 → <img> (必须在 [link] 之前)
|
||
[text](url) → <a>
|
||
- / 1. list → <ul>/<ol>
|
||
```
|
||
|
||
### 6.3 KaTeX 数学公式
|
||
|
||
通过 CDN 加载,支持的格式:
|
||
|
||
| 格式 | 类型 | 示例 |
|
||
|------|------|------|
|
||
| `$$...$$` | 块级居中 | `$$E = mc^2$$` |
|
||
| `\[...\]` | 块级居中 | `\[\sum_{i=1}^n x_i\]` |
|
||
| `\(...\)` | 行内嵌入 | `\(\alpha + \beta\)` |
|
||
| `$...$` | 行内嵌入 | `$x^2 + y^2 = r^2$` |
|
||
|
||
渲染时机:DOM 插入后 100ms + 500ms 重试,确保 KaTeX 库加载完毕。
|
||
|
||
### 6.4 样式适配
|
||
|
||
使用 Tailwind CSS `prose` 类 + 自定义内联样式:
|
||
|
||
- 代码块:`bg-slate-900 text-slate-100`
|
||
- 行内代码:`bg-emerald-50 text-emerald-700 border-emerald-200`
|
||
- 引用块:`border-l-4 border-emerald-400 bg-emerald-50/50`
|
||
- 链接:`text-emerald-600 hover:text-emerald-700`
|
||
|
||
---
|
||
|
||
## 7. 关键文件清单
|
||
|
||
| 文件 | 职责 |
|
||
|------|------|
|
||
| `backend/app/routers/crawl.py` | 普通采集核心逻辑(双模式、HTML→MD、图片下载、公式修复) |
|
||
| `backend/app/routers/wechat_crawl.py` | 微信采集(Playwright+Stealth、Sogou会话预热、预览→保存工作流) |
|
||
| `backend/batch_crawl.py` | 批量采集脚本(niaodaifu.cn 整站抓取) |
|
||
| `backend/app/main.py` | 挂载 `/storage/tmp` 静态目录、启动时清理过期临时文件 |
|
||
| `frontend/src/pages/admin/news/CrawlNews.jsx` | 普通采集界面(URL输入、结果预览、编辑) |
|
||
| `frontend/src/pages/admin/news/WechatCrawlEdit.jsx` | 微信采集界面(URL输入、预览、栏目选择、保存) |
|
||
| `frontend/src/pages/NewsDetail.jsx` | 访客端文章详情(Markdown渲染 + KaTeX公式) |
|
||
| `frontend/index.html` | KaTeX CDN 引入 |
|
||
|
||
---
|
||
|
||
## 8. 常见问题
|
||
|
||
### Q1: 普通采集和微信采集有什么区别?
|
||
|
||
| 维度 | 普通采集 | 微信采集 |
|
||
|------|---------|---------|
|
||
| API 端点 | `/api/crawl` | `/api/wechat-crawl/*` |
|
||
| Playwright | 同步 API,条件启用 | 异步 API,始终启用 |
|
||
| Stealth | 可选 | 必需 |
|
||
| 保存流程 | 返回预览,编辑器手动保存 | 预览后一键保存入库 |
|
||
| 图片目录 | 临时目录 → 迁移 | 直接存入正式目录 |
|
||
| 支持链接 | 一般 URL | `mp.weixin.qq.com` + `weixin.sogou.com` |
|
||
|
||
### Q2: 搜狗链接为什么需要"会话预热"?
|
||
|
||
搜狗对直接访问的链接会 302 到验证码页面。通过先在同一个浏览器会话中访问一次搜狗搜索页面,浏览器获得 `SUID` / `SNUID` cookie,之后再跳转目标链接就能正常通过反爬检测。
|
||
|
||
### Q3: 为什么掘金采集必须使用 Playwright?
|
||
|
||
掘金返回的 HTML 是经过混淆的 JavaScript 代码,Requests 无法解析。必须用浏览器执行 JS 后才能得到真实内容。
|
||
|
||
### Q4: 图片为什么先放临时目录(普通采集)?
|
||
|
||
- 避免未确认的文章污染正式图库
|
||
- 不保存的文章不会留下垃圾文件
|
||
- 保存时一次性迁移并入库,保证数据一致性
|
||
|
||
### Q5: 如何支持更多平台?
|
||
|
||
**普通采集**:在 `crawl_with_playwright()` 中新增平台检测变量和对应的等待选择器、标题/正文选择器。
|
||
|
||
**微信采集**:在 `crawl_wechat_article()` 中新增正文选择器或链接格式支持。
|
||
|
||
---
|
||
|
||
*文档版本: 2.0*
|
||
*创建时间: 2026-04-21*
|
||
*更新时间: 2026-05-18(新增微信采集独立模块、Sogou链接支持、批量采集章节)*
|