# 资讯采集模块技术文档 ## 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. **`` 标签**(清理 `| 站点名` 等后缀) 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链接支持、批量采集章节)*