# 资讯采集模块技术文档 ## 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. **`
` → 代码块(自动识别 `language-xxx` / `lang-xxx` 类名)
- `` → 行内代码
- `` / `` → `**粗体**`
- `` / `` → `*斜体*`
- 空行合并、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` 常为占位图,需对调
- **代码块**: `` 内每行是一个 ``,需合并为纯文本
- **空标签清理**: 移除不含文本也不含图片的空 `` / `
` / ``
### 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``` →
#### ~ # → ~
**bold** →
*italic* →
~~del~~ →
`code` →
> quote →
 →
(必须在 [link] 之前)
[text](url) →
- / 1. list → /
```
### 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链接支持、批量采集章节)*