> ⚠️ 已知问题:本文件为 UTF-8 编码(无 BOM)。如某些环境显示中文乱码,请检查读取工具的编码设置。不影响功能执行。
/yipian — 公众号文章消化技能
> 一篇公众号文章 → 一片结构化知识
配置(用户按需修改)
# yipian 配置
preferences:
# 存档后端: local / getnote / obsidian / none
# local: 保存到本地文件系统
# getnote: 保存到 Get笔记(需配置 getnote 相关字段)
# obsidian: 保存到 Obsidian(需配置 vault_path)
# none: 跳过存档,只在对话中展示产出
storage_backend: local
# 本地存档路径(仅 storage_backend 为 local 时生效)
# 默认值: "~/yipian-notes/"(替换 ~ 为用户家目录)
# 如果设为空字符串,则使用内置默认路径 yipian-output/(当前工作目录下,笔记跟项目走)
local_path: ""
# getnote 配置(仅 storage_backend 为 getnote 时需要)
getnote_tag_prefix: "阅读"
getnote_output_tag: "精读产出"
# obsidian 配置(仅 storage_backend 为 obsidian 时需要)
obsidian_vault_path: ""
# 搜索工具(留空则自动探测可用工具: doubao_search > web_search > 无搜索)
# 如果填入不存在的工具名,自动回退到自动探测
search_tool: ""
# 消化深度默认值: shallow / medium / deep
default_depth: "medium"
# 交互模式: standard / fast
# standard: 每步停等确认(适合用户想深度参与)
# fast: 自动走完,只在产出前做一次确认(适合日常刷文)
# 如果设为其他无效值,自动回退到 standard
interaction_mode: "standard"
触发方式
/yipian <公众号文章链接>— 单篇消化/yipian <链接1> <链接2> <链接3>— 多篇同主题消化- 或直接发链接 — 问是否要用 yipian 消化
输入校验
收到用户输入后,先做校验:
- 无链接:用户只发文字(如"帮我消化一篇文章")但没有发任何链接 → 提示"请提供公众号文章链接",不调用 MCP
- 无效链接:链接格式明显不对(不包含
http或mp.weixin.qq.com)→ 提示"这不是公众号文章链接,请确认后重发" - 非公众号链接:是有效 URL 但非
mp.weixin.qq.com域名 → 改用fetch_content读取内容,然后走标准消化流程 - 多链接逐条验证:如果用户发了多个链接,逐条检查上述条件,分别标记有效/无效,只对有效链接继续处理。无效链接单独告知用户
核心原则
- 用户不懂技术。能直接完成的就完成,不要教步骤。
- 先评估后行动 — 每步前做条件检测,条件不够则降级或提示,不硬闯
- 按类型自动匹配格式 — 用户不需要选择输出格式
- MD + HTML 双输出 — 二者缺一不可,先 MD 空一行后 HTML
工作流程(总览 7 步)
① 输入 → ② 初检+概览 → ③ 选板块精读 → ④ 产物组装 → ⑤ 存档 → ⑥ 完成告知
确认节点策略(根据 interaction_mode)
standard 模式(默认): 每步执行完成停等确认。
fast 模式:
- Step 1→2 自动执行,初检+概览一出直接展示
- Step 3 精读板块选择 → 用户一次性选完 → AI 自动连续精读全部选中板块 → 精读完后问"直接全输出还是再补缺"
- 减少确认次数从 5-6 次压到 2-3 次
异常处理总则
| 异常场景 | 处理方式 |
|---|---|
| 用户中途说"算了/不弄了" | 已产出的内容保留在对话中,存档步骤跳过,告知用户"已暂停,产出内容还在对话中" |
| 用户中途追加新链接 | 暂停当前流程,将新链接追加到待处理队列。询问用户"新链接是同一主题(走多篇模式)还是单独处理" |
| 系统/网络错误 | 重试 1 次 → 降级到备选方案(如 MCP 失败用 fetch_content)→ 还不行则告知用户具体失败原因和重试建议 |
分步详细流程
Step 1:输入
🔴 CHECKPOINT · 输入确认: 链接校验通过后,展示待消化链接列表让用户确认再进入下一步。
- 用户发公众号文章链接(1 个或多个)
- URL 校验:调用 MCP 前先确认链接是
mp.weixin.qq.com域名且格式有效 - 非公众号链接:直接用
fetch_content读取,不走 MCP - ⏳ 进度提示: 调用 MCP 前先说 "正在用 download-mcp 下载文章...",避免用户等待时无反馈
- 调用方式:直接用固化脚本(不要再手写 urllib 代码):
python # 完整代码见附录 A(download_article.py) # 用法:将附录 A 代码保存为 download_article.py,然后运行: # python download_article.py <文章链接>
脚本自动完成:MCP 调用 → 嵌套 JSON 二次解析 → 中文文件名 URL 编码(urllib.parse.quote)→ 下载到当前目录(文件名取自 MCP 返回的 urls[0])。 - 返回结构说明: MCP 响应中
result.content[0].text是一个 JSON 字符串,需再次解析才能获取urls[0]和status。直接读result.urls[0]会失败。脚本已封装此逻辑。 - MCP 调用失败(HTTP 500 / 超时 / 返回空 result):重试 1 次 → 仍失败则告知用户具体原因,不降级到其他工具
- URL 编码问题: 下载文件时如遇到 404(中文文件名被错误转义),脚本已内置
urllib.parse.quote(parsed.path)编码处理。不用 PowerShell。 - 脚本不存在/报错时:回退到手写 urllib 调用(代码参考附录 A 内部实现,逻辑一致)
- 如果 download-mcp 完全不可用 → 告知用户 MCP 不可用,请稍后重试
Step 2:内容初检 + 宏观概览(合并输出)
收到链接后,下载完毕直接输出内容初检结果 + 宏观概览,不停等确认。内容初检和宏观概览一次性展示给用户,用户看完了直接决定下一步。
2A 内容初检
自动检查三个维度:
| 检查项 | 判断标准 | 结果 |
|---|---|---|
| 完整性 | 是否有明显截断、字数远低于预期 | ✅ 完整 / ⚠️ 可能不完整 |
| 结构性 | 是否有标题层级(h1/h2/h3) | ✅ 有结构 / ⚠️ 纯文本堆叠 |
| 可读性 | 是否含大量乱码/HTML 残留 | ✅ 干净 / ⚠️ 需要清洗 |
原文清洗(存档前执行): 运行附录 B 的 clean_article.py 代码(保存为 .py 文件后执行:python clean_article.py 原始文章.md),不需要手写正则。
脚本内置:去除公众号 UI 元素(关注引导"微信扫一扫可打开此内容"、小程序弹窗"预览时标签不可点"、作者头像、底部相关阅读等)、压缩多余空行(连续 3 行以上压为 1 行)、去行尾空格。
- 绝对保留:正文内容、标题层级(h1/h2/h3/h4)、图片(含 alt 文本)、引用块格式、代码块、列表格式、原文链接
- 敏感内容:如果文章明显包含违法违规内容,停止处理并提示用户。如果包含广告软文/营销推广,在消化笔记中标注"营销倾向"
- 新 UI 元素漏清:若脚本未覆盖新的公众号 UI 残留,人工补删后,把新元素追加到 clean_article.py 的 JUNK 列表(便于下次自动处理)
产出可消化度 + 消化深度建议:
| 可消化度 | 条件 | 建议深度 |
|---|---|---|
| 高(≥80%) | 完整 + 有结构 + 干净 | 深层消化(精读+联想+搜索锚定) |
| 中(50-80%) | 有 1 项黄色预警(⚠️) | 中层消化(结构化拆解+信息补全) |
| 低(<50%) | 有 2 项及以上黄色预警,或 1 项红色预警 | 浅层消化(摘要+金句),给出处理建议 |
评分规则: 三项全部绿色(✅)= 高;有一项黄色(⚠️)= 中;两项及以上黄色或任一红色 = 低。
文章长度特殊处理:
- 超短文(< 1000 字):不拆板块,直接输出摘要(150-300 字)+ 核心观点 1-2 条。跳过精读步骤,直接进入产物组装。在初检中标注"短文模式"。
- 超长文(> 20K 字):在初检中标注"长文模式",概览板块拆解控制在最多 5 个板块(取信息密度最高的 5 个)。精读时每个板块输出控制 ≤800 字,避免产出过长。
2B 宏观概览
内容初检之后,同一轮直接输出宏观概览(不停等)。
单篇模式
输出概览包含:
【核心主题】一句话
【解决什么问题】读完能带走什么价值
【板块拆解】全文划成 N 个板块,各标实用价值(⭐⭐⭐)
【信息缺口标注】原文哪块讲得不细,需要搜补
【消化建议】推荐精读板块 + 建议消化深度
⭐ 概览输出样例(基于真实文章《AI到底能帮你做什么?》):
【核心主题】WorkBuddy × ima 产品的 3 种使用范式与 7 个真实用户案例
【解决什么问题】读完能带走 3 套可直接落地的 AI 工作方式(读资料→造产品→跑腿)
【板块拆解】
① 读资料→第二大脑(3案例)⭐⭐⭐ 上手门槛最低
② 造产品→一人工程师(2案例)⭐⭐⭐ 最有杠杆感
③ 跑腿→数字同事(2案例)⭐⭐⭐ 效率提升最猛
【信息缺口】未提产品定价/获取渠道/自动化任务配置细节
【消化建议】推荐精读全部三个板块,深度建议 medium→deep
说明:概览不是固定模板填空,AI 可据文章特性调整格式。核心底线是板块拆解+信息缺口+推荐精读三项必须有。
概览纠错: 如果用户反馈概览中的主题或板块划分不准确,允许用户手动修正。修正后重新输出概览,不再重做 2A 内容初检。
多篇模式
输出主题知识地图:
【主题确认】共同主题
【每篇快照】每篇一句话 + 侧重点
【共识与矛盾】各篇一致的地方 vs 有分歧的地方
【信息互补关系】哪篇缺的、另一篇正好补了
【消化建议】推荐按篇精读 / 按概念精读 / 全主题输出
→ 用户确认后进入下一步
Step 3:选板块精读
🔴 CHECKPOINT · 精读选择: 展示板块列表 + 精读方式选项后停等用户选择。用户一次性选完,AI 自动连续执行。
单篇
用户在概览后选择精读方式(一次性操作,不要反复确认):
- A. 按个精读 → 从概览板块列表中选 1 个或多个精读(推荐一次性选完,AI 自动连续精读所有选中板块)
- B. 全主题输出 → 跳过精读,直接出整合报告
板块编号不存在: 如果用户选的板块编号超出概览列表范围,提示"板块编号不正确,请从以下列表中选择(1~N)"并重新展示板块列表。
"你决定" / "随便": 用户不做选择时,AI 自动推荐实用价值最高的板块(⭐⭐⭐优先)精读。
内容严重不足的板块: 精读时发现该板块原文仅几句话带过、实质内容太少,标记"无法精读,内容过少",询问用户是跳过还是由 AI 基于主题扩展补充。
AI 对每个选中板块逐一执行:
1. 输出核心观点拆解(2-3句话)
2. 提取关键细节/数据/案例
3. 原文讲得不细的地方 → 自动搜索补充
4. 关联延伸资源(如有)
精读完后自动衔接:
- 如果用户选了多个板块 → 自动连续精读,不逐个停等
- 全部精读完成后 → 询问"需要补其他板块,还是直接全主题输出?"
- 如果用户只选了 1 个板块 → 精读完后问"继续精读其他板块,还是直接出全主题输出?"
- fast 模式:用户在精读过程中可以随时说"这个板块够了/跳过",立即停止当前板块进入下一个
- 不再回头重新展示 Step 3 的板块选择
多篇
用户选择精读方式:
- A. 按篇精读 → 选某篇展开深挖(走单篇精读逻辑)
- B. 按概念精读 → 选某个概念,跨多篇提取拉对比
- C. 全主题输出 → 跳过精读,直接出整合报告
→ 确认后执行
Step 4:产物组装
🔴 CHECKPOINT · 产物确认: 精读完成后(或全主题输出前),确认所选板块是否已全部覆盖 + 是否需要补缺,再进入组装。
按文章类型自动匹配格式(无需用户选择):
| 文章类型 | 产物格式 | 说明 |
|---|---|---|
| Type A 知识型 | 结构化笔记 | 板块拆解 + 核心观点 + 关键细节 + 延伸资源 |
| Type B 操作型 | SOP 清单 | 操作步骤 + 事实核查结果 + 可自动化提示词 |
| Type C 感悟型 | 金句+观点集 | 核心心法 + 原文金句 + 可复用框架 |
| 混合型(A+B / A+C / B+C / A+B+C) | 合并产出 | 以第一种类型为主格式,融入第二种类型的特征。如 A+B:结构化笔记中每个板块附带 SOP 步骤 |
必须同时输出两份文件,严格按以下顺序:
[MD 文档正文]
(不要前置闲聊、额外解释、多余注释)
(空一行)
[完整 HTML 源码]
(内嵌全部 CSS/JS,不引用外部资源)
MD 和 HTML 二者缺一即判定为任务失败。
产出质量自检清单(写入前执行): 运行附录 D 的 check_output.py 代码(保存为 .py 文件后执行:python check_output.py <产出目录>),不用手写正则。
脚本自动检查 4 项:① 三文件存在且非空 ② HTML 零外部依赖(正则扫描 https?://,排除 mailto/tel)③ MD/HTML 结构一致性(MD 的 ## 标题必须全部出现在 HTML h2 中)④ 🔍 标记一致性。
AI 另需人工确认 1 项(脚本不覆盖):
1. 覆盖检查:用户选的精读板块是否全部在产出中有对应章节?未选的是否未出现?
(原自检清单 2/3/4/5 项已由脚本自动执行:🔍 标记检查 / HTML 外部依赖检查 / MD-HTML 结构一致性 / 输出格式约束由生成流程保证)
产出文件写入失败(磁盘满/权限不足): 直接将完整内容输出到对话中(先 MD 源码,空一行后 HTML 源码),并提示用户"自动保存失败,请手动复制保存"。不阻塞流程继续。
HTML 规格
**HTML 生成:运行附录 C 的 render_html.py 代码(保存为 .py 文件后执行:python render_html.py yipian-笔记.md yipian-笔记.html),不要手写 13KB 模板。
脚本自动完成:零外部依赖内联 CSS/JS(markdown 库渲染)→ 正文 ## 自动生成 TOC 锚点(sec-N)→ h3 小节自动包成 <details> 折叠块(SOP 步骤默认可展开)→ 图片外链替换为占位块 → 普通链接转纯文本 → 亮/暗色切换 + 响应式 + 系统字体栈。
- 零外部依赖:内嵌全部 CSS 和 JS,不引入 CDN、外部字体、外部图标库
- 自检:HTML 生成后检查源码中不包含
http:///https://的外部资源引用(除mailto:tel:协议外)。如果发现,替换为内联版本或删除 - 导航:支持目录(TOC)锚点点击跳转
- 交互:内容区块可折叠/展开
- 响应式:排版自适应电脑和移动端
- 样式区分:标题、段落、列表、引用、代码块各自有清晰样式
- 暗色模式:支持亮色/暗色切换开关
- 脚本缺失/报错时:回退到手写 HTML(遵循以下规格),质量要求不变
Type B 特殊处理
SOP 清单中每步包含:
- 操作描述 + 预期结果
- 难度标记:🔧手工 / 🤖可自动化
- 🤖 步骤附完整提示词,用户可直接复制发给 Agent 执行
- 同时提供两种粒度:
- a. 单步骤独立提示词
- b. 整份 SOP 合并提示词(统一丢给 Agent 一次搞定)
提示词模板原则: 如果文章涉及特定产品/工具,模板用 [工具名] 等占位符标注,并说明"可替换为其他同类工具",避免硬编码。
SOP 生成前先做事实核查:
1. 文章说的步骤/数据/效果是否真实准确?→ 搜索交叉验证
2. 文章一笔带过的步骤 → 搜索找完整教程补全
3. 有多个做法/版本的分歧 → 搜索确认当前主流做法
4. 核查发现矛盾:如果搜索发现文章中的说法与权威来源不符,在 SOP 中标注矛盾点 + 附上证据来源,让用户自行判断,不直接篡改原文内容
Step 5:存档
存档路径优先级:
1. 配置中 local_path 有值且不为空 → 使用 local_path 作为根目录
2. 配置中 local_path 为空字符串 → 使用默认路径 yipian-output/(当前工作目录下)
3. storage_backend: none → 跳过存档,仅在对话中展示产出
同名文件处理: 目标路径已存在同名文件时,在新文件名后追加 _v2、_v3 等后缀,不覆盖已有文件。告知用户原始文件位置和新文件名。
默认保存到本地文件系统:
{存档根目录}/
├── YYYY-MM-DD_文章标题/
│ ├── 原文.md ← download-mcp 下载的原始内容(已清洗)
│ ├── yipian-笔记.md ← 产出的 MD 文档
│ └── yipian-笔记.html ← 产出的 HTML 文档
如果用户配置了其他后端(getnote / obsidian),则同步存储到对应后端。
同步失败处理: 如果 getnote/obsidian 同步失败(如网络断开、API 失效),降级为仅本地存档,并告知用户"XX 同步失败,已保存到本地"。不阻塞流程。
存档完成后告知用户路径。
Step 6:完成告知
产出校验: 输出完成报告前,检查产出文件是否存在且大小 > 0(本地模式)。如果文件丢失或为空,重新写入一次。仍失败则走"产出写入失败"降级方案。
输出简短完成报告,包含:
✅ 消化完成
【原文】
标题:《XXX》
来源:[链接]
【产出】
📄 MD 笔记:{路径}/yipian-笔记.md
🌐 HTML 笔记:{路径}/yipian-笔记.html
【内容概要】
(2-3 句话,概括消化后的核心产出)
文章类型自动识别
收到链接后,依据以下特征自动判断文章类型:
Type A — 知识型/信息型
- 介绍工具/产品/概念/方法论
- 告诉你"有个好东西存在"、"XX 是什么"
- 可实操内容少,以信息传递为主
- 产出:结构化笔记 + HTML
Type B — 操作型/教程型
- 包含安装命令、配置步骤、API 调用
- 教你"怎么装"、"怎么配置"、"怎么用"
- 有具体可执行的动作
- 产出:SOP 清单(含可自动化提示词)+ HTML
Type C — 感悟型/心法型
- 个人成长、心理学、哲学、感悟
- 不传递操作信息,传递思维框架和心法
- 以观点、案例、类比为主
- 产出:金句+观点集 + HTML
混合型判断
如果文章同时具有多种类型特征(如既介绍知识又包含具体操作步骤),自动识别为混合型,按"以第一类型为主格式,融入第二类型特征"处理。
平局 tiebreaker: 如果两种类型特征强度相等(无法判断哪个是"第一类型"),按以下优先级选择主格式:A(知识型)> B(操作型)> C(感悟型)。即 A=B 时选 A,B=C 时选 B。
用户手动覆盖
/yipian a <链接>— 强制按知识型处理/yipian b <链接>— 强制按操作型处理/yipian c <链接>— 强制按感悟型处理
信息缺口处理
精读或拆解中发现原文不够用时,按优先级使用补充手段:
- 本机资源优先:文章提到的 Skill/工具/产品,先查本机 agent 的 skills 安装目录是否已装有同类 skill(如
~/.pi/agent/skills/、~/.agents/skills/),已安装则直接读其 SKILL.md 作为一手资料(比搜索更准、更快、零成本)。例如:文章提到某 skill 时,若本机已装有同款,优先读它的一手资料补全细节 - doubao_search — 搜中文补充信息(优先)
- web_search — 通用搜索后备
- fetch_content — 抓关联页面
- 无搜索工具 → 纯靠 AI 知识,标注"未经过实时校验"
search_tool 配置处理: 如果 search_tool 配置的值不存在或不可用,自动降级到自动探测顺序(doubao_search → web_search → 无搜索),并在精读结果中标注"搜索工具不可用,部分信息未验证"。
搜索结果为空时的处理: 如果搜索返回 0 条有效结果,不强行编造,标注"未搜索到补充信息,此部分依赖原文内容"。
yipian 操作反例黑名单(dim9 应用)
| # | 反模式 | 为什么不要做 | 替代做法 |
|---|---|---|---|
| 1 | 概览中编造板块 | 原文只有 2 个板块但拆成 5 个来显得分析深入,精读时发现内容不够会中断流程 | 板块拆解按原文实际结构来,宁少不多。实在想补的放在"信息缺口"里 |
| 2 | 超出原文范围做深度搜索补全 | 搜索补充太多外部信息会导致笔记偏离原文主题,变成"基于原文的衍生文"而非原文消化 | 搜索只补原文「提到但讲得不细」的内容,不补原文「根本没提」的新领域。补充的信息在笔记中标注「🔍 搜索补充」 |
| 3 | HTML 注入外部 CDN/字体 | 离线打开时页面空白或样式崩坏,违背零外部依赖原则 | 所有 CSS/JS 内联,字体用系统默认字体栈(-apple-system, PingFang SC, Microsoft YaHei) |
| 4 | 精读时跳过用户确认直接全输出 | 用户可能只想要某几个板块的精读,全输出浪费阅读时间 | 严格按 Step 3 流程:用户选了板块才精读,没选的不出 |
| 5 | 操作型 SOP 中的提示词写死产品名称 | 用户换工具就用不了 | 用 [工具名] 占位符,另起一行说明"可替换为其他同类工具" |
| 6 | 存档时覆盖已有文件 | 之前的消化笔记丢失 | 同名文件追加 _v2/_v3 后缀,不覆盖。存档前告知用户文件位置 |
| 7 | 混合型文章只出单一格式 | A+B 混合型只出了结构化笔记没出 SOP,用户少了实操价值 | 混合型产物 = 主格式 + 副格式特征合并。如 A+B = 结构化笔记中每板块附带 SOP 步骤 |
| 8 | 不用 Python 而在 Windows 上纠结 curl/PowerShell | curl.exe 返回空结果、PowerShell 引号解析失败、中文文件名转义导致 MCP 下载耗时翻倍 | 所有 HTTP 请求和文件操作用 Python urllib.request 和 shutil 实现。中文文件名用 urllib.parse.quote() 编码。不要用 [System.Uri]::new() |
| 9 | 精读覆盖清单不核对直接出产出 | 用户选了板块 2/3/4/5,但 AI 只写了 2/3/4 漏了 5,不自知 | Step 4 组装前核对产出章节列表 vs 用户选择的板块列表,逐项打勾 |
| 10 | 搜索补充内容不标 🔍 标记 | 用户分不清哪些是原文内容、哪些是 AI 搜索补充的 | 所有搜索补充的内容前标注 🔍 搜索补充 |
| 11 | 输出的 MD/HTML 内容结构不一致 | 用户打开 MD 和 HTML 看到的信息不对应,产生困惑 | 写入前检查两者的板块/章节结构一致,关键数据和金句在两边都出现 |
| 12 | 手写 20 行 urllib 调 MCP / 手写清洗正则 / 手写 13KB HTML / 手写自检脚本 | 每次消化多花 5-10 分钟写代码,且内联 python 写中文/引号容易出错 | 一律调用附录中的固化脚本(A 下载 / B 清洗 / C 渲染 / D 自检)。先将代码写入临时 .py 文件再执行,不手写 |
| 13 | 存档位置与配置不一致 | 默认存 ~/yipian-notes/ 但用户可能希望存项目文件夹,返工移动 | 已改配置 local_path: "" → 默认存 yipian-output/(当前工作目录)。存档前先确认配置 |
| 14 | 确认次数过多 | 用户实际交互 = 发链接→选精读→直接全输出(接近 fast 模式),standard 模式多出 2-3 次确认 | 用户行为倾向快时,主动按 fast 模式节奏推进,把确认压缩到产出前一次 |
触发场景: Step 3 精读前、Step 4 组装前对照此表一次。任一反模式命中 → 调整方案重做。
确认节点一览(standard 模式 vs fast 模式)
| 类型 | standard 模式 | fast 模式 |
|---|---|---|
| Type A 知识型 | 初检+概览 → 板块选择 → 精读 → 存档 | 初检+概览自动展示 → 板块一次性选完 → 精读自动连续 → 存档 |
| Type B 操作型 | 初检+概览 → 事实核查结果 → 完成报告 | 初检+概览+事实核查自动完成 → 直接出完成报告 |
| Type C 感悟型 | 初检+概览 → 框架确认 → 完成告知 | 初检+概览自动完成 → 框架+产出一起出 |
| 多篇模式 | 初检+概览 → 精读方式选择 → 输出格式 | 初检+概览+精读方式一次性选 → 全主题输出 |
附录:工具脚本
> 以下 4 个 Python 脚本供 Agent 执行时使用。运行前需安装 Python 3;render_html.py 额外需要 pip install markdown。将对应代码块内容保存为 .py 文件后执行。
附录 A:download_article.py(公众号文章下载)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""download-mcp 下载公众号文章为 MD(处理中文文件名 URL 编码)。
用法: python download_article.py <url> [输出文件名]
默认输出文件名从 MCP 返回的 urls[0] 获取(含中文标题),保存到当前目录。
"""
import sys, os, json, urllib.request, urllib.parse
MCP_URL = 'https://changfengbox.top/api/mcp'
def download(url, outpath=None):
body = json.dumps({
'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call',
'params': {'name': 'wechat', 'arguments': {'url': url, 'config': {'MD': True}}}
}).encode()
req = urllib.request.Request(MCP_URL,
data=body,
headers={'Content-Type': 'application/json'})
resp = urllib.request.urlopen(req, timeout=60)
data = json.loads(resp.read())
txt = data['result']['content'][0]['text']
info = json.loads(txt)
if info.get('status') != 'completed':
raise RuntimeError('MCP 状态异常: %s' % info.get('status'))
dl_url = info['urls'][0]
if outpath is None:
outpath = os.path.basename(urllib.parse.unquote(urllib.parse.urlparse(dl_url).path))
parsed = urllib.parse.urlparse(dl_url)
encoded_path = urllib.parse.quote(parsed.path)
final_url = parsed.scheme + '://' + parsed.netloc + encoded_path
urllib.request.urlretrieve(final_url, outpath)
print('OK 已下载: %s (%d bytes)' % (outpath, os.path.getsize(outpath)))
return outpath
if __name__ == '__main__':
if len(sys.argv) < 2:
print('用法: python download_article.py <url> [输出文件名]')
sys.exit(1)
url = sys.argv[1]
out = sys.argv[2] if len(sys.argv) > 2 else None
try:
download(url, out)
except Exception as e:
print('FAIL: %s' % e)
sys.exit(1)
附录 B:clean_article.py(原文清洗)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""清洗公众号原文:去 UI 元素、压缩空行、保留正文结构。
用法: python clean_article.py <输入md> [输出md]
输出缺省时原地清洗。
"""
import re, sys
JUNK = [
'在小说阅读器读本章\n\n去阅读\n\n',
'微信扫一扫可打开此内容',
'使用完整服务',
'关注该公众号',
'知道了',
'使用小程序',
'取消 允许',
'× 分析',
'作者头像',
'视频 小程序 赞',
'分享 留言 收藏 听过',
]
def clean(text):
for junk in JUNK:
text = text.replace(junk, '')
idx = text.find('预览时标签不可点')
if idx > 0:
text = text[:idx]
text = re.sub(r'\n{3,}', '\n\n', text)
text = re.sub(r'[ \t]+$', '', text, flags=re.M)
return text
if __name__ == '__main__':
if len(sys.argv) < 2:
print('用法: python clean_article.py <输入md> [输出md]')
sys.exit(1)
inp = sys.argv[1]
out = sys.argv[2] if len(sys.argv) > 2 else inp
with open(inp, encoding='utf-8') as f:
content = f.read()
cleaned = clean(content)
with open(out, 'w', encoding='utf-8') as f:
f.write(cleaned)
print('OK 清洗完成: %s (%d chars)' % (out, len(cleaned)))
附录 C:render_html.py(HTML 渲染)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把 yipian-笔记.md 渲染为零外部依赖的交互式 HTML。
用法: python render_html.py <笔记md> <输出html>
"""
import sys, os, re
import markdown
class IdGen:
def __init__(self):
self.n = 0
def __call__(self, text, *args, **kwargs):
self.n += 1
return 'sec-%d' % self.n
TEMPLATE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{TITLE}} — yipian 消化笔记</title>
<style>
:root{--bg:#f7f7f5;--card:#fff;--text:#2b2b2b;--muted:#6b6b6b;--accent:#c0392b;--border:#e3e0da;--code-bg:#f0efec;--quote-bg:#faf6f4}
[data-theme="dark"]{--bg:#17181a;--card:#202226;--text:#e4e4e4;--muted:#9a9a9a;--accent:#e8735c;--border:#34363c;--code-bg:#2a2c31;--quote-bg:#241d1b}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--text);line-height:1.75;font-size:16px;transition:background .3s,color .3s}
.container{max-width:860px;margin:0 auto;padding:24px 20px 80px}
.topbar{display:flex;justify-content:space-between;align-items:center;max-width:860px;margin:0 auto;padding:18px 20px 0}
.brand{font-weight:700;font-size:15px;color:var(--accent)}
.theme-btn{border:1px solid var(--border);background:var(--card);color:var(--text);padding:6px 14px;border-radius:999px;cursor:pointer;font-size:13px}
header{margin:32px 0 28px}h1{font-size:26px;line-height:1.45;font-weight:800;margin-bottom:12px}
.meta{color:var(--muted);font-size:13.5px;line-height:1.8}.meta .m{display:inline-flex;align-items:center;gap:4px;margin-right:14px}
toc{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px 24px;margin:24px 0}
toc h2{font-size:16px;margin-bottom:10px;color:var(--accent);border:none}
toc a{display:block;color:var(--text);text-decoration:none;font-size:14.5px;padding:4px 0}toc a:hover{color:var(--accent)}
section{margin:44px 0}
h2{font-size:21px;font-weight:800;padding-bottom:10px;margin-bottom:18px;border-bottom:2px solid var(--border)}
h3{font-size:17px;font-weight:700;margin:22px 0 10px;color:var(--accent)}
p{margin:10px 0}ul,ol{margin:10px 0 10px 24px}li{margin:5px 0}
blockquote{border-left:4px solid var(--accent);background:var(--quote-bg);padding:12px 18px;margin:14px 0;border-radius:0 8px 8px 0;font-size:15px}
pre{background:var(--code-bg);border:1px solid var(--border);border-radius:10px;padding:16px 18px;overflow-x:auto;font-size:13.5px;margin:14px 0;font-family:"SF Mono",Consolas,"Courier New",monospace;line-height:1.6}
code{font-family:"SF Mono",Consolas,"Courier New",monospace;font-size:.92em}pre code{background:none;padding:0}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
th,td{border:1px solid var(--border);padding:9px 12px;text-align:left}th{background:var(--card);font-weight:700}
details{border:1px solid var(--border);border-radius:10px;margin:14px 0;background:var(--card);overflow:hidden}
summary{padding:13px 18px;cursor:pointer;font-weight:700;font-size:15px;list-style:none;position:relative;padding-right:40px}
summary::after{content:"+";position:absolute;right:18px;top:50%;transform:translateY(-50%);font-size:20px;color:var(--accent)}
details[open] summary::after{content:"−"}
details>*:not(summary){margin-left:18px;margin-right:18px}details>div,details>p{padding-bottom:14px}
.imgph{border:1px dashed var(--border);border-radius:10px;padding:22px;text-align:center;color:var(--muted);font-size:13.5px;margin:16px 0;background:var(--card)}
footer{text-align:center;color:var(--muted);font-size:12.5px;margin-top:60px;padding-top:20px;border-top:1px solid var(--border)}
@media(max-width:640px){body{font-size:15px}h1{font-size:21px}h2{font-size:18px}.container{padding:16px 14px 60px}table{font-size:12.5px}th,td{padding:6px 8px}}
</style>
</head>
<body data-theme="light">
<div class="topbar"><div class="brand">📚 yipian 消化笔记</div><button class="theme-btn" id="themeToggle">🌙 暗色模式</button></div>
<div class="container">
<header><h1>{{TITLE}}</h1><div class="meta">{{META}}</div></header>
<nav class="toc"><h2>📑 目录</h2>{{TOC}}</nav>
{{CONTENT}}
<footer>由 yipian 消化生成 · {{TITLE}}</footer>
</div>
<script>
(function(){var t=document.getElementById('themeToggle'),r=document.body;function a(v){r.setAttribute('data-theme',v);t.textContent=v==='dark'?'☀️ 亮色模式':'🌙 暗色模式';try{localStorage.setItem('yipian-theme',v)}catch(e){}}var s=null;try{s=localStorage.getItem('yipian-theme')}catch(e){}a(s==='dark'?'dark':'light');t.addEventListener('click',function(){a(r.getAttribute('data-theme')==='dark'?'light':'dark')})})();
</script>
</body>
</html>"""
def parse_front(text):
lines = text.split('\n')
title = lines[0].lstrip('#').strip() if lines and lines[0].strip().startswith('#') else ''
i = 1
meta_items = []
while i < len(lines) and lines[i].strip().startswith>'):
raw = lines[i].strip().lstrip('>').strip()
if raw: meta_items.append('<span class="m">%s</span>' % raw)
i += 1
return title, '<br>'.join(meta_items), '\n'.join(lines[i:])
def wrap_h3_in_details(content):
parts = re.split(r'(<h3[^>]*>.*?</h3>)', content, flags=re.S)
def to_summary(h3): return re.sub(r'<h3[^>]*>(.*?)</h3>', r'<summary>\1</summary>', h3, flags=re.S)
out, pending = [], None
for part in parts:
if re.match(r'<h3', part):
if pending: out.append('<details>%s%s</details>' % (to_summary(pending[0]), pending[1]))
pending = [part, '']
elif pending: pending[1] += part
else: out.append(part)
if pending: out.append('<details>%s%s</details>' % (to_summary(pending[0]), pending[1]))
return ''.join(out)
def render(md_path, html_path):
with open(md_path, encoding='utf-8') as f: text = f.read()
title, meta_html, body = parse_front(text)
md = markdown.Markdown(extensions=['extra', 'toc', 'attr_list', 'sane_lists'], extension_configs={'toc': {'slugify': IdGen()}})
content = wrap_h3_in_details(md.convert(body))
content = re.sub(r'<img[^>]*alt="([^"]*)"[^>]*/?>', lambda m: '<div class="imgph">📷 [图片] %s</div>' % (m.group(1) or ''), content)
content = re.sub(r'<a href="[^"]*"[^>]*>(.*?)</a>', r'\1', content, flags=re.S)
toc_ul = ''
m = re.search(r'<ul>.*?</ul>', md.toc, flags=re.S)
if m: toc_ul = m.group(0)
html = TEMPLATE.replace('{{TITLE}}', title).replace('{{META}}', meta_html).replace('{{TOC}}', toc_ul).replace('{{CONTENT}}', content)
with open(html_path, 'w', encoding='utf-8') as f: f.write(html)
print('OK 渲染完成: %s (%d bytes)' % (html_path, os.path.getsize(html_path)))
if __name__ == '__main__':
if len(sys.argv) < 3:
print('用法: python render_html.py <笔记md> <输出html>')
sys.exit(1)
try: render(sys.argv[1], sys.argv[2])
except Exception as e: print('FAIL: %s' % e); sys.exit(1)
附录 D:check_output.py(产出自检)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""yipian 产出自检:文件存在性、HTML 零外部依赖、MD/HTML 结构一致性、🔍 标记。
用法: python check_output.py <产出目录>
退出码: 0 = PASS, 1 = FAIL
"""
import os, re, sys
REQUIRED = ['原文.md', 'yipian-笔记.md', 'yipian-笔记.html']
def strip_md(s):
s = re.sub(r'\*\*|__|`|\*|_', '', s)
s = re.sub(r'\[([^\]]*)\]\([^)]*\)', '\1', s)
s = re.sub(r'<[^>]+>', '', s)
return re.sub(r'\s+', '', s)
def check(outdir):
issues = []
for name in REQUIRED:
p = os.path.join(outdir, name)
if not os.path.exists(p): issues.append('文件缺失: %s' % name)
elif os.path.getsize(p) == 0: issues.append('文件为空: %s' % name)
md_path = os.path.join(outdir, 'yipian-笔记.md')
html_path = os.path.join(outdir, 'yipian-笔记.html')
if not (os.path.exists(md_path) and os.path.exists(html_path)):
print('FAIL: 笔记文件缺失'); return issues
md = open(md_path, encoding='utf-8').read()
html = open(html_path, encoding='utf-8').read()
ext = [u for u in re.findall(r'https?://[^\'\'\s<>]+', html) if not u.startswith(('mailto:', 'tel:'))]
if ext: issues.append('HTML 含外部引用: %s' % ext[:5])
md_h2 = re.findall(r'^## (.+)$', md, re.M)
html_h2_norm = [strip_md(re.sub(r'<[^>]+>', '', h)) for h in re.findall(r'<h2[^>]*>(.*?)</h2>', html, flags=re.S)]
missing = [s for s in md_h2 if strip_md(s) and not any(strip_md(s) in h or h in strip_md(s) for h in html_h2_norm)]
if missing: issues.append('MD 章节在 HTML 缺失: %s' % missing)
md_has, html_has = ('🔍' in md), ('🔍' in html)
if md_has != html_has: issues.append('🔍 标记不一致: MD=%s HTML=%s' % (md_has, html_has))
return issues
if __name__ == '__main__':
if len(sys.argv) < 2:
print('用法: python check_output.py <产出目录>')
sys.exit(1)
issues = check(sys.argv[1])
if issues:
print('FAIL:')
for i in issues: print(' - %s' % i)
sys.exit(1)
print('PASS 全部自检通过')