skill
nyk-wechat-article-archive
微信公众号文章归档 skill:抓取/抽取/图片/质检全部确定性,LLM 只写摘要且必须过 JSON schema。区别于同类工具的是多一道质检门——抓到验证页、抽取作用域出错、正文混进 JavaScript,都会被拦下并报出原因,不会静默进你的知识库。支持 Claude Code / Hermes / Codex 等,安装器按你的 agent 适配;输出 Obsidian / Markdown
Set up
Skills are installed by placing their folder where your agent loads skills.
The download comes from github.com/nyk-netizen/nyk-wechat-article-archive itself, not from our server, so what you get is exactly what its author published.
Install instructions come from the README
README
synced Oct 21, 2026公众号文章自动归档
给一个链接,自动完成抓取 → 抽取 → 图片归档 → 质检 → 摘要 → 写入 Obsidian。
确定性的部分(抓取、抽取、图片、质检)全在 Python 里,没有 LLM 也没有浏览器;LLM 只负责语义摘要与分类,且被质检门和 JSON schema 双重约束。
和同类工具的区别:多一道质检门。 抓到的是验证页、抽取作用域错了、正文里混进几万字符 JavaScript——这些在「抓到即写盘」的工具里会静默进入你的知识库,而且往往几十篇之后才发现。 这里它们会被拦下并报出原因,一个字都进不去。
三条差异,都可以自己验证:
| 本仓库 | 常见做法 | |
|---|---|---|
| 质检门 | 抽取结果必须先证明「是文章散文」才放行,配三个必须被拦下的负样本 | 抓到即写盘 |
| 两段式受限 loop | extract / deliver 分离,LLM 只在中间做语义,输出必须过 JSON schema | 一把梭,模型输出直接落盘 |
| 不挑 agent | 一条 CLI 契约 + 按你的 agent 适配的安装器(Claude Code / Hermes / Codex) | 绑定单一宿主或单一 App |
而且不需要浏览器——没有 Playwright、没有 Chromium、没有镜像站,一个 HTTP GET 加一次作用域限定的解析。
归档出来长什么样
article.md 的属性面板。domain / topics / entities 是 Obsidian 属性而不是正文里的文字标签——可筛选、可点击、可跨文章聚合。original_source 记的是上游原文:这篇公众号文章转述自吴恩达在 X 上的帖子,两个出处并列存着,转述和原始来源不会被混为一谈。
原文.md 保留原文结构:图片下载到本地、按原位置与正文交错、依次编号,段落与标题层级不被压平。抓到即写盘的工具通常把这些压成一整段纯文本。
两个可以自己验证的结论
一、公众号「分享短链」不需要浏览器。 一个带桌面 UA 的 HTTP GET 就能拿到完整 HTML,图片也没有防盗链。跨 9 个互不相关的公众号实测 12/12 成功,零验证码。
但这条有边界,扩大样本后才看清:
| 链接形态 | 样本 | 结果 |
|---|---|---|
分享短链 /s/<token>(微信「复制链接」得到的) | 12 篇 / 9 账号 | 12/12 成功 |
in-app 长链 /s?__biz=…&mid=…&sn=…&scene=21 | 15 篇 | 15/15 撞验证码 /mp/wappoc_appmsgcaptcha |
长链失败与请求频率无关(全部首次尝试即失败,间隔拉到 6 秒仍然如此)——是链接类型差异,不是限流。所以「必须用 Playwright / 镜像站」这个前提对短链不成立,对长链成立。遇到长链,fetch 会明确报 captcha_challenge 并提示改用分享链接。
二、真正的瓶颈是解析。 把整篇文档喂给 html.parser 会得到几百万字符的内联 JavaScript——因为 HTMLParser 在 CDATA 模式下会把 <script> 内容交给 handle_data。必须先限定到文章元素再取文本。
有一个例外形态值得单说:微信的**「文字消息」**(item_show_type == 10)根本没有 #js_content,正文是一段 JavaScript 字符串(text_page_info.content,换行写成 \x0a),DOM 由前端渲染。它同样完整存在于 HTTP 响应里,所以走独立的 text_page_info 作用域——而不是报「没有正文」。这个区分很重要:把「解析器不认识这种页面」报成「抓取被拦」,会把人引向完全错误的排查方向。这类帖子也没有自己的标题(og:title 就是整篇正文),标题取首句。
自己跑一遍:
pip install -r requirements.txt
python3 verify_premise.py "<你自己的公众号文章链接>"
真实输出示例(一篇 129 张图的建筑类长文):
① 直连 HTTP 200,3,919,958 字符,含 #js_content / og:title / 262 处 data-src
② 图片 不带 Referer → HTTP 200,magic bytes 判型 .jpg
③ 旧做法 标题 '',抽出 3,170,122 字符,JS 标记 13,827 处,图片 0 张
④ 作用域限定 标题正常,正文 12,881 字符,JS 标记 0,图片 129 张
信噪比 0.4063% ← 旧方法抽出的内容里只有这个比例是正文
⑤ 质检门 正文放行 / 整篇文档抽取结果拦下
脚本不写任何文件、不使用登录态、不处理验证码。
快速开始
pip install -r requirements.txt
export WECHAT_ARCHIVE_VAULT="$HOME/Documents/你的 Vault/公众号文章" # 不设则落在仓库内 vault/
export WECHAT_ARCHIVE_FORMAT="obsidian" # 见下方「输出格式」
python3 archive.py extract "https://mp.weixin.qq.com/s/xxxx"
打印 workdir、标题、公众号、正文字符数、图片数、JS 标记数与质检结论。退出码:0 通过 · 2 抓取被拦 · 3 质检未通过。
质检通过后把摘要写成 <workdir>/summary.json(字段与受控词表见 archiver/summary.py),然后:
python3 archive.py deliver "<workdir>" --summary "<workdir>/summary.json"
退出码
| 码 | 含义 | 调用方该怎么办 |
|---|---|---|
0 | 成功 | 继续 |
2 | 抓取被拦 | 读 status.json 的 reason,如实报告。只有这一个含义 |
3 | 质检未通过 | 读 failures,不要写入知识库 |
4 | 摘要 JSON 语法错或 schema 校验失败 | 按错误改一次重试,上限两次 |
5 | 该文已归档 | 问用户要不要 --overwrite |
6 | docx 转换失败(通常是缺 pandoc) | 装 pandoc,或改用 --format plain |
64 | 命令用法错误 | 不要重试,是命令写错了 |
64 而不是 argparse 默认的 2,是为了让 2 只有一个含义——分不清「链接被拦」和「命令写错了」的调用方会对后者无限重试。
输出格式
不用 Obsidian 也能用。--format 或环境变量 WECHAT_ARCHIVE_FORMAT,逗号分隔可同时输出:
| 值 | 产出 | 适合 |
|---|---|---|
obsidian(默认) | article.md + 原文.md + assets/ | 长期积累:文章之间靠共享实体自动连成网 |
plain | 文章.md(归纳在前、原文在后)+ assets/ | 最通用。Notion、飞书、Typora、VS Code、Word 都能直接吃 |
docx | 文章.docx(图片内嵌) | 交付给不用 Markdown 的人。需要 pandoc |
raw.html、metadata.json、quality-report.json 三份记录与格式无关,任何格式都会写。
python3 archive.py deliver "<workdir>" --summary "<workdir>/summary.json" --format docx,plain
选哪个:自己攒知识库用 obsidian——双链让第 50 篇能自动连回第 3 篇,一堆 Word 文档做不到这件事。要发给别人用 docx 或 plain(双链会被渲染成普通文字,那些方括号对不用 Obsidian 的人只是噪音)。
想要别的格式:加一个渲染器
分层就是为这件事留的:
extracted.json + summary.json ← 格式中立,任何渲染器的输入
↓
render.py 只有它知道 Obsidian([[双链]]、properties)
render_portable.py 只有它知道 pandoc(plain / docx)
加一种输出——PDF、飞书文档、单页 HTML、你公司的模板——只要三步:
- 写一个函数,签名对齐
render_portable.write_plain():(target, extracted, summary, quality, assets_source) -> Path。想复用现成的 Markdown 就调build_markdown()。 - 在
archive.py的VALID_FORMATS加一项,在cmd_deliver()里加一个分支。 - 在
tests/test_archive.py的test_portable_formats()里加两条断言。
抓取、抽取、图片下载、质检一行都不用碰——那四步的产物就是 extracted.json,它是唯一契约。render_portable.py 不到 200 行,其中一半是拼 Markdown 字符串,照着抄就行。
归档体积
实测一篇 12 张图的文章,各格式目录占用:
| 项 | 大小 | 说明 |
|---|---|---|
assets/ | 4.1 MB | 原图,obsidian / plain 需要 |
文章.docx | 4.0 MB | 图片内嵌,pandoc 零膨胀——比原图还略小 |
raw.html | 3.0 MB | 保真副本,其中 98% 是微信的内联 JavaScript |
| 正文 md / json | ~40 KB | 可以忽略 |
所以「docx 太大」其实是误判:大的是图片本身,不是格式开销。真正容易被忽略的是 raw.html —— 每篇都存一份 3 MB,而里面几乎没有一个字是文章内容。它换来的是「原文被删了你还留着」,这个取舍本仓库选择保留。
单独用 --format docx 时不再复制 assets/(图已内嵌,那是第二份拷贝),同一篇从 11 MB 降到 7.0 MB。要原图就加 --format docx,plain。
pandoc 找不到图片时只告警、不报错,会产出一份图全丢却退出码为 0 的文档。转换后会核对内嵌张数,对不上直接失败——这类「不报错的错」正是本仓库要防的。
日常不用手敲这些——装进你的 agent,贴链接说「归档」就会跑完整流程。
装进你的 agent(不限 Claude Code)
python3 install.py # 检测这台机器上的 agent 并按各自约定安装
python3 install.py --list # 只看检测到什么
| Agent | 约定 | 装到 |
|---|---|---|
| Claude Code | SKILL.md | ~/.claude/skills/wechat-article-archive/ |
| Hermes Agent | SKILL.md(同一份文件,兼容 agentskills.io) | ~/.hermes/skills/wechat-article-archive/ |
| Codex CLI | AGENTS.md(无技能发现机制,指令写进它已经会读的文件) | ~/.codex/AGENTS.md |
安装器会把仓库绝对路径写进指令,所以不需要设 REPO。写 AGENTS.md 时用起止标记圈出自己的段落——重装是替换而不是追加,你自己写的内容不受影响。
都不在表里也没关系。 真正的接口是 CLI,不是 skill 文件:
python3 install.py --dir ~/.your-agent/skills # 任何遵循 SKILL.md 约定的 agent
python3 install.py --print # 把完整指令打出来,粘给任何 agent
新增一个 agent 是在 install.py 的 targets() 里加一行数据,不用改渲染逻辑——包装只有两种(技能目录 / 上下文文件),差别只在装到哪。
架构:确定性做全部,LLM 只做语义
链接
↓ ── 全部确定性,无 LLM、无浏览器 ──
fetch HTTP GET + 退避重试,识别验证页并给出明确 reason
extract scope 到 #js_content → drop script/style → 输出有序分块
(段落/标题/引用/图片/表格,而不是一整个字符串)
assets 图片下载,magic bytes 判真实格式
↓
extracted.json ← 唯一契约
↓
QUALITY GATE ──不通过→ 停,报告原因,绝不写入知识库
↓ 通过
── 受限 loop:唯一用到 LLM 的地方 ──
摘要 + 分类 → 必须通过 JSON schema 校验
不过 → 带校验错误重试 1 次 → 仍不过 → needs_review
↓
渲染并交付 article.md / 原文.md / raw.html / assets/ / metadata.json / quality-report.json
loop 的「受限」体现在四点:① LLM 只碰语义步,永远不碰抓取与质检;② 最多 2 次尝试;③ 输出必须通过 schema 校验;④ 确定性质检不过则任何内容都进不了知识库。
第 4 条是关键——质检门在 LLM 之前,不在之后。
质检门
这是和同类项目的主要差异(它们大多抓到即写盘)。检查的不是「量够不够」而是「抽出来的是不是文章散文」:
| 检查项 | 挡住什么 |
|---|---|
no_javascript_contamination | JS 语法频次必须近乎为零 —— 直接对应上面那次 267 万字符的故障 |
not_a_verification_page | 验证页/错误页被当成正文归档 |
scoped_to_article | 没定位到文章元素、回落到整篇文档 |
body_long_enough | 空正文(图片型文章放宽阈值) |
images_downloaded | 图片大面积下载失败 |
title_present / has_structure | 标题缺失、结构被压平 |
配三个必须被拦下的负样本:验证页 HTML、纯 JS 文档、空正文。这三条测的不是功能能不能跑通,是失败时会不会静默通过。
测试
python3 tests/test_archive.py # 离线,108 项
python3 tests/test_archive.py --live # 额外跑联网用例,共 120 项
CI 在 push / PR 时跑离线套件,矩阵 Python 3.9 / 3.11 / 3.13(见 .github/workflows/tests.yml)。--live 不进 CI——那些用例打真实文章,早晚会 404,红灯会与被测的改动无关。
核心回归跑在 fixtures/wechat-shaped.html 上——一个复现微信页面结构的合成 fixture(空 <title>、大量内联 JS、只有 data-src 的图片、#js_content 嵌套 section),因此不依赖会失效的线上链接。测试里同时保留了旧解析器作为对照组,断言它确实复现故障。
结构
archive.py 两阶段 CLI
install.py 按使用者的 agent 适配安装(SKILL.md / AGENTS.md)
verify_premise.py 可复现的证伪脚本
batch_probe.py 批量探针:跨账号、跨网络环境验证抓取
archiver/
fetch.py HTTP 抓取 + 拦截页识别
extract.py 作用域限定 + 分块抽取(核心)
assets.py 图片下载 + magic bytes 判型
quality.py 质检门
summary.py 摘要 schema 与校验
render.py Obsidian 渲染(双链 + properties)
render_portable.py plain / docx 渲染(走 pandoc)
identity.py 文章主键与目录命名
skills/ 指令唯一来源,install.py 按目标 agent 包装它
fixtures/ 测试样本(含合成的微信结构 fixture)
tests/test_archive.py 验证套件
同类项目
抓取这一层已经有人做了,而且分发更好:
- izscc/x2md — 桌面 App + Chrome 扩展,多平台 → Obsidian
- gxcsoccer/wechat-article-crawler — Claude Code skill,crawl4ai +
data-src修复 - toto2016/wechat-article-extractor — agent skill,镜像站发现路线
本仓库和它们的差异不在抓取,在质检门和两段式受限 loop——这部分与微信无关,可以泛化到任何「外部内容 → 知识库」的管线。
这套设计从哪来
质检门不是设计出来的,是被一次故障逼出来的:早期版本把整篇文档喂给解析器,抽出 267 万字符的内联 JavaScript 并原样写进了知识库,而当时的质检只检查「字数够不够」,全程零报错。
这类错误的共同点是不会自己暴露——一个错的结果和一个对的结果,交付出来长得一模一样。所以整套流程里的作用域限定、JS 污染检测、三个负样本反向测试,都是在给这个不存在的报错机制补位。可复现的验证见 verify_premise.py,拿你自己的链接就能跑。
不要重新引入
Playwright、Chrome UI 自动化、打印 PDF、Computer Use。抓不到的正确反应是读 status.json 里的 reason 并如实报告,不是换一个更重的工具再试。
安全边界
- 只访问用户明确给出的链接及其图片资源
- 不导出 Cookie、不使用登录态、不走中间人代理
- 不绕过验证码与登录墙——遇到就停下报告
- 验证页永远不能当正文归档;抓取失败的 workdir 保留供诊断
- 单张图片超过 20 MB 即中止下载并记为
image_too_large,不会把响应整个读进内存
一条剩余风险,说在明处:图片地址来自被抓取页面自己的 HTML,工具会按这些地址向任意 http(s) 主机发起请求。对公众号文章(图片都在 mmbiz.qpic.cn)没有影响,但本工具也支持普通网页,所以——不要用它归档不可信来源的链接。这里没有做主机白名单,因为白名单会直接砍掉「普通网页文章」这个能力;权衡之后选择把边界写清楚,而不是假装它不存在。
同理,raw.html 与表格的原始 HTML 是原样保留的(归档目的上必须如此)。它们应当被视为不可信内容——Obsidian 预览不执行脚本、pandoc 会丢弃绝大部分 raw HTML,所以实际风险低,但不要用浏览器直接打开来路不明的 raw.html。
License
MIT