Files
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

446 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 资讯采集模块技术文档
## 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>` → `![alt](url)`(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 提取 `![alt](url)` 中的图片 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>
![alt](url) → <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链接支持、批量采集章节)*