# 文章采集方案详解
本文档详细介绍个人博客 CMS 系统中的三种文章采集方案,包括技术选型、实现原理、各平台适配策略,以及前端交互流程。
## 一、总体架构
系统提供 **三种采集入口**,对应不同的技术策略和适用场景:
| 采集方案 | 前端入口 | 后端路由 | 核心引擎 | 适用场景 |
|---------|---------|---------|---------|---------|
| **通用网页采集**(方案一) | "采集文章" | `/api/crawl/` | `curl_cffi` + `Playwright` | 传统网站:人人都是产品经理、博客园、菜鸟教程等 |
| **微信公众号采集**(方案二) | "公众号采集" | `/api/wechat-crawl/` | `Playwright` + `playwright_stealth` | 微信公众号文章(`mp.weixin.qq.com`) |
| **通用采集**(方案三) | "通用采集" | `/api/universal-crawl/` | `curl_cffi` + `Playwright` | 新一代技术社区:InfoQ、掘金、CSDN、51CTO、博客园等 |
三种方案的前端交互流程完全一致(输入 URL → 预览 → 编辑 → 保存入库),区别仅在于后端采集引擎和平台适配策略。
## 二、方案一:通用网页采集(`crawl.py`)
这是系统最早实现的采集方案,采用**多引擎组合**策略,根据目标站点的特征选择最合适的请求方式。
### 2.1 技术栈
- **HTTP 请求**:`curl_cffi`(默认,带浏览器指纹伪装)+ `requests`(备选)
- **浏览器渲染**:`Playwright`(用于 SPA/JS 渲染型站点)
- **HTML 解析**:`BeautifulSoup`(`lxml` 加速)
- **Markdown 转换**:`markdownify`(备选)+ 自研 `html_to_markdown`(主用)
### 2.2 核心流程
```
1. 提取标题 → extract_title_from_html(html)
2. 判断站点类型 → is_spa_site(html) ? SPA : 传统站点
3. 获取页面 HTML → curl_cffi / Playwright
4. 提取主要内容 → extract_main_content(html) / convert_spa_html_to_markdown()
5. 修复编码乱码 → fix_encoding(text)
6. HTML 转 Markdown → html_to_markdown(html, url)
7. 提取文章图片 → extract_images(html, url)
8. 下载图片到本地 → download_image() / download_all_images()
9. 替换 Markdown 中的图片 URL 为本地路径
10. 保存到数据库
```
### 2.3 引擎选择策略
系统根据目标域名自动判断使用哪个引擎获取 HTML:
```python
# 强制使用 Playwright 的域名(SPA / JS 渲染 / 反爬严格)
browser_domains = [
'mp.weixin.qq.com', # 微信公众号
'juejin.cn', # 掘金
'zhihu.com', # 知乎
'segmentfault.com', # SegmentFault
'infoq.cn', # InfoQ
]
# 默认使用 curl_cffi(高性能、带浏览器指纹)
# 备选 requests(纯文本站点)
```
### 2.4 平台专用适配
`extract_main_content()` 内置了多个平台的 CSS 选择器优先级列表:
| 平台 | 选择器 | 说明 |
|------|--------|------|
| 人人都是产品经理 | `.article--content.grap` | 文章正文区域 |
| 博客园 | `#cnblogs_post_body` | 博客正文容器 |
| 菜鸟教程 | `.article-intro` | 教程内容区域 |
| 掘金 | `article` | 文章标签 |
| 通用 | `article`, `main`, `.post-content`, `.article-content`, `.entry-content`, `#content` | 回退选择器 |
如果 BeautifulSoup 解析失败,系统会回退到**正则表达式匹配**。
### 2.5 上层业务逻辑
#### 2.5.1 编码修复(`fix_encoding`)
处理 UTF-8 内容被错误解码为 Latin-1 的情况(常见于老旧网站或代理转发),支持**单层修复**和**双重编码修复**:
```python
# 检测乱码特征:é, è, Ã, Â, Ã¥, â€, æ, å, è, § 等
garbled_patterns = ['é', 'è', 'Ã', 'Â', 'Ã¥', 'ü', 'ö', 'ä', 'ß', 'â€', 'æ', 'å', 'è', '§']
# 单层修复:latin1 → utf-8
text.encode('latin1').decode('utf-8')
# 双重编码修复:utf-8 → latin1 → utf-8 → latin1 → utf-8
text.encode('utf-8').decode('latin1').encode('latin1').decode('utf-8')
```
#### 2.5.2 数学公式修复(`fix_math_formulas`)
检测 Markdown 中混排的 LaTeX 数学公式,进行规范化包裹:
- **块级公式**(含 `\begin{array}`, `\sum_`, `\int_`, `\frac`, `\prod`, `\lim`, `\mathbf`, `\alpha`, `\beta`, `\gamma` 等)→ 用 `$$...$$` 包裹
- **行内公式**(含 `_{`, `^{`, `\mathbf`, `\alpha`, `\sum`, `\int`, `\frac`, `\sqrt`, `\lim`, `\prod`, `\binom`, `\overline`, `\underline`, `\widehat`, `\widetilde`, `\mathcal`, `\mathbb` 等)→ 用 `\(...\)` 包裹
- 清理已渲染的 Unicode 数学运算符
- 规范化双重反斜杠为单重
#### 2.5.3 HTML → Markdown 转换(`html_to_markdown`)
自研转换器,支持以下标签:
- `` → ``,自动将相对 URL 转为绝对 URL
- `
` → 段落文本 - `
` → 代码块(自动识别 `language-xxx` 类名)
- `` → 行内代码
- ``, `` → `**粗体**`
- ``, `` → `*斜体*`
- 修复混合内容问题(HTTPS 页面中的 HTTP 图片自动升级)
- 处理 HTML 实体(` `, `<`, `>` 等)
#### 2.5.4 图片下载(`download_image`)
- 支持 `curl_cffi` 和 `requests` 双引擎下载
- 限制最大 10MB,超时 15 秒
- 生成唯一文件名:`{timestamp}_{hash[:8]}{ext}`
- 保存到 `uploads/` 目录
- 保存后返回本地 URL 映射表
### 2.6 已知局限
- 对 InfoQ 的 `.ProseMirror` 容器支持不够精细,会混入导航噪声
- 对掘金、CSDN、51CTO 等平台无专用提取逻辑,正文可能夹杂大量 UI 元素
- 对知乎无登录态支持,无法采集
---
## 三、方案二:微信公众号文章采集(`wechat_crawl.py`)
专门针对微信公众号文章(`mp.weixin.qq.com`)设计的采集方案,处理微信特有的反爬机制和页面结构。
### 3.1 技术栈
- **浏览器引擎**:`Playwright` + `playwright_stealth`(反检测)
- **HTML 解析**:`BeautifulSoup`
- **Markdown 转换**:`markdownify` + 自研微信专用修复函数
### 3.2 核心流程
```
1. Playwright 打开微信文章 URL(wait_until="networkidle", 60s 超时)
2. 注入反检测脚本(隐藏 webdriver、伪造插件列表、模拟 chrome.runtime)
3. 启用 playwright_stealth(如果可用)
4. 提取标题(优先 page.title(),回退到 #activity_name)
5. 提取公众号名称(#js_name)
6. 提取正文 HTML(#js_content)
7. 提取封面图(og:image meta)
8. 提取文章内图片(data-src 属性)
9. 微信专用 HTML 修复
10. 转换为 Markdown
11. 预览 → 下载图片(带 Referer 防盗链) → 入库
```
### 3.3 微信专用处理
#### 3.3.1 data-src 修复(`fix_wechat_images`)
微信文章使用 `data-src` 存放真实图片 URL,`src` 往往是 SVG 占位图或空值:
```python
for img in soup.find_all('img'):
data_src = img.get('data-src', '')
src = img.get('src', '')
# data-src 是真实 URL,且 src 不是有效的 http URL
if data_src and data_src.startswith('http') and (
not src or src.startswith('data:image') or not src.startswith('http')
):
img['src'] = data_src
# 清理无意义的占位属性
del img['data-src']
```
#### 3.3.2 代码块修复(`fix_wechat_code_blocks`)
微信代码块使用 `` + `` + `` 结构,需要按行重组:
```python
for pre in soup.find_all('pre', class_=re.compile('code-snippet')):
code_tag = pre.find('code')
lines = []
for span in code_tag.find_all('span', class_='code-snippet__line'):
lines.append(span.get_text(separator='', strip=False))
code_tag.clear()
for line in lines:
code_tag.append(NavigableString(line + '\n'))
```
#### 3.3.3 空段落清理(`remove_empty_paragraphs`)
移除没有文本内容也没有图片的空 `` 标签。
#### 3.3.4 微信图片防盗链绕过
微信图片服务器(`mmbiz.qpic.cn`)对直接访问会返回 403,必须在请求头中带上 `Referer`:
```python
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Referer": "https://mp.weixin.qq.com/", # 关键!
}
```
下载时通过 `curl_cffi` 或 `requests` 发送带 Referer 的请求,成功后将图片保存到本地。
### 3.4 测试结果
微信公众号文章采集**测试通过**,标题、正文、代码块、图片均能正常采集。腾讯的反爬策略主要集中在异常流量检测,Playwright + stealth 组合可以稳定绕过。
---
## 四、方案三:通用采集(`universal_crawl.py`)
这是为**新一代技术社区**专门设计的采集方案,核心理念是**"专用选择器提取 + 复用上层业务逻辑"**。
### 4.1 设计目标
- 针对各平台页面结构差异大、通用提取效果差的问题
- 为每个主流平台编写专用 CSS 选择器,精准提取正文
- 复用 `crawl.py` 中已验证成熟的上层业务逻辑(编码修复、数学公式、HTML→Markdown、图片下载)
- 保持与方案一/方案二完全一致的前端交互流程
### 4.2 技术栈
- **HTTP 请求**:`curl_cffi`(默认)+ `Playwright`(SPA / 反爬严格站点)
- **HTML 解析**:`BeautifulSoup`(`lxml` 加速)
- **上层逻辑**:全部从 `crawl.py` 复用:`extract_title_from_html`、`fix_encoding`、`fix_math_formulas`、`html_to_markdown`、`extract_images`、`extract_images_from_markdown`、`download_image`、`download_all_images`
### 4.3 引擎选择策略
```python
browser_domains = [
'mp.weixin.qq.com', # 微信公众号
'juejin.cn', # 掘金(JS 渲染)
'zhihu.com', # 知乎(未登录无法采集)
'segmentfault.com', # SegmentFault(WAF 拦截)
'infoq.cn', # InfoQ(JS 渲染)
'51cto.com', # 51CTO(JS 渲染)
]
```
对于非 browser_domains 的站点,默认使用 `curl_cffi` 获取 HTML,性能更好。
### 4.4 Playwright 反爬配置
通用采集的 Playwright 配置比方案一更完善:
```python
browser = await p.chromium.launch(
headless=True,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-web-security",
"--disable-features=IsolateOrigins,site-per-process",
"--disable-dev-shm-usage",
"--no-sandbox",
]
)
context = await browser.new_context(
user_agent="...Chrome/120.0.0.0 Edg/120.0.0.0...",
viewport={"width": 1920, "height": 1080},
locale="zh-CN",
timezone_id="Asia/Shanghai",
extra_http_headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="120", "Microsoft Edge";v="120"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"Windows"',
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
},
)
```
对于知乎,采用**先访问首页建立 Cookie,再跳转目标页面**的策略:
```python
if 'zhihu.com' in url:
await page.goto("https://www.zhihu.com", wait_until="domcontentloaded", timeout=15000)
await asyncio.sleep(2)
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
await asyncio.sleep(3)
# 等待正文加载
await page.wait_for_selector(".Post-RichTextContainer, .RichContent-inner", timeout=10000)
```
### 4.5 平台专用适配
#### 4.5.1 专用内容提取
| 平台 | 主选择器 | 备选选择器 | 说明 |
|------|---------|-----------|------|
| **InfoQ** | `.ProseMirror` | `article` | InfoQ 使用 ProseMirror 编辑器渲染正文,包含干净的结构化内容 |
| **掘金** | `article.article` | `.article-viewer`, `#article-root` | 掘金文章正文在 `` 中 |
| **CSDN** | `#content_views` | `.article_content` | CSDN 正文主容器,排除侧边栏、评论等噪声 |
| **博客园** | `#cnblogs_post_body` | `.postBody` | 博客园经典正文容器 |
| **51CTO** | `.article-content` | `article` | 51CTO 文章正文区域 |
#### 4.5.2 专用标题提取
各平台 `` 标签通常包含站点后缀,需要清洗:
| 平台 | 清洗规则 |
|------|---------|
| **InfoQ** | 去掉 `" - InfoQ"` 后缀 |
| **掘金** | 去掉 `" - 掘金"` 后缀 |
| **CSDN** | 去掉 `"-CSDN博客"` / `" - CSDN博客"` 后缀 |
| **博客园** | 去掉 `" - 博客园"` 后缀 |
| **51CTO** | 去掉 `"-51CTO.COM"` 后缀 |
#### 4.5.3 图片过滤
各平台正文容器中常常混入 UI 图标(收藏、点赞、logo、二维码等),需要过滤:
| 平台 | 过滤规则 |
|------|---------|
| **CSDN** | 跳过 `newHeart2023`、`tobarCollect`、`tobarCollection`、`copyright`、`avatar`、`blogv2/dist/pc/img` 等关键词的图片 |
| **博客园** | 跳过 `assets.cnblogs.com/logo`、`assets.cnblogs.com/icons`、`skins/custom/images/logo` |
| **51CTO** | 跳过公众号矩阵二维码(`oss/202302`)、软考二维码(`oss/202408`)、顶部横幅(`oss/202506/06`)、APP 下载图(`oss/202302/07`) |
### 4.6 测试结果汇总
| 平台 | 测试链接 | 结果 | 正文长度 | 图片数 | 说明 |
|------|---------|------|---------|--------|------|
| **微信公众号** | `mp.weixin.qq.com/s/...` | ✅ 正常 | ~6000 | 24 | Playwright + stealth 稳定采集,防盗链通过 |
| **InfoQ** | `infoq.cn/article/...` | ✅ 正常 | ~9000 | 2 | `.ProseMirror` 精准提取,无导航噪声 |
| **掘金** | `juejin.cn/post/...` | ✅ 正常 | ~6253 | 26 | `article.article` 精准提取,JS 渲染正常 |
| **CSDN** | `blog.csdn.net/...` | ✅ 正常 | ~9678 | 91 | `#content_views` 精准提取,过滤 UI 图标 |
| **博客园** | `cnblogs.com/...` | ✅ 正常 | ~17092 | 17 | `#cnblogs_post_body` 精准提取,过滤 logo |
| **51CTO** | `51cto.com/article/...` | ✅ 正常 | ~14177 | 36 | `.article-content` 精准提取,过滤广告二维码 |
| **知乎** | `zhuanlan.zhihu.com/...` | ❌ 失败 | — | — | 强制登录,未登录跳转登录页,无法采集 |
| **SegmentFault** | `segmentfault.com/...` | ❌ 失败 | — | — | 雷池 WAF 拦截,返回 "客户端异常,请确认您是合法用户" |
#### 详细说明
**✅ 微信公众号**
- 使用 Playwright + `playwright_stealth` 模拟真实浏览器
- 注入反检测脚本隐藏 `navigator.webdriver`
- 图片使用 `data-src` 修复,下载时带 `Referer: https://mp.weixin.qq.com/` 绕过防盗链
- 代码块、空段落、标题均处理正常
**✅ InfoQ**
- InfoQ 页面使用 JS 渲染,必须走 Playwright
- 通用提取会把导航栏、推荐阅读等混入正文(37265 字符)
- 使用 `.ProseMirror` 专用选择器后,正文精简到 9000 字符,完全无噪声
**✅ 掘金**
- 掘金是 SPA,必须走 Playwright
- 通用提取会混入头部导航和底部推荐
- 使用 `article.article` 专用选择器后,正文从导航处开始,干净整洁
**✅ CSDN**
- CSDN 页面为服务端渲染,可用 curl_cffi 获取
- 通用提取会混入收藏按钮、版权声明、专栏信息、相关推荐等
- 使用 `#content_views` 专用选择器 + UI 图标过滤后,正文从第一张图开始
**✅ 博客园**
- 博客园页面为服务端渲染,可用 curl_cffi 获取
- 通用提取混入博客园 logo、会员周边、搜索栏、用户头像等
- 使用 `#cnblogs_post_body` 专用选择器 + logo 过滤后,正文干净
**✅ 51CTO**
- 51CTO 使用 JS 渲染,必须走 Playwright
- 通用提取混入首页导航、AI 社区、博客学堂、公众号矩阵二维码等
- 使用 `.article-content` 专用选择器 + 二维码过滤后,正文从编辑信息开始
**❌ 知乎**
- 知乎对所有未登录的浏览器请求强制跳转登录页
- 即使先访问首页建立 Cookie、使用 stealth、调整 viewport 均无效
- 这是知乎的反爬策略,与采集工具无关,必须提供登录态 Cookie 才能解决
**❌ SegmentFault**
- SegmentFault 部署了**雷池 WAF**(SafeLine Web Application Firewall)
- 无论 curl_cffi(多种浏览器指纹)还是 Playwright(先访问首页建立信任)均被拦截
- 返回 HTTP 468 + "安全检测能力由 雷池 WAF 驱动 / 客户端异常,请确认您是合法用户"
- 可能需要更换代理 IP 或使用更高级的设备指纹模拟才能绕过
### 4.7 扩展新平台的方法
要为新平台添加支持,只需在 `universal_crawl.py` 中完成四步:
1. **判断域名**:在 `fetch()` 中添加 `is_xxx = 'xxx.com' in url`
2. **提取标题**:编写 `_extract_xxx_title(html)` 函数,清洗 `` 后缀
3. **提取正文**:编写 `_extract_xxx_content(html)` 函数,返回精准容器的 HTML
4. **过滤图片**(可选):编写 `_filter_xxx_images(images)` 函数,过滤 UI 图标
5. **接入主流程**:在 `fetch()` 的标题提取、正文转换、图片过滤三个分支中加入新平台
不需要修改 `html_to_markdown`、`fix_encoding`、`fix_math_formulas` 等上层逻辑,它们对所有平台通用。
---
## 五、三种方案对比
| 维度 | 方案一(通用网页) | 方案二(公众号) | 方案三(通用采集) |
|------|------------------|----------------|------------------|
| **目标站点** | 人人都是产品经理、博客园、菜鸟教程 | 微信公众号 | InfoQ、掘金、CSDN、博客园、51CTO |
| **核心引擎** | curl_cffi / Playwright | Playwright + stealth | curl_cffi / Playwright |
| **平台适配** | 通用选择器(CSS + 正则回退) | 微信专用(data-src、代码块、防盗链) | 各平台专用选择器 |
| **编码修复** | ✅ | ❌(微信无此问题) | ✅(复用方案一) |
| **数学公式** | ✅ | ❌(微信无此问题) | ✅(复用方案一) |
| **图片下载** | ✅ 通用 | ✅ 微信防盗链 | ✅(复用方案一) |
| **SPA 支持** | ✅ | ✅(必须) | ✅ |
| **扩展性** | 修改通用选择器列表 | 固定(仅微信) | 新增平台只需四步 |
---
## 六、前端交互流程
三种采集方案的前端页面结构完全一致(`CrawlEdit.jsx` / `WechatCrawlEdit.jsx` / `UniversalCrawlEdit.jsx`),交互流程为:
```
┌─────────────────────────────────────────┐
│ 步骤1:输入 URL │
│ ├─ 文本框输入文章链接 │
│ └─ "采集预览" 按钮 │
├─────────────────────────────────────────┤
│ 步骤2:预览采集结果 │
│ ├─ 显示文章标题(可编辑) │
│ ├─ 显示 Markdown 正文(可编辑) │
│ ├─ 显示图片列表(可删除) │
│ └─ 侧边栏显示文章元信息 │
├─────────────────────────────────────────┤
│ 步骤3:编辑与调整 │
│ ├─ 修改标题 │
│ ├─ 修改正文(Markdown 编辑器) │
│ ├─ 删除不需要的图片 │
│ └─ 选择文章分类 │
├─────────────────────────────────────────┤
│ 步骤4:保存入库 │
│ ├─ "保存文章" 按钮 │
│ ├─ 后端下载图片到本地 │
│ ├─ 替换 Markdown 中的图片 URL │
│ └─ 写入 SQLite 数据库 │
└─────────────────────────────────────────┘
```
API 调用时序:
```
POST /api/{scheme}/preview → 采集并返回预览数据(标题、正文、图片列表)
POST /api/{scheme}/save → 确认保存(下载图片、替换 URL、入库)
```
其中 `{scheme}` 对应三种方案:`crawl`(方案一)、`wechat-crawl`(方案二)、`universal-crawl`(方案三)。
---
## 七、总结与建议
### 7.1 使用建议
| 场景 | 推荐方案 |
|------|---------|
| 微信公众号文章 | 方案二(公众号采集) |
| InfoQ、掘金、CSDN、博客园、51CTO | 方案三(通用采集) |
| 人人都是产品经理、菜鸟教程 | 方案一(通用网页采集) |
| 其他未知站点 | 先尝试方案三,失败再尝试方案一 |
### 7.2 未来优化方向
1. **知乎登录态支持**:通过 Cookie 池或扫码登录获取知乎登录态,解决知乎采集问题
2. **SegmentFault 绕过**:尝试更换出口 IP 或更高级的设备指纹模拟
3. **自动平台识别**:通过页面特征自动判断站点类型,减少人工选择采集方案的步骤
4. **智能摘要生成**:接入大模型 API,对采集内容自动生成摘要和标签
5. **更多平台适配**:根据使用需求,逐步添加开源中国(oschina)、简书(jianshu)等平台