微信公众号文章 → ima 知识库
先做决策:用户要的是哪种条目?
| 需求 | 方案 | 结果 |
|---|---|---|
| 「像别人知识库那样,点条目跳微信看原文」 | A. 合集采集法 | 规范链接条目,可跳转 ✅ |
| 目标号没有合集 | B. 滚雪球法(本号推荐位) | 同样是规范链接,可跳转 ✅ |
| 只要内容可读/可检索,不要求跳转 | C. 正文转 Markdown 文件入库 | 文件版,永久可读、可全文检索,不跳转 |
判据只有一个:链接是不是含真实 sn + chksm 的官方规范链接(mp.weixin.qq.com/s?__biz=..&mid=..&idx=..&sn=..&chksm=..)。
只要满足,入库后就能跳微信;签名临时链接(src=11&timestamp&signature)一律不行,别浪费时间去"修"。
前置条件
- ima OpenAPI 凭证在
~/.config/ima/client_id、~/.config/ima/api_key(无则向用户索要)。 - ima-skill v1.1.10+ 在
~/.workbuddy/skills/ima-skills/,API 前缀openapi/wiki/v1。 - 关键词搜索能力来自 wechat-article-search skill(
~/.workbuddy/skills/wechat-article-search__skillhub/),依赖 cheerio + iconv-lite,运行需设置
NODE_PATH=<本机 node 依赖目录>/node_modules(WorkBuddy 托管环境通常为~/.workbuddy/binaries/node/workspace/node_modules,以实际安装路径为准)。 - 若本机 bash 环境基础命令(
ls/cat/grep/tail/dirname)异常不可用 → 文件/JSON 操作改用node -e;解压用python -c "import zipfile..."。
内置脚本(scripts/)
| 脚本 | 作用 | 用法 |
|---|---|---|
fetch_album.js |
核心:合集翻页采集全量文章规范链接 | node fetch_album.js albums.json out.json |
find_album.js |
找合集入口:抓文章页提取 biz + album_id + 公众号名 |
node find_album.js articles.json out.json |
snowball_account.js |
无合集时的兜底:以本号推荐位链接为种子逐层扩链 | node snowball_account.js seeds.json <biz> out.json 300 |
import_urls.js |
批量导入 ima(自动分批 ≤10) | node import_urls.js --kb <kb> --folder <folder> --urls urls.json |
resolve_short_links.js |
短链 → 规范长链:把 /s/{id} 短链还原成含真实 sn 的规范链接 |
node resolve_short_links.js in.json out.json --conc 3 |
count_kb_folder.js |
分页统计知识库/文件夹条目数与 media_type 分布(导入后核对) | node count_kb_folder.js <kb_id> [folder_id] |
score_articles.js |
择优打分:抓正文 → 按可得信号给 0~100 分并分档 | node score_articles.js in.json scored.json --cache wx_cache |
report_scored.js |
把打分结果渲染成可筛选的 HTML 择优清单 | node report_scored.js scored.json out.html "标题" |
指标可得性(2026-09-23 实测,回答"能不能按阅读量/转发量/粉丝数筛选")
| 指标 | 能否拿到 | 实测依据 |
|---|---|---|
| 阅读量 | ❌ | 文章页里 read_num = appmsgBarData.read_num \|\| 0 且 read_num: '';like_num = ""。取数接口 mp/getappmsgext 虽返回 ret:0,但顶层无 appmsgstat 字段,没有 read_num/like_num。原因:window.appmsg_token = "",非微信环境取不到阅读数 |
| 在看/点赞 | ❌ | 同上(like_num 为空) |
| 转发/分享量 | ❌ | 从不对外暴露,仅号主后台可见 |
| 粉丝数 | ❌ | 同上,仅号主后台可见 |
| 第三方指数(新榜/清博) | ❌ | 需登录,非可编程获取 |
| 合集页热度字段 | ❌ | 合集 <li> 只有 data-msgid/itemidx/link/title/is_read/pos_num,无任何热度属性;pos_num 是合集内序号不是热度 |
结论:阅读量/转发量/粉丝数在非微信环境下拿不到,别在这上面耗时间。
替代方案:用 score_articles.js —— 原创声明、留言权限、作者署名、正文体量、实心段占比、史料密度、野史/标题党话术、时效与系列归属,加权成 0~100 分,默认按批次分位分 A/B/C 档。词典与权重都在脚本顶部,按需改。
若用户能手动提供阅读量,写进 reads.json 用 --reads 传入,会额外加权(最多 +10)。
第 1 步:找到合集入口(biz + album_id)
album_id 只存在于页面里,拿它的两条路:
- 有种子文章(搜狗搜到 / 用户给的):把文章放进
articles.json([{title,url}],url 支持搜狗中转链接,脚本会自动解析),跑
node find_album.js articles.json found.json
→ 输出每篇的biz、nickname、albums[].album_id。 - 已经知道 biz:直接拿该号任意文章页抓(同上)。
⚠️ 提取陷阱:文章页里有大量 JS 模板含 album_id(opt.album_id、x.appmsgalbuminfo.album_id || 0),
必须用「后面紧跟 10~20 位数字字面量」的正则才能避开。find_album.js 已内置该过滤;若结果为空,说明该号确实没挂合集 → 转第 2 步 B。
第 2 步 A【核心】合集采集法(推荐)
合集页 mp.weixin.qq.com/mp/appmsgalbum?action=getalbum&__biz=&album_id=&scene=173 在非微信环境可直接访问、不走风控(本机实测 200)。
# albums.json: [{"name":"崇祯号","biz":"MzIwOTY3NzM0OQ==","album_id":"2311091460542480384"}]
node scripts/fetch_album.js albums.json albums_result.json
要点:
- 返回 HTML 每条形如
<li data-msgid="2248082471" data-itemidx="1" data-link="http://mp.weixin.qq.com/s?__biz=..&mid=..&idx=..&sn=..&chksm=..#rd" data-title="标题">
—— data-link 即完整官方规范链接,与「微信里转发到 ima」保存的条目同源。
- 翻页:追加 &count=10&begin_msgid=<上页最后一条 msgid>&begin_itemidx=<其 itemidx>,循环至无新条目。单合集可达 200+ 篇(实测 209 篇)。
- 清洗:&amp;→&、去掉尾部 #rd、http:→https:。
- 一个号可能有多个合集,全部跑一遍再合并去重。
第 2 步 B【兜底】滚雪球法(该号无合集时)
文章页底部的「推荐阅读 / 往期文章」里往往带本号其它文章的规范链接,以它们为种子逐层扩展即可滚出文章池。
# seeds.json: [{"title":"...","url":"https://mp.weixin.qq.com/s?__biz=..&mid=..&idx=..&sn=.."}]
node scripts/snowball_account.js seeds.json MzA5NDExMDUxNA== snowball.json 300
- 第二参数
biz用于只收该号的链接,避免串号。 - 增长约 1~1.5 篇/页,冷门号较慢;脚本自带
maxArticles与安全阀,别设太大。 - 种子从哪来:
find_album.js输出的real_url页面里已含本号推荐链接;也可用搜狗搜到的该号文章页抓取时保存的 HTML。
第 2 步 C【兜底】正文转 Markdown 文件入库(不能跳微信)
当某篇既不在合集、也滚不到时用:内容永久可读可检索,但点开不跳转。用户明确要"跳微信"时不要用这个糊弄。
- 趁签名链接还有效,立即抓页面:标题
#activity-name/msg_title、公众号#js_name、时间#publish_time、正文#js_content(img的data-src转src)。 - cheerio + turndown 转 Markdown,头部注明来源公众号与原文链接。
- 文件上传:check_repeated_names → create_media(media_type=7, text/markdown) → cos-upload.cjs → add_knowledge(带 folder_id)。
第 2 步 D【强烈推荐】社区归档法 —— 找全量历史文章的捷径
适用:该号没挂合集、滚雪球也滚不动,或用户要"某号全部/近 N 年文章"(几百上千篇)。
原理:GitHub 上有社区维护的公众号历史文章列表(含日期 / 标题 / 简介 / 作者 / 微信链接),比任何爬取都快且无风控。
已知源:sushengbuhuo/blog 仓库 docs/wechat/ 下有 246 个公众号的历史文章列表 md,
例子:docs/wechat/记忆承载公众号历史文章列表.md(2,606 篇,2017-12 ~ 2026-09 共 105 个月无缺口)。
# ① 下载(GitHub raw 会被 ECONNRESET,走 jsDelivr)
curl -L -o list.md "https://cdn.jsdelivr.net/gh/sushengbuhuo/blog@main/docs/wechat/<号名>公众号历史文章列表.md"
② 解析条目——归档是双格式,正则必须同时覆盖长链与短链,否则会漏掉一大半(曾因此误判"归档有 18 个月缺口"):
// 2026-09-24 实测修订:日期可能带时间([2022-06-24 21:00:00_标题],麦子熟了/夜听等号全是这种);
// 2013 年代老帖链接是 mp/appmsg/show?__biz=…(无 sn,央视新闻/人民日报的 2013 归档全是这种)。
const HEAD = /\[(\d{4}-\d{2}-\d{2})(?:[ T]\d{2}:\d{2}:\d{2})?_([^\]]*)\]\((https?:\/\/mp\.weixin\.qq\.com\/(?:s\?__biz=[^)]+|s\/[A-Za-z0-9_\-]+|mp\/appmsg\/show\?[^)]+))\)/g;
每条目正文段 = 到下一个条目头之前,内含两行元数据:文章简介: / 文章作者:(可用于清单交付物)。
②' 列出归档全部号(2026-09-24 实测,已产出全 246 号清单):
- ⚠️ data.jsdelivr.com/v1/packages/gh/...?structure=flat 对 >50MB 的仓库直接 403("Package size exceeded"),不能用来列目录。
- 改用 GitHub contents API(本机可达,一页拿全):https://api.github.com/repos/sushengbuhuo/blog/contents/docs/wechat,返回 name+size。
- 批量统计每号篇数:并发 10 从 cdn.jsdelivr.net 拉全部 246 个 md(158MB,59s,0 失败)→ HEAD 正则计数 + 取最早/最晚日期
(按 scripts/ 内其他脚本的 fetch 模式即可实现:GitHub contents API 列目录 + jsDelivr 拉 raw 文件)。
- 文件名形态多样,清洗号名时注意:公众号X历史文章.md("公众号"在开头)、202136氪…(年份与号名连写)、2020年12月人民日报…(年月前缀)、2013公众号人民日报…;同一账号可能有多段归档(如 人民日报 2013 段 + 2020-12 段)。
③ 短链还原——归档里短链占一半以上,跑 resolve_short_links.js 变成规范链接:
node scripts/resolve_short_links.js in.json out.json --conc 3
实测 1,186 条 → 1,109 条成功、77 条失败(失败的页面无 sn,多为原文被删/违规下架),耗时约 4.5 分钟。
关键原理:短链页面里 var sn 有真实值,而长链页面里 var sn = ""(服务端刻意屏蔽本篇 sn)。
所以短链是拿到规范链接的正门——与传统认知("页面拿不到 sn")不冲突,那条只针对长链页。
短链长这样:https://mp.weixin.qq.com/s/NtrkuHZ_B_7JYZy8RfQwtg(hashid 型),
resolve_short_links.js 会流式读取 + 命中即 abort,只下载约 500KB(整页 3.5MB)。
⚠️ 该脚本会把成功条目的 url 就地替换成规范链、失败条目保留原短链且 canonical: null。
后续要关联原始元数据,别按 url 匹配,按 日期|标题 建索引。
④ 导入前排除已试导入的:试导入的少量条目已在库里,必须先从待导入集合里剔除,否则重复。
第 3 步:定位知识库 / 文件夹
node ima_api.cjs "openapi/wiki/v1/search_knowledge_base" '{"query":"知识库名","cursor":"","limit":20}'
node ima_api.cjs "openapi/wiki/v1/search_knowledge" '{"query":"文件夹名","knowledge_base_id":"<kb_id>","cursor":""}'
- 文件夹
folder_id取返回条目的media_id(形如folder_75083...,带前缀)。 - 没有创建文件夹的 API,目标文件夹必须已存在。
- 实际使用前先用
search_knowledge_base查到目标知识库kb_id,再用search_knowledge查到目标文件夹folder_id
(OpenAPI 没有创建文件夹的接口,目标文件夹必须已存在,可在 ima 客户端里先建好)。 - 还可用
get_addable_knowledge_base_list确认哪些库可写。
第 4 步:导入
node scripts/import_urls.js --kb <kb_id> --folder <folder_id> --urls urls.json
# 或 node import_urls.js payload.json (payload 含 knowledge_base_id / folder_id / urls)
- ⚠️ urls.json 必须是「纯字符串数组」(如
["https://mp.weixin.qq.com/s?...", ...]),不能传[{title,url,date}]对象数组——
脚本内部只做String(u),传对象会变成"[object Object]",表现为所有批次均 0 成功且 ret_code 异常。
2026-09-24 实测:692 条以对象数组传入 → 69 批全败;改成.map(u=>u.url)的字符串数组后重跑即成功。手头是对象数组时先转换再写出。 - 每批 ≤10 条,
ret_code=0即成功。大批量实测:1,099 条 / 110 批,约 5.5s/批(含 1.2s 间隔),全程约 10 分钟,110/110 批全成功——不必担心量级。 - 入库后标题先是原始 URL,ima 异步解析后才会变成文章标题——这属正常,不能用它当"链接有效"的判据。
实测回填很慢:导入 1,099 条后,十余分钟仍有 ~1,020 条显示 URL;而 16 分钟前试导入的 10 条已回填 9 条。属异步排队,等待即可。 - 导入后核对文件夹列表(用
count_kb_folder.js):条目数应 = 新增数 + 试导入数(实测 1,099 + 10 = 1,109 条,media_type 全为 6)。
⚠️ 大坑:get_knowledge_list在标题回填窗口期是"最终一致"的——列表按更新时间排序,标题逐条回填导致条目在分页边界上漂移,
同一批条目两次全量扫描会得到不同的"缺失集合"(实测:1109 → 1108 → 补导 6 条后仍 1108,但"缺失"从 6 条变成另外 4 条)。
别据此反复补导。正确姿势:等回填稳定(几十分钟)后双次全量扫描取并集再判缺失;
ret_code=0才是"已接受"的权威判据。
若出现与主题无关的条目(如"我想和你聊聊"),那是提取环节误取了推荐位链接所致,不是 ima 的行为;需在最终交付里列出让用户手删。 - OpenAPI 没有删除接口,任何清理都要用户在 ima 客户端手动做。
已验证无效的做法(别再试)
- ❌ 导入搜狗签名链接(
src=11&timestamp&signature):过期即报「参数错误」。 - ❌ 从文章页提取本篇 sn(指长链页):
var sn = ""是服务端刻意屏蔽;页面里出现的 sn 全是推荐位/往期文章的,误用会指向完全无关的文章。
⚠️ 例外:短链页mp.weixin.qq.com/s/{hashid}的var sn是真实本篇 sn,可据此还原规范长链(见第 2 步 D)。两条结论不矛盾,区别在页面类型。 - ❌
mp/profile_ext、appmsgalbum?action=getalbumlist:一律返回「请在微信客户端打开」。 - ❌ 用本机 IP 请求规范链接来验证链接"是否失效":风控是间歇性的(见下方修订),打不开≠链接坏了。
- ❌ 搜索引擎找规范链接:搜不到(转载站不带原文规范链接)。
实测边界(2026-09-23 补充,务必先读)
① 合集法对「没挂合集的号」无效
跑 find_album.js,若返回 0/N 篇找到合入口 → 该号确实没挂合集标签。
实测:「读史」(MzA5NDExMDUxNA==)、「九边」(MzUzMjY0NDY4Ng==) 都是 0/4。
文章页里出现的 appmsgalbum/album_id 全是 JS 模板代码(opt.album_id、x.appmsgalbuminfo.album_id || 0),不是真数据——别被骗。
② ⚠️【2026-09-23 修订】规范链接其实能打开,风控是间歇性的
- 早前记录"本机 IP 被整体风控、规范链接一律 302"不成立。当天稍晚复测:61 篇规范链接 61/61 全部 HTTP 200 且正文完整。
- 真实情况:风控间歇性发作,且表现为「HTTP 200 但返回空壳页」(页面有
__biz等 JS 变量,却没有js_name/js_content),不是显式报错。并发高时更容易触发。 - 正确做法:拿到空壳页就退避重试(
score_articles.js内置 3 次重试,1.5s/3s/4.5s 递增),并发压到 2~3,请求间留 0.6~1s 间隔。 - 推论:滚雪球法重新可用——能打开文章页就能从推荐位/往期位滚出新链接。
snowball_account.js可再试(之前"读史/九边滚不动"的结论需重测)。 - ~~唯一能打开文章页的通道是搜狗解析出的临时签名链接~~ ← 同样已被推翻,规范链接能直接打开。
- 结论修正:
snowball_account.js在并发受控(≤3)且有重试的前提下可用;请重新实测,不要沿用"本机不可用"的旧结论。
③ 对「无合集号」的正确做法(按优先级)
- 请用户提供该号的合集/话题页链接(微信里点开文章底部「合集」标签即可看到 URL)→ 再用
fetch_album.js全量采集。最高效。 - 请用户在微信里逐篇「用 ima 知识库打开」保存 → 唯一官方稳定路径,条目必然可跳转;但需用户手动,无法代劳。
- 搜索 → 解析签名链接 → 抓页面 → 提取推荐位里的本号规范链接:可零星收获(每篇 0~2 条),但依赖搜狗收录、量小,且搜狗限流严重(实测 6 组关键词只成功 1 组)。
④ 条目「标题长期显示为网址」的终态判读【2026-09-24 实测修订】
- 回填窗口内(导入后数小时~1 天):显示 URL 属正常排队,速率随时段波动大(实测白天 ~0.5 篇/分、早晚 ~3-5 篇/分),不影响点开跳转。
- 队列排空后仍为 URL = 死链的可靠信号。实测:1,099 条导入后,1,021 条正常回填;剩余 87 条逐条抓微信页面,
85 条明示「该内容已被发布者删除」、2 条空壳页——即 ima 早就解析完了,这些是死链,标题永远不会变。
不要再归因于"解析排队"或"ima 解析谨慎",先实测原文再下结论。 - 死链条目会在 ima 客户端里点开显示"已删除"。OpenAPI 无删除接口,需用户按清单在客户端手动清理。
成功案例(2026-09-23)
用户确认《袁崇焕的图森破(一)》条目能跳微信 → 其链接正是从《图森破(三)》页面的往期位采集的规范链接。
据此用合集法一次采集 3 个号共 305 篇,筛出 61 篇明朝历史文章全部导入成功。
关键教训:别急着下"做不到"的结论——文章页拿不到 sn 时,换「合集页」入口就通了。
案例二:「记忆承载」近三年 1,109 篇全量入库
该号无合集(find_album.js 0/6)、无上下篇导航(pre_article_link / next_article_link 全为 0)、搜狗 0 结果 → 合集法与滚雪球法双双失效。
改走社区归档法(见第 2 步 D):从 jsDelivr 拉归档 md(2,606 篇 / 105 个月无缺口)→ 双格式正则解出 1,186 篇近三年 →
resolve_short_links.js 把 647 条短链还原 → 1,109 条可用规范链(失败 77 条为被删/违规文)→
import_urls.js 110 批全成功 → count_kb_folder.js 核对 1,109 条落地,全部 media_type 6。
关键教训:主力方法失效时,先问"有没有别人已经整理好的清单"——社区归档往往比现爬更快更全,且完全无风控。
环境坑(Windows 本机通用)
- 若 bash shim 的 PATH 异常:
ls/cat/grep/tail/dirname不可用 → 用node -e或 Python 替代;传参避免$(cat file)。 - PowerShell 输出捕获不稳定、
Expand-Archive可能静默失败 → 解压用python -c "import zipfile; zipfile.ZipFile(r'..').extractall(r'..')"。 - WorkBuddy 托管运行时典型路径(以本机实际安装为准):python
~/.workbuddy/binaries/python/<版本>/python.exe;node~/.workbuddy/binaries/node/<版本>/node.exe;npm 依赖装在~/.workbuddy/binaries/node/workspace,运行时设NODE_PATH。