Skip to content
Conduit

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

Skills do not use an MCP config. Follow the steps in the README below.

README

synced Oct 21, 2026

公众号文章自动归档

tests License: MIT Python

给一个链接,自动完成抓取 → 抽取 → 图片归档 → 质检 → 摘要 → 写入 Obsidian。

确定性的部分(抓取、抽取、图片、质检)全在 Python 里,没有 LLM 也没有浏览器;LLM 只负责语义摘要与分类,且被质检门和 JSON schema 双重约束。

和同类工具的区别:多一道质检门。 抓到的是验证页、抽取作用域错了、正文里混进几万字符 JavaScript——这些在「抓到即写盘」的工具里会静默进入你的知识库,而且往往几十篇之后才发现。 这里它们会被拦下并报出原因,一个字都进不去。

三条差异,都可以自己验证:

本仓库常见做法
质检门抽取结果必须先证明「是文章散文」才放行,配三个必须被拦下的负样本抓到即写盘
两段式受限 loopextract / 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=2115 篇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
6docx 转换失败(通常是缺 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、你公司的模板——只要三步:

  1. 写一个函数,签名对齐 render_portable.write_plain():(target, extracted, summary, quality, assets_source) -> Path。想复用现成的 Markdown 就调 build_markdown()。
  2. 在 archive.py 的 VALID_FORMATS 加一项,在 cmd_deliver() 里加一个分支。
  3. 在 tests/test_archive.py 的 test_portable_formats() 里加两条断言。

抓取、抽取、图片下载、质检一行都不用碰——那四步的产物就是 extracted.json,它是唯一契约。render_portable.py 不到 200 行,其中一半是拼 Markdown 字符串,照着抄就行。

归档体积

实测一篇 12 张图的文章,各格式目录占用:

项大小说明
assets/4.1 MB原图,obsidian / plain 需要
文章.docx4.0 MB图片内嵌,pandoc 零膨胀——比原图还略小
raw.html3.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 CodeSKILL.md~/.claude/skills/wechat-article-archive/
Hermes AgentSKILL.md(同一份文件,兼容 agentskills.io)~/.hermes/skills/wechat-article-archive/
Codex CLIAGENTS.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_contaminationJS 语法频次必须近乎为零 —— 直接对应上面那次 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   验证套件

同类项目

抓取这一层已经有人做了,而且分发更好:

本仓库和它们的差异不在抓取,在质检门和两段式受限 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

↑↓ navigate openesc close