Files
nanocode/personal-blog/docs/04-crawl-solution.md
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

472 lines
21 KiB
Markdown
Raw 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.
# 文章采集方案详解
本文档详细介绍个人博客 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`)
自研转换器,支持以下标签:
- `<img>` → `![alt](url)`,自动将相对 URL 转为绝对 URL
- `<h1>`~`<h6>` → `#` ~ `######`
- `<p>` → 段落文本
- `<pre><code>` → 代码块(自动识别 `language-xxx` 类名)
- `<code>` → 行内代码
- `<strong>`, `<b>` → `**粗体**`
- `<em>`, `<i>` → `*斜体*`
- 修复混合内容问题(HTTPS 页面中的 HTTP 图片自动升级)
- 处理 HTML 实体(`&nbsp;`, `&lt;`, `&gt;` 等)
#### 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`)
微信代码块使用 `<pre class="code-snippet__js">` + `<code>` + `<span class="code-snippet__line">` 结构,需要按行重组:
```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`)
移除没有文本内容也没有图片的空 `<p>` 标签。
#### 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` | 掘金文章正文在 `<article class="article">` 中 |
| **CSDN** | `#content_views` | `.article_content` | CSDN 正文主容器,排除侧边栏、评论等噪声 |
| **博客园** | `#cnblogs_post_body` | `.postBody` | 博客园经典正文容器 |
| **51CTO** | `.article-content` | `article` | 51CTO 文章正文区域 |
#### 4.5.2 专用标题提取
各平台 `<title>` 标签通常包含站点后缀,需要清洗:
| 平台 | 清洗规则 |
|------|---------|
| **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)` 函数,清洗 `<title>` 后缀
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)等平台