From cbb405e80aff129ae9d6ac764105239faf67b130 Mon Sep 17 00:00:00 2001 From: Leroy Date: Tue, 6 Oct 2026 13:57:30 +0800 Subject: [PATCH] =?UTF-8?q?le-wechat-article=20v1.0=EF=BC=9ALeroy=20?= =?UTF-8?q?=E4=B8=93=E5=B1=9E=E5=85=AC=E4=BC=97=E5=8F=B7=E5=86=99=E4=BD=9C?= =?UTF-8?q?=E5=B7=A5=E5=9D=8A=EF=BC=88=E7=B1=BB=E5=9E=8B=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=20/=20=E5=88=86=E5=B1=82=E5=AE=A1=E8=AF=BB=20/=20=E6=8E=92?= =?UTF-8?q?=E7=89=88=E8=BD=AC=E6=8D=A2=20/=20=E8=8D=89=E7=A8=BF=E7=AE=B1?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/le-wechat-article/README.md | 31 ++ skills/le-wechat-article/SKILL.md | 155 +++++++++ .../evals/behavior_cases.json | 31 ++ .../evals/trigger_cases.json | 26 ++ .../references/article-types.md | 61 ++++ .../references/author-profile.md | 59 ++++ .../references/review-playbook.md | 96 ++++++ .../references/style-rules.md | 61 ++++ .../references/topic-facts.md | 33 ++ .../references/visual-production.md | 61 ++++ .../reports/trigger-eval.json | 285 ++++++++++++++++ .../scripts/check_article.py | 224 +++++++++++++ skills/le-wechat-article/scripts/md2wechat.py | 311 ++++++++++++++++++ .../scripts/publish_draft.py | 255 ++++++++++++++ 14 files changed, 1689 insertions(+) create mode 100644 skills/le-wechat-article/README.md create mode 100644 skills/le-wechat-article/SKILL.md create mode 100644 skills/le-wechat-article/evals/behavior_cases.json create mode 100644 skills/le-wechat-article/evals/trigger_cases.json create mode 100644 skills/le-wechat-article/references/article-types.md create mode 100644 skills/le-wechat-article/references/author-profile.md create mode 100644 skills/le-wechat-article/references/review-playbook.md create mode 100644 skills/le-wechat-article/references/style-rules.md create mode 100644 skills/le-wechat-article/references/topic-facts.md create mode 100644 skills/le-wechat-article/references/visual-production.md create mode 100644 skills/le-wechat-article/reports/trigger-eval.json create mode 100644 skills/le-wechat-article/scripts/check_article.py create mode 100644 skills/le-wechat-article/scripts/md2wechat.py create mode 100644 skills/le-wechat-article/scripts/publish_draft.py diff --git a/skills/le-wechat-article/README.md b/skills/le-wechat-article/README.md new file mode 100644 index 0000000..c3fd4ce --- /dev/null +++ b/skills/le-wechat-article/README.md @@ -0,0 +1,31 @@ +# le-wechat-article + +> Leroy 专属的微信公众号文章写作工坊:把主题、素材或旧稿写成可直接发布的高质量公众号文章。 + +**状态**:shared 档,随 LeMetaSkill 项目安装使用。 + +## 它解决什么问题 + +写公众号文章时最耗神的不是打字,而是每次都要重新把关的事:这篇该用什么结构、哪里在注水、哪里有 AI 味、事实有没有被写走样、排版能不能直接贴进公众号编辑器。这个 skill 把这些判断固化成类型路由 + 审读手册 + 机器自检 + HTML 转换,让你每次丢来主题或素材,都能拿到一篇过得了审读、贴得进编辑器的成稿。 + +## 触发方式 + +- 「帮我写篇公众号文章,讲讲 XX」 +- 「把这份素材/笔记/复盘整理成推文」 +- 「发公众号:XX 更新了这几点」 +- 「这篇旧稿改成公众号版本,去一下 AI 味」 +- 「润色下这篇公众号稿子,看看能不能发」 + +## 交付物 + +`article.md`(终稿)+ `article.html`(粘贴公众号用,3 骨架 × 4 色板可选)+ 审读报告摘要(含合规清零)+ 3 个标题候选与摘要 + 封面 brief(七步推导,可联动 ckm-banner-design 直接出图)。 + +可选收尾:经官方 API 同步到公众号草稿箱(`scripts/publish_draft.py`,需已认证公众号 + `WECHAT_APP_ID`/`WECHAT_APP_SECRET` 环境变量 + IP 白名单;自动提交 2.35:1 与 1:1 封面裁剪框;首次用 `--check` 验证,`--dry-run` 预览计划)。 + +## 安全边界 + +同步止于草稿箱,**群发与发表永远由用户在公众号后台手动完成**;平台合规命中一律必改;不做小红书/知乎等其他平台;素材不足时停下来要料,不编造;审读由 subagent 独立执行(长文加终审),主 agent 只改不评;密钥只放环境变量,不进 git。 + +## 致谢 + +方法融合自两个开源项目(仅借机制与方法,未复制表达):[mp-article-writor](https://github.com/balabalabalading/huuuuuuho-skills/tree/main/skills/mp-article-writor)(MIT)的类型路由、去模板化审读与语义保真;[wechat-article-skills](https://github.com/aiworkskills/wechat-article-skills)(Apache-2.0)的产出配额、AI 味指纹阈值与机器自检思想。 diff --git a/skills/le-wechat-article/SKILL.md b/skills/le-wechat-article/SKILL.md new file mode 100644 index 0000000..2e645bd --- /dev/null +++ b/skills/le-wechat-article/SKILL.md @@ -0,0 +1,155 @@ +--- +name: le-wechat-article +description: > + Leroy 专属的微信公众号文章写作工坊:把一个主题、一堆素材(链接、笔记、聊天记录、代码变更)、 + 一段复盘或调研,或一篇旧稿,写成可直接发布的高质量公众号文章——类型路由、质量审读、排版自检、微信可粘贴 HTML。 + 当用户说「写篇公众号文章」「把这份素材/复盘整理成推文」「发公众号」「把这篇改写成公众号文章」 + 「润色下这篇公众号稿」「同步到公众号草稿箱」时使用;只丢素材并说明要发公众号也触发。 + 可选收尾:凭证配好后经官方 API 把成稿写入公众号草稿箱(需用户当次同意,只写草稿)。 + 不要用于:群发/发表推文(同步止于草稿箱,发表永远由用户在后台手动完成)、 + 小红书/知乎/微博等其他平台的内容、非公众号场景的长文(技术文档、博客、周报、论文)。 +--- + +# le-wechat-article + +把主题、素材或旧稿变成可发布的公众号文章。质量优先于速度:宁可多一轮审读,不交一篇有编造或模板味的稿子。 + +## 核心原则 + +- **真实性是底线。** 每个事实性陈述都要能追溯到用户素材、公开可验证信息或用户声明的个人经历。素材不足就明说缺什么,不为凑篇幅编造细节、数据、经历或情绪。 +- **类型决定写法。** 不同文章类型有不同的开头、节奏和审读标准(见 [references/article-types.md](references/article-types.md)),不把所有文章写成一种腔调。 +- **修改不得走样。** 审读修改时保护事实、否定、范围、时间、归因和确定程度——不得把相关写成因果、可能写成确定、计划写成已完成。 +- **判断放参考、数数放脚本。** 结构和审读的判断读 references/;能机器量的(标题层级、加粗密度、金句数、表格、中英文间距)交给 `check_article.py`,不靠眼睛。 + +## 工作流 + +按步骤顺序执行;用户当次已明确给过的信息直接采用,不重复询问。**每步完成后向 Leroy 展示该步生成物,确认后进下一步**(Leroy 说「一口气跑完」或明确授权连续执行时可跳过中间确认)。轻稿(简报类、<1500 字)可合并第 3–4 步,但第 5–6 步的审读与自检不可跳过。 + +### 第 1 步:接单与类型路由 + +先读 [references/article-types.md](references/article-types.md),按「文章要回答的核心问题」选定唯一主类型,记录下来。用户已明确类型或目的清楚时直接选定;只有类型会实质改变结构且无法判断时才问一次。 + +缺失且影响结构的信息,一次性问全(能从素材推断的不问): + +- 切入角度与核心主旨(读完读者该记住什么,一句话) +- 真实素材边界:哪些是用户的真实经历,哪些细节必须保留或避免 +- 输出目录(未指定则在当前目录建 `YYYYMMDD-/`) + +用户只说「帮我写篇公众号文章」没给主题 → 先给 3–5 个选题角度卡片(标题候选 + 一句话切入 + 适合的类型),等用户选定再进入流程;禁止未经选定就开始调研或写稿。 + +### 第 2 步:校准风格与事实 + +读三份参考:[style-rules.md](references/style-rules.md)(排版硬规则与产出配额)、[author-profile.md](references/author-profile.md)(作者画像,管风格)、[topic-facts.md](references/topic-facts.md)(常用事实卡,管事实——涉及 Leroy 的项目/工具链/经历时从中取用,免于重新询问)。 + +画像文件还是空模板时,用其中的「首访问题」问 Leroy 一次,把答复写回该文件(此文件随本 skill 存放,属于个人资产);之后不再重复问。事实卡留空则跳过,Leroy 随时可补。画像与事实卡都不能覆盖类型路由、事实核查和真实性底线。 + +### 第 3 步:大纲与图位 + +按所选类型的结构骨架起草大纲,同时规划图位(按 style-rules 的密度档位表选一档)。每个图位标注「信息位 / 节奏位」: + +- **信息位**:删掉这张图读者会**看不懂**(流程、结构、数据、对比)→ 图上文字逐字取自文章 +- **节奏位**:删掉这张图读者会**读不下去**(纯换口气)→ 图上不加文字 +- 两者都不影响 → **删掉这个图位**,不为好看配图 +- 真实截图/照片优先作为**实证位**直接使用,不算信息位/节奏位 + +将大纲 + 图位清单呈现给用户确认后进入下一步;用户当次已给并确认过大纲的,核对覆盖要求后直接用。 + +### 第 4 步:写初稿 + +遵守 style-rules 的全部硬规则(标题层级、表格限制、中英文间距、直角引号),并满足产出配额——这些规则不靠自觉,第 6 步有脚本核验: + +- **加粗是扫读落点**:每 100 字左右一处,连续 200 字不得没有;每个小节至少一处。验收标准:把全文加粗按顺序连起来读,应该是一篇能独立看懂的缩写版 +- **摘要 ≠ 导语**:front matter 的 digest 与正文开头导语不得是同一句话 +- **金句卡至多一处**:`> 金句。 —— 出处`,多了就退化成装饰 + +初稿写到 `article.md`,同时复制一份完整副本到同目录 `review-baseline.md`。基线在终稿交付前不得修改——第 5 步审读要对照它检测语义漂移。 + +### 第 5 步:分层独立审读(subagent) + +两项审读,各起独立 subagent(关注点不同,混在一次会顾此失彼),都给它:作者素材、`review-baseline.md`、当前 `article.md`、文章类型、[references/review-playbook.md](references/review-playbook.md)。 + +**5a 内容审读**——按手册输出意见清单(每条含位置、可观察的问题、对阅读的影响、必须保留的信息、建议方向):去模板化、AI 味指纹(按防误判阈值,命中本身不是修改理由)、语义保真风险(对照基线标出可能改变事实/否定/范围/条件/时间/归因/确定程度/立场的位置)。 + +**5b 事实核查 + 合规扫描**——数字、事件、产品名、引用是否可追溯到素材;平台合规逐类扫描(违禁内容、广告法绝对化用语、医疗金融承诺、未授权素材、画像自定义敏感词清单)——**合规命中一律必改,与 AI 味的建议级不同**。 + +**分档**:正文 <2500 字且为简报类 → 5a/5b 合并为一次 subagent(分两部分输出);否则分开两次。 + +subagent 可以给出「没有需要修改的问题」;不要为填满清单制造意见。同一只手不改又评:修改由主 agent 做,不由审读 subagent 代改。 + +### 第 6 步:修改、终审与机器自检 + +逐条处理审读意见,每条做出「采纳」或「不采纳(附理由)」的判断;只改确实空泛、重复、歧义或不符合类型文体的表达,不为展示修改量重写已经清楚的内容。需要个人表达的类型只用素材中已有的视角和细节。 + +**长文终审**(正文 ≥2500 字或类型为测评/复盘/叙事/技术):修改完成后、交付前,再起一个 subagent 对照「作者素材 + 基线 + 当前稿」做终审——只查三件事:新增/遗漏/强化了主张没有、合规清零没有、HKR 一句话总评。轻稿(简报 <2500 字)跳过终审,主 agent 自查即可。 + +然后跑自检直到退出码为 0: + +```bash +python3 scripts/check_article.py <文章目录>/article.md --type <主类型> +``` + +脚本输出加粗串读提要——自己读一遍那一串,像词云或像把段落重念一遍都是挑错了落点,这要人来判断,脚本量不了。 + +**改稿后复核规则**:终审通过后,凡又修改了标题或正文含义的,先做一次限定范围的语义保真核对(对照基线,只查被改的部分——长文用 subagent,轻稿主 agent 自查),通过才能交付;纯排版、图片路径、front matter 状态更新不需要复核。 + +### 第 7 步:交付 + +0. **图片生产(默认执行,Leroy 明确不要图时才跳过)**:正文图位与封面按 [visual-production.md](references/visual-production.md) 生产——交给 ckm-banner-design,或 SVG 自绘 + 浏览器截图均可,满足 brief 验收点即可。信息位图上的文字**逐字取自文章**,主题色与排版骨架一致;可编辑源文件(SVG/HTML)随 PNG 一起保留在 `imgs/src/`。生成后把正文图位占位替换为 `imgs/<文件名>.png` 真实路径——**终稿不得残留 placeholder**(此时 check_article 不再加 `--allow-placeholder`)。 + +1. 排版转换(纯本地,零依赖)。骨架按文章类型选:工程(技术/教程)、杂志(叙事/访谈)、简报(更新/快讯);骨架与色板以作者画像的「排版偏好」为默认: + + ```bash + python3 scripts/md2wechat.py <文章目录>/article.md --skeleton <工程|杂志|简报> --palette <靛蓝|松绿|黛紫|绛红> -o <文章目录>/article.html + # 不确定选哪个时先看对照页:--preview 生成三骨架 375px 样张,让 Leroy 挑 + ``` + + 输出的 HTML 全内联样式,全文复制粘贴进公众号编辑器即可用。 + +2. 交付清单(一次给全): + - `article.md`(终稿)+ `article.html`(粘贴用)+ 审读报告摘要(采纳/驳回计数 + 合规清零 + 遗留风险) + - **3 个标题候选**(混两种以上风格,注明各自打法,避开 style-rules 的禁用套路)+ digest 摘要(80–120 字) + - **封面 brief**:按 [visual-production.md](references/visual-production.md) 七步推导产出——2.35:1 规格、主标题 4–7 字且在中央 1:1 安全区、短标题(分享卡用)、张力一句话、形态与媒介、避雷项。需要直接出图时把 brief 交给 ckm-banner-design 执行,产出按「345px 缩略回看」验收 + - 图片状态:信息位/节奏位/实证位图是否就位;未生成的列出建议 +3. 提醒用户:标题填在公众号后台(正文不含大标题);图片需在编辑器中手动上传(走第 8 步草稿箱同步则自动上传)。 + +### 第 8 步:同步到公众号草稿箱(默认执行) + +交付确认后**直接同步**——凭证配置完成即视为持续授权(Leroy 说过「先审再同步」的,按其要求先展示等待确认)。Leroy 明确说不不同步时才跳过。封面必须就位(草稿接口必填封面),正文图未全部就位时同步前说明。 + +前提:已认证公众号 + 环境变量 `WECHAT_APP_ID`、`WECHAT_APP_SECRET`(后台「设置与开发 → 基本配置」获取,放 shell 环境变量,不进任何 git 文件)+ 调用方公网 IP 已加入后台 IP 白名单。 + +```bash +# 首次使用或怀疑凭证/IP 问题时先验证(只调 token 接口,不写内容) +python3 scripts/publish_draft.py . --check +# 确认执行计划(不联网) +python3 scripts/publish_draft.py <文章目录> --title "标题" --dry-run +# 正式同步:上传封面与正文图 → 替换为微信域名地址 → 写入草稿箱 +python3 scripts/publish_draft.py <文章目录> --title "标题" --digest "摘要" +``` + +同步成功即止:提示用户到「内容与互动 → 草稿」人工预览,**发表永远由用户手动完成**。脚本默认随草稿提交 2.35:1 与 1:1 两个封面裁剪框(居中最大区域,防标题被默认裁掉;封面设计满足中央安全区时效果最佳),`--no-crop` 可跳过。脚本已内置错误码翻译(48001 = 账号未认证无 API 权限;40164 = IP 不在白名单;40113 = 图片编码被素材接口拒,转 jpg 重试);失败时保留本地文件原状,把错误提示给用户,不盲目重试超过一次。封面图缺失时先按封面 brief 生成(交给 ckm-banner-design)再同步。 + +## 失败模式与停手点 + +- **素材不足以支撑所选类型** → 停下来列出缺什么,让用户补素材或换类型;不编造垫稿。 +- **审读意见互相冲突**(如保真要求与去模板化要求打架)→ 按手册判断顺序裁决:保信息 > 保类型 > 保作者声音 > 改表达;裁决不了的带两案问用户。 +- **用户要「直接发表/群发」** → 拒绝:同步止于草稿箱,发表由用户在公众号后台手动完成;凭证缺失时给配置指引并停,不代填。 +- `check_article.py` 跑不过且原因不明 → 先修硬规则项;脚本本身出错时报告错误,改为人工对照 style-rules 逐项检查,不假装通过。 + +## 反例黑名单 + +- 不把一次性总结、标题党、凑字注水当交付;篇幅区间是参考,结构完整优先。 +- 不在简报类文章里强加个人故事、情绪曲线或价值升华;不为提高「共鸣」编造经历。 +- 不用「随着 AI 的发展」「综上所述」这类零信息套话开头收尾;连接词只在承担真实顺序、转折、总结功能时保留。 +- 不把技术术语当 AI 味删掉;技术解析保持应有正式度,不统一改成聊天语气。 +- 不在没跑 check_article.py 的情况下宣称「自检通过」。 +- 不把作者画像、素材中的私人内容复制进交付物或对外文件。 + +## Reference Map + +- 五类型路由与各类型结构:[article-types.md](references/article-types.md) +- 公众号排版硬规则、标点规范、产出配额、标题规范:[style-rules.md](references/style-rules.md) +- 审读手册(去模板化 + AI 味指纹 + 平台合规 + 语义保真):[review-playbook.md](references/review-playbook.md) +- Leroy 作者画像(风格,渐进填充):[author-profile.md](references/author-profile.md) +- Leroy 常用事实卡(事实,渐进填充):[topic-facts.md](references/topic-facts.md) +- 封面与插图生产方法(七步推导 + ckm-banner-design 联动):[visual-production.md](references/visual-production.md) diff --git a/skills/le-wechat-article/evals/behavior_cases.json b/skills/le-wechat-article/evals/behavior_cases.json new file mode 100644 index 0000000..cdfaf24 --- /dev/null +++ b/skills/le-wechat-article/evals/behavior_cases.json @@ -0,0 +1,31 @@ +{ + "_说明": "前向行为评测:预置全部接单信息,模型应不重复询问直接执行工作流。评测重点=保真性与合规,不是文采。跑完后人工对照 assertions 核验。", + "cases": [ + { + "id": 1, + "name": "简报·预置输入不重复问+保留计划状态", + "prompt": "用 le-wechat-article 写一篇公众号文章,以下信息已确认,不要重复询问:类型=新闻和软件更新简报;主旨=说明本次已完成的三项功能和一项未完成的边界;没有第一人称经历;图片密度=按需配图;输出目录=/tmp/le-wx-eval-01;不需要同步草稿箱。素材:本次新增导出、搜索、批量重命名三个功能;离线编辑计划下一版推出,尚未完成测试。素材有限,允许低于推荐篇幅,不要为凑字数补写,不要使用素材之外的外部事实。", + "assertions": [ + "不重复询问已提供的类型/目录/密度等信息", + "导出、搜索、批量重命名三个功能全部保留,不为凑数增删", + "离线编辑保持「下一版计划且未完成测试」的表述,未写成已上线", + "无编造的日期、性能数据、用户反馈或第一人称经历", + "开头按简报规范:直接说明信息范围 + 无序列表概括", + "标题层级合规(一个 #,正文 ###),跑过 check_article.py" + ] + }, + { + "id": 2, + "name": "复盘·保留不确定性+不改写成SOP", + "prompt": "用 le-wechat-article 写一篇公众号文章,以下信息已确认,不要重复询问:类型=个人实践和工作流复盘;主旨=复盘一次批量导出流程调整及其有限证据;真实经历仅限素材所述;图片密度=少图;输出目录=/tmp/le-wx-eval-02;不需要同步草稿箱。素材:第一次批量导出时我逐个输入文件名,用了 40 分钟;第二次改用现成命名模板,用了 25 分钟;目前只有这两次记录,不能判断长期效果。素材有限,允许低于推荐篇幅,不要写成教程或通用 SOP,不要使用素材之外的外部事实。", + "assertions": [ + "只使用素材已有的第一人称经历和判断,无补写的冲突、失败或情绪", + "保留「只有两次记录、不能判断长期效果」的限制,未推导普遍结论", + "未改写成教程、固定阶段或通用 SOP", + "问题→尝试→结果的因果链清楚,数字(40/25 分钟)未被改动", + "审读环节实际执行(有意见清单输出),修改采纳/驳回有理由", + "长文阈值未到时可合并审读,但机器自检不可跳过" + ] + } + ] +} diff --git a/skills/le-wechat-article/evals/trigger_cases.json b/skills/le-wechat-article/evals/trigger_cases.json new file mode 100644 index 0000000..9541eac --- /dev/null +++ b/skills/le-wechat-article/evals/trigger_cases.json @@ -0,0 +1,26 @@ +{ + "_说明": "le-wechat-article 触发用例。正例=公众号写作意图(含同步草稿箱收尾);负例=相邻但不该触发的请求。negative_patterns 是词面黑名单:群发/跨平台/非公众号文体都不归本 skill。", + "recommended_threshold": 0.3, + "negative_patterns": ["不要做成 skill", "只要总结", "翻译一下", "群发", "小红书", "知乎", "微博", "周报", "README"], + "should_trigger": [ + {"text": "帮我写篇公众号文章,讲讲最近用 Agent Skill 的体会", "family": "explicit_new_article"}, + {"text": "把这份调研笔记整理成一篇推文", "family": "material_to_article"}, + {"text": "发公众号:ZGLM-5.3 发布了,主要更新这三点", "family": "release_brief"}, + {"text": "我这里有一段上次做自动化测试的复盘,写成公众号文章吧", "family": "retro_article"}, + {"text": "这篇旧稿帮我改成公众号的版本,去一下 AI 味", "family": "rewrite_old_draft"}, + {"text": "润色下这篇公众号稿子,看看能不能发", "family": "polish_check"}, + {"text": "素材在这个目录里,整理成公众号文章,输出到 articles 下面", "family": "dir_material"}, + {"text": "写完把这篇同步到公众号草稿箱", "family": "sync_draft"} + ], + "should_not_trigger": [ + {"text": "帮我把这篇稿子群发给所有粉丝", "family": "mass_send"}, + {"text": "把这篇小红书笔记改成知乎回答", "family": "other_platform"}, + {"text": "帮项目写一份 README 文档", "family": "tech_doc"}, + {"text": "把这周的代码变更总结成周报", "family": "weekly_report"}, + {"text": "给这个函数写个注释,解释一下逻辑", "family": "code_comment"} + ], + "near_neighbor": [ + {"text": "帮我找几个公众号选题", "family": "topic_only", "note": "应触发:skill 的轻量选题支线覆盖「只要选题」入口"}, + {"text": "给这篇文章配几张图", "family": "image_only", "note": "边界:单只要配图不是本 skill 主职责,但若在公众号文章语境下可接"} + ] +} diff --git a/skills/le-wechat-article/references/article-types.md b/skills/le-wechat-article/references/article-types.md new file mode 100644 index 0000000..bd698f0 --- /dev/null +++ b/skills/le-wechat-article/references/article-types.md @@ -0,0 +1,61 @@ +# 文章类型路由 + +在接单时选定唯一主类型,写作、大纲、审读、自检全程应用对应规则。类型由「文章要回答的核心问题」决定,日期、版本号等表面特征只作辅助。 + +## 路由顺序 + +1. 用户明确指定的类型优先。 +2. 回答「更新了什么」→ **新闻和软件更新简报** +3. 回答「实际用起来怎么样、值不值得、适合谁」→ **产品体验和工具测评**(即使素材含版本号,谈体验就是测评) +4. 回答「这件事怎么做成的、有什么可复用」→ **个人实践和工作流复盘** +5. 回答「为什么、怎么理解、怎么复现」→ **技术解析和教程** +6. 靠生活事件、观察、观点推进 → **生活叙事和观点文章** + +混合素材只选一个主类型,其他类型的写法可局部借用,但不得同时套多套开头和结尾结构。判断不了且会改变结构时,问用户一次。 + +## 各类型规格 + +### 新闻和软件更新简报 + +- **回答**:更新了什么、影响哪些操作、怎么用上 +- **开头**:第一段直接说日期/版本/信息范围,紧接无序列表概括主要更新。列表只写用户可感知的变化,不罗列测试、重构、内部维护 +- **推进**:每节标题直接写功能变化;标题后第一段立即说改动点,随后 1–3 段说影响、场景、用法。会改变读者判断的限制才进正文;包管理器、内部模块等细节除非影响安装/兼容/安全否则删 +- **篇幅**:1500–3000 字,信息完整可以更短 +- **来源**:commit SHA、PR 地址等代码证据记在交付时的审读报告里,不塞进正文;确有查阅价值时文末集中列官方公告链接 +- **审读重点**:标题+首段+列表能否一分钟讲清更新范围;每节是否含改动点/影响/用法;是否把开发中的代码写成了已发布;**不要求个人故事和情绪,HKR 以 K 为主,R 低不算失败** + +### 产品体验和工具测评 + +- **回答**:真实任务里表现如何、优缺点、适合谁 +- **开头**:从一次真实使用任务或最影响判断的体验结论切入,同时交代使用条件。不因出现版本号就写成更新列表 +- **推进**:按「使用任务 → 实际表现 → 体验变化 → 优势与限制 → 适用对象」。功能只在解释体验时出现,功能清单不是测评 +- **篇幅**:2500–5000 字 +- **审读重点**:每项判断是否来自真实操作或素材;使用条件/限制是否交代;是否把短期试用写成了普遍结论;是否把宣传语当成了测试结论 + +### 个人实践和工作流复盘 + +- **回答**:任务怎么完成的、过程怎么调整的、什么可复用 +- **开头**:从真实问题、任务目标或限制条件切入,让读者先知道当时要解决什么 +- **推进**:按「问题 → 尝试 → 调整 → 结果 → 复盘」。尝试多时围绕关键决策点组织,不必逐项记录 +- **篇幅**:2500–6000 字 +- **审读重点**:问题-尝试-结果之间因果是否清楚;是否补写了未经确认的经历、失败次数、情绪;复盘结论是否由实际过程支撑;读者能否识别可复用的方法及其适用条件;**不得加工成一次性成功故事** + +### 技术解析和教程 + +- **回答**:问题为什么发生、概念怎么理解、操作怎么复现 +- **开头**:从明确问题、技术现象或待解释概念直接进入,不硬造叙事场景,也不堆时代背景 +- **推进**:按「问题 → 原理 → 验证/操作 → 边界」。教程保留环境、步骤、配置和失败处理;解析区分已验证事实、解释和推断。代码和命令只留完成任务需要的部分——保留可复现的信息,但不贴大段代码 +- **篇幅**:2500–6000 字 +- **审读重点**:问题-原理-操作是否对应、能否复现;环境与前置条件是否完整;事实/验证/推断是否分清;技术细节是否服务理解而非挤占结论;**保持术语和正式度,不为「自然」改成聊天语气** + +### 生活叙事和观点文章 + +- **回答**:通过事件、观察或观点呈现经验与思考 +- **开头**:从真实事件、当下观察、具体细节或明确观点切入,可留悬念,不用空泛背景 +- **推进**:时间、人物、主题、思考变化均可作线;细节与观点相互支撑;可用回环留白,不套论证模板 +- **篇幅**:2000–5000 字 +- **审读重点**:事件与经历是否真实、外部事实可否追溯;观点是否由细节支撑;结构是否自然、有无强行升华;作者声音是否稳定、情绪是否来自素材 + +## 篇幅的共同原则 + +区间是参考不是配额:结构完整、逻辑连贯优先,素材不足时短于下限也可交付(在审读报告中注明),不为凑字数注水。 diff --git a/skills/le-wechat-article/references/author-profile.md b/skills/le-wechat-article/references/author-profile.md new file mode 100644 index 0000000..e294679 --- /dev/null +++ b/skills/le-wechat-article/references/author-profile.md @@ -0,0 +1,59 @@ +# Leroy 的作者画像 + +本文件随 skill 存放,属于个人资产:写作前加载,让每篇文章保持同一个人的声音。画像只细化偏好,不能覆盖文章类型路由、事实核查与真实性底线。 + +## 使用方式 + +- 首次使用时,用下方「首访问题」问 Leroy 一次,把答复写进对应小节,之后不再重复问 +- Leroy 随时可以直接口述修改本文件;改完即时生效 +- 本文件内容不复制进文章交付物或对外文件 + +## 首访问题(未填写时问一次) + +1. 你的公众号叫什么、主要写给谁看?(账号定位与读者画像) +2. 你希望读者读完记住你是一个什么样的作者?(声音与气质) +3. 有没有你特别喜欢/不能接受的表达方式?(如:口语化程度、能否用梗、第一人称习惯) +4. 文末固定要加什么?(关注引导/签名/往期推荐/什么都不加) +5. 有没有偏好的排版主题色?(影响 md2wechat.py 的默认配色) + +## 账号与读者 + +读者定位:**入门科普向**——没用过微调但会用 AI 工具的人。类比优先,术语首次出现必须解释;不假设编程背景。(2026-10-05 首访确认) + +## 声音与语气 + +默认中性基线:清楚、自然、有取舍;观点由素材支撑。个性化偏好写在这里: + +(待填写) + +## 各类型偏好(可选,按 article-types.md 的五类型分别覆盖) + +- 新闻和软件更新简报:(待填写) +- 产品体验和工具测评:(待填写) +- 个人实践和工作流复盘:(待填写) +- 技术解析和教程:(待填写) +- 生活叙事和观点文章:(待填写) + +## 文末固定块 + +默认追加关注引导,逐字使用: + +```markdown +--- + +觉得有用?点个关注,持续收到这类内容。 +``` + +(2026-10-05 首访确认;改文案直接编辑此处) + +## 排版偏好 + +默认排版骨架:**工程**(色条标题,适合技术文) +默认主题色:**靛蓝 #1A5CFF** +默认字号:16px(公众号正文标准) + +## 敏感词清单(可选) + +一行一个,审读时逐词扫描,命中即必改;留空则只按通用合规类别检查: + +(待填写) diff --git a/skills/le-wechat-article/references/review-playbook.md b/skills/le-wechat-article/references/review-playbook.md new file mode 100644 index 0000000..980b470 --- /dev/null +++ b/skills/le-wechat-article/references/review-playbook.md @@ -0,0 +1,96 @@ +# 审读手册 + +供独立审读(subagent)与修改环节使用。目标:发现空泛、重复、失真和不符合文体的表达——**不是**作者身份检测,不保证通过任何 AI 检测器。没有问题时明确说「没有需要修改的问题」,不为填清单制造意见。 + +## 判断顺序(冲突时靠前赢) + +1. 保留信息和确定程度 +2. 遵守本次任务范围和文章类型 +3. 保留作者素材中已有的声音与立场 +4. 处理具体表达问题 + +模式命中只是线索,不是禁词表:单个词语、三项列表、被动句都不单独构成修改理由。 + +## 语义保真(每次修改都要对照基线) + +对照 `review-baseline.md` 和作者素材,核实以下内容没有被新增、遗漏或强化: + +- 独立事实、数字、名称、日期、引用 +- 否定、比较对象、适用范围和条件 +- 时间、进行状态、完成状态、后续计划 +- 判断的归因对象 +- 「可能、据称、预计、仅、至少、正在、尚未」承载的确定程度 +- 作者已表达的立场、犹豫和限制 + +红线:不把相关写成因果、可能写成确定、计划写成已完成;不为显得具体补造数据、来源、经历、情绪、冲突、引文。审读只指出问题和不改变含义的修改方向,不代改。 + +## 平台合规(命中即必改,与 AI 味的「默认建议」不同级) + +公众号内容合规是发文失败与限流的实际来源。逐类扫描,命中任何一条都按🔴必改处理,不带「建议」: + +- **违禁内容**:政治敏感表述、暴恐、色情低俗、赌博诈骗类措辞——拿不准的具体表述列出来问 Leroy,不自行改写后放行 +- **广告法绝对化用语**:最、第一、唯一、100%、国家级、顶级、极致、史上最 X——用户素材里确有出处的数据表述(如「官方公布的 100% 兼容」须附来源)除外 +- **医疗与金融承诺**:疗效保证、稳赚、保本、收益率承诺、荐股荐基类表述 +- **未授权素材**:他人图片/大段引文无来源标注;无法确认授权的标「待作者确认」 +- **自定义敏感词**:Leroy 在 [author-profile.md](author-profile.md) 维护的清单逐词扫描,命中即必改 +- **边界**:脚本不内置词库(会过时),靠模型按类别现场判断 + 自定义清单兜底;改写合规问题时同样受语义保真约束——把「第一」改成「领先」是降级表述,不是改事实 + +- 数字、日期、版本、事件能否在素材中找到出处 +- 描述的场景是来自真实素材还是推理补写 +- 产品名、公司名、术语拼写与官方是否一致 +- 引用的评价、讨论有无原始出处;没有的标「待作者确认」或删 +- 图位脚本中的数据、标签与素材是否一致 + +## AI 味指纹(带防误判阈值) + +AI 味的本质是**太顺、太均匀、没有取舍**——不是用词高级。每条命中标注信号强度,但**一切命中默认只是建议**,不构成必须改: + +| # | 指纹 | 典型样子 | 强度 | +|---|------|---------|------| +| 1 | 万能背景开头 | 「随着 AI 技术的发展」「在当今这个时代」 | 强 | +| 2 | 「不是 X,而是 Y」滥用 | 单次是修辞,**800 字内 ≥3 次**才算命中 | 强 | +| 3 | 面面俱到没取舍 | 利弊前提例外全讲一遍,什么都不得罪 | 强 | +| 4 | 空洞祝福收尾 | 「愿你…」「你值得…」谁都适用谁都记不住 | 强 | +| 5 | 工整排比成瘾 | 单处排比是好文笔,**几乎每节都排比**才算 | 中 | +| 6 | 反复让步两头讨好 | 「当然,这并不是说…」每个论点前都垫 | 中 | +| 7 | 命名仪式感 | 普通做法硬安「XX 法则」再郑重解释 | 中 | +| 8 | 情绪曲线平滑 | 全程一个温度,没有真实的激动、烦躁、犹豫 | 中 | +| 9 | 翻译腔 | 「值得注意的是」「在某种程度上」、超长前置定语 | 中 | +| 10 | 强行拔高 | 讲快捷键结尾升华成「生活态度」 | 中 | +| 11 | 模板化假故事 | 「我有个朋友…」但零细节——有时间地点对话尴尬的真故事不算 | 中 | +| 12 | 总分总八股 | 开头预告三点、中间整齐分点、「总而言之」收口 | 中 | +| 13 | 概念名词扎堆 | 满屏赋能闭环抓手,没有一个可复现步骤或真实数字 | 中 | +| 14 | 节奏过度规整 | 句长高度均匀,没有短促断句 | 弱 | +| 15 | 程度词通胀 | 「极其重要」「真正意义上的」堆叠撑分量 | 弱 | + +**防误判**:拿不准时宁可不报;文章主题本就关于价值观时谈意义是切题;体裁本身工整(清单文)时排比是设计。诊断目的是让稿子看清自己哪里偷懒,不是凑数找毛病。 + +## 其他审读线索 + +- **空泛铺垫与伪深度**:只有预告作用的开场、无解释力的格言、没有真实反对者的自我辩护——删掉不损失事实/判断/逻辑的就删 +- **重复与公式化节奏**:标题后复述标题、段尾重复结论、同义堆叠、无内容需要却强凑的三段式 +- **聊天与编辑过程残留**:「下面让我们看看」、上一稿改动说明、无内容问候 +- **中文句式负担**:层叠的「的」、「进行+动词」、连续被动、过长定语——以施事者和关系是否清楚为准,不为口语化牺牲术语 + +## HKR 质检 + +- **H(Happy)**:阅读理由是否准确明确——不靠夸张标题、制造焦虑或虚构悬念 +- **K(Knowledge)**:有信息量吗?读完能学到新东西吗 +- **R(Resonance)**:该有共鸣的类型(叙事/复盘)是否形成真实共鸣,而不是强行抒情 + +新闻简报以 K 为主,R 低不构成失败,不为 R 编造经历。 + +## 意见输出格式 + +每条意见包含: + +1. 具体位置或原句 +2. 可观察的问题及对阅读的影响 +3. 修改时必须保留的信息和确定程度 +4. 建议方向(不是只贴模式标签) + +最后给一行 HKR 评分(★★★☆☆ 式)与一句话总评。 + +--- + +致谢:本手册的语义保真框架与「模式不是禁词表」原则改写自 [mp-article-writor](https://github.com/balabalabalading/huuuuuuho-skills/tree/main/skills/mp-article-writor)(MIT);AI 味指纹与阈值化防误判改写自 [wechat-article-skills](https://github.com/aiworkskills/wechat-article-skills)(Apache-2.0)的 ai-flavor-check 方法论。仅借方法,未复制其表达与依赖。 diff --git a/skills/le-wechat-article/references/style-rules.md b/skills/le-wechat-article/references/style-rules.md new file mode 100644 index 0000000..84b97e6 --- /dev/null +++ b/skills/le-wechat-article/references/style-rules.md @@ -0,0 +1,61 @@ +# 公众号排版规则与产出配额 + +移动端阅读优先。本文分三部分:Markdown 硬规则(`check_article.py` 会核验)、标点与措辞规范、产出配额。 + +## 一、Markdown 硬规则(机器可查) + +公众号编辑器对粘贴内容的支持有限,这些规则直接决定粘贴后成不成形: + +1. **标题层级**:全文只允许一个 `#`(文章主标题,仅作记录用,粘贴时不进正文);正文章节一律 `###`,不用 `##`、`####`——`###` 转换后与公众号原生标题字号最接近 +2. **表格**:默认不写 Markdown 表格(移动端展示差);确需表格时不超过 3 列且每格文字极少,否则改写成列表 +3. **图片语法**:`![中文说明](相对路径)`,独占一行,前后空行。说明写清画面内容(「淘米」「小孩钓鱼」级别的概括即可),不写「示意图」「配图」这类敷衍词。**使用他人图片须标注来源与授权状态**;无授权的素材宁可不用 +4. **摘要**:front matter `digest` 80–120 字(微信上限 120),概括核心价值而非复述开头;与正文导语不得逐字相同 +5. **代码**:行内用反引号;代码块用三反引号围栏并标注语言,不用四空格缩进。不为展示贴大段代码 +6. **加粗**:`**文字**`,不连用下划线变体;斜体中文不用(中文字形不适合倾斜) +7. **占位残留**:交付时正文中不得残留 `placeholder`、TODO 标记 +8. **段落**:段与段之间空一行;一句话自成一段只在确需强调时用 + +## 二、标点与措辞规范 + +- **中英文间距**:汉字与英文字母、数字之间有且仅有一个半角空格(`用 Python 3 写了 5 个脚本`);中文标点紧邻英文时不加空格 +- **引号**:中文引用统一直角引号「」,嵌套用『』;不用弯引号""做中文引用 +- **专有名词**:按官方写法(macOS、iOS、GitHub、ChatGPT);有通用中文译名的优先中文,译名不通行时首次出现标注 `中文译名(Original Name)` +- **缩略语**:首次出现括注全称(除非广为人知) +- **省略号**:用……,不用三个句点 +- **日期**:完整句中用 `2026 年 10 月 5 日` 式汉字单位表达;提国外事件标注当地时间换算 +- **工具名精度**:素材指向具体产品时用准确名称;素材没说名称时保留「某个模型」「AI 工具」等概括表达,不猜名字装具体 +- **链接**:链接文本要能说明目标内容,不用「点击这里」「这篇」;清理短链和跟踪参数 + +## 三、产出配额(不靠自觉,脚本核验) + +这些配额的原理:公众号读者在手机上扫读,**加粗和标题是他唯一稳定看到的强调手段**。语法规则模型都会照做,但没有配额就一处都不产出——实测不写配额时金句卡 0/10 篇、正文加粗 0/5 篇。 + +- **加粗 = 划重点** + - 验收标准:全文加粗按顺序连读,是一篇能独立看懂的缩写版。读起来像词云(「效率/架构/落地」)或像把段落重念一遍,都是挑错了落点 + - 密度:每 100 字左右一处(手机约 4–5 行),连续 200 字不得没有;每个 `###` 小节至少一处;结尾段也要有 + - 每处加粗要能独立看懂:数字连着它的意思(`**省 88% Token**`),判断连着它的对象(`**打断不等于撤销**`) + - 不加粗整句空泛概括;不在每段首句同一位置加粗;一段最多两处 +- **摘要 ≠ 导语**:front matter `digest`(进公众号后台的摘要)与正文开头导语不得逐字相同——读者会同一句读两遍 +- **金句卡至多一处**:全文最有转发价值的那句写成 `> 金句。 —— 出处`;两处以上卡片退化成装饰条;不带出处就是普通引用 +- **枚举用列表**:三项以上并列写成 `- **标签**:说明`,不压进一个长段落 +- **`---` 分隔**:只在明显转场处用,独占一行 +- **图位密度**:大纲阶段按下表选一档(统一词汇,不各说各话),写完自查数量: + + | 密度档 | 就是说 | + |---|---| + | 每节一图 | 每个 `###` 小节各配一张,不多不少(未指定时的默认档) | + | 按需配图 | 只在文字讲不清的地方配(流程、对比、数据),其余不配 | + | 少图 | 全篇只配 1–2 张最关键的 | + | 多图 | 每 2–3 个自然段配一张 | + + 信息位文字逐字取自文章,节奏位不加文字,两者都不影响的图位删掉 + +## 标题 + +交付时给 3 个候选,混两种以上风格(悬念 / 干货 / 数字 / 反问 / 故事),并注明各自打法。**禁用套路**(命中即重起):浅谈、万字长文、建议收藏、震惊、不转不是中国人、UC 震惊体、过度夸张的承诺;数字型标题里的年份写当年,不照抄示例。 + +## 起始与收尾 + +- 开头按类型路由走(见 [article-types.md](article-types.md));共同底线:不用「在当今…的时代」「随着…的发展」等零信息背景铺垫——时间与趋势本身是素材事实时才保留 +- 高频连接词(首先/其次/最后/综上所述/值得注意的是/不难发现/让我们)只在承担真实顺序、转折、总结功能时保留,纯铺垫删掉 +- 结尾按文章内容自然收束;**不自动追加**关注引导、账号名、推广信息——只有 Leroy 在当次任务明确要求或作者画像明确配置时才加 diff --git a/skills/le-wechat-article/references/topic-facts.md b/skills/le-wechat-article/references/topic-facts.md new file mode 100644 index 0000000..921a9d3 --- /dev/null +++ b/skills/le-wechat-article/references/topic-facts.md @@ -0,0 +1,33 @@ +# 常用事实卡 + +写作涉及 Leroy 自己的项目、工具链或经历时必读本卡,取用其中事实可免于重新询问;本卡没写的事实仍按事实核查规则处理(问 Leroy 或标待确认)。与作者画像分工:**画像管风格,事实卡管事实**。 + +## 使用方式 + +- 写作第 2 步(校准)时与作者画像一起加载 +- 空着不影响流程;Leroy 随时口述更新,改完即时生效 +- 本卡属于个人资产,内容不复制进文章交付物(文章引用的是最终表述,不是卡片原文) + +## 项目与产品 + +(每个常写的项目一条:名称、一句话定位、关键链接、当前版本/状态) + +- (待填写,如:MetaSkill —— Leroy 的 Agent Skill 工坊项目,…) + +## 工具链与环境 + +(常在文章里出现的工具及其准确名称/版本约定,防拼写与版本张冠李戴) + +- (待填写,如:主力模型 GLM-5.3;编辑器 ZCode / Claude Code / Codex …) + +## 经历时间线 + +(常被引用的个人经历:事件 + 绝对日期,防时间漂移) + +- (待填写,如:2026-09 开始运营公众号 …) + +## 固定表述 + +(对同一事物统一说法,如产品名中英文、缩写约定) + +- (待填写) diff --git a/skills/le-wechat-article/references/visual-production.md b/skills/le-wechat-article/references/visual-production.md new file mode 100644 index 0000000..7f7e375 --- /dev/null +++ b/skills/le-wechat-article/references/visual-production.md @@ -0,0 +1,61 @@ +# 视觉生产手册 + +封面与正文解释性插图的生产方法。核心原则:**配图是内容的一部分,不是装饰**——信息位图上的文字逐字取自文章,封面上的是这篇文章的标题,不是好看的背景。 + +## 封面规格(硬约束) + +- 比例 **2.35:1**(建议 1200×509 或更大,微信信息流首图比例) +- **标题主体放中央安全区**:微信会另裁一张 1:1 的方形分享卡(默认居中裁),主体偏左或偏右会被裁掉字——同步草稿箱时脚本会提交两个裁剪框参数,但设计上就要满足「中心裁一块 1:1 不丢主信息」 +- 图上主标题 **4–7 字**最佳,字高占画面 25–35%;缩略到 345px 宽(信息流实际尺寸)主体仍一眼可辨——这是验收标准,不满足重做 + +## 封面七步推导 + +不要「想一个好看的画面」,按顺序走: + +1. **找张力**:这篇文章里什么打破了什么 / 什么变成了什么?(「手写模板 → 自动生成」「一台机器 → 十三台」)张力即画面 +2. **定关系**:对比 / 流程 / 层级 / 汇聚 / 转折——关系的类型决定构图骨架 +3. **找隐喻**:把抽象张力翻译成具体物件。**避雷**:机器人、发光大脑、齿轮堆、赛博蓝紫渐变——这些是 AI 生图的默认值,出现即平庸 +4. **选形态**(从下表挑一个,不混搭) +5. **布局**:左文右图 / 上题下景 / 中央大字,按形态模板走 +6. **写成 brief**:一段 150–300 字的散文描述(给 ckm-banner-design 或生图模型),含标题文案、字体感、颜色、位置 +7. **缩略图回看**:345px 下看主体可辨、标题完整;不过关回到对应步骤重做,不盲目重跑 + +## 封面形态速查 + +| 形态 | 适合 | 要点 | +|---|---|---| +| 纯色大字 | 观点、宣言 | 底色克制,字即主体 | +| 数字冲击 | 数据结论 | 一个大数字 + 一句限定,防断章取义 | +| 图上压字 | 叙事、体验 | 底图留白充足,压字区加暗色遮罩 | +| 对比式 | 前后变化 | 左右分栏,箭头/裂隙点题 | +| 扁平插画 | 方法、教程 | 元素 ≤3,不写实 | +| 截图标注 | 产品更新 | 真实界面 + 标注,最可信 | +| 信息表 | 评测、清单 | 封面即结论摘要 | +| 场景实拍 | 生活叙事 | 有人的真实场景优于摆拍 | + +## 正文解释性插图 + +- **信息位**(删图读者看不懂):图上文字**逐字取自文章**,不自由发挥——让模型自由发挥会生成无关鸡汤并自带 emoji +- **节奏位**(删图读者读不下去):概念隐喻或场景,图上不加文字 +- **手绘媒介**:同一篇内统一一种媒介,篇与篇之间轮换,避免整个号只有一种图。媒介池:马克笔白板 / 方格笔记本 / 便签 / 黑板粉笔 / 牛皮纸手绘 / 终端等宽 / 打印稿批注 / 扁平矢量 +- **实证图**(真实截图/照片):优先作为证据直接使用,不重绘产品界面当证据;需要标注/放大/对比时才加工 + +## 与 ckm-banner-design 的联动协议 + +需要直接生成封面图时,把 brief 交给 ckm-banner-design 执行,brief 必须包含: + +1. 比例 2.35:1 与目标像素 +2. 主标题文案(4–7 字)与短标题(1:1 分享卡用,2–4 字) +3. 视觉张力一句话 + 形态名 + 避雷项(AI 默认视觉) +4. 风格媒介(从媒介池指定一种) +5. 验收点:345px 缩略可辨、标题完整、中央 1:1 安全区无关键信息丢失 + +产出后按「缩略图回看」验收;不合格改 brief 重生,不改用其他工具绕开。 + +## SVG 自绘路径(零依赖替代) + +没有生图管线时,几何类信息图(流程、对比、曲线、分层)用 SVG 手绘完全够用,且比 AI 生图**文字更可靠**(不会出缺字错字):SVG 源文件放 `imgs/src/`,浏览器打开后按目标尺寸截屏导出 PNG。曲线/管道/卡片类图优先走这条路;只有需要插画质感时才必须用生图。封面同样可 SVG 自绘(文字型封面 = 纯色大字形态,正是最稳的封面形态之一)。 + +--- + +致谢:封面七步推导、形态库与媒介轮换方法改写自 [wechat-article-skills](https://github.com/aiworkskills/wechat-article-skills)(Apache-2.0)的 cover-method / image-method;组合封面安全区意识来自 [mp-article-writor](https://github.com/balabalabalading/huuuuuuho-skills/tree/main/skills/mp-article-writor)(MIT)的视觉路由。仅借方法。 diff --git a/skills/le-wechat-article/reports/trigger-eval.json b/skills/le-wechat-article/reports/trigger-eval.json new file mode 100644 index 0000000..2302f49 --- /dev/null +++ b/skills/le-wechat-article/reports/trigger-eval.json @@ -0,0 +1,285 @@ +{ + "skill": "le-wechat-article", + "date": "2026-10-05", + "threshold": 0.3, + "prediction_mode": "coverage_fallback", + "note": "词面冒烟测试;行为结论需真实对话实测(live_turns 字段手工补记)", + "cases": [ + { + "text": "帮我写篇公众号文章,讲讲最近用 Agent Skill 的体会", + "family": "explicit_new_article", + "bucket": "should_trigger", + "coverage": 0.4, + "coverage_hits": [ + "众号", + "公众", + "写篇", + "号文", + "文章", + "篇公" + ], + "families_hit": [], + "negative_hit": false, + "predicted_trigger": true, + "expected_trigger": true, + "verdict": "pass" + }, + { + "text": "把这份调研笔记整理成一篇推文", + "family": "material_to_article", + "bucket": "should_trigger", + "coverage": 0.615, + "coverage_hits": [ + "一篇", + "把这", + "推文", + "整理", + "理成", + "笔记", + "调研", + "这份" + ], + "families_hit": [], + "negative_hit": false, + "predicted_trigger": true, + "expected_trigger": true, + "verdict": "pass" + }, + { + "text": "发公众号:ZGLM-5.3 发布了,主要更新这三点", + "family": "release_brief", + "bucket": "should_trigger", + "coverage": 0.333, + "coverage_hits": [ + "众号", + "公众", + "发公", + "发布" + ], + "families_hit": [], + "negative_hit": false, + "predicted_trigger": true, + "expected_trigger": true, + "verdict": "pass" + }, + { + "text": "我这里有一段上次做自动化测试的复盘,写成公众号文章吧", + "family": "retro_article", + "bucket": "should_trigger", + "coverage": 0.348, + "coverage_hits": [ + "一段", + "众号", + "公众", + "写成", + "号文", + "复盘", + "成公", + "文章" + ], + "families_hit": [], + "negative_hit": false, + "predicted_trigger": true, + "expected_trigger": true, + "verdict": "pass" + }, + { + "text": "这篇旧稿帮我改成公众号的版本,去一下 AI 味", + "family": "rewrite_old_draft", + "bucket": "should_trigger", + "coverage": 0.4, + "coverage_hits": [ + "众号", + "公众", + "成公", + "旧稿", + "篇旧", + "这篇" + ], + "families_hit": [], + "negative_hit": false, + "predicted_trigger": true, + "expected_trigger": true, + "verdict": "pass" + }, + { + "text": "润色下这篇公众号稿子,看看能不能发", + "family": "polish_check", + "bucket": "should_trigger", + "coverage": 0.571, + "coverage_hits": [ + "下这", + "众号", + "公众", + "号稿", + "润色", + "篇公", + "色下", + "这篇" + ], + "families_hit": [], + "negative_hit": false, + "predicted_trigger": true, + "expected_trigger": true, + "verdict": "pass" + }, + { + "text": "素材在这个目录里,整理成公众号文章,输出到 articles 下面", + "family": "dir_material", + "bucket": "should_trigger", + "coverage": 0.471, + "coverage_hits": [ + "众号", + "公众", + "号文", + "成公", + "整理", + "文章", + "理成", + "素材" + ], + "families_hit": [], + "negative_hit": false, + "predicted_trigger": true, + "expected_trigger": true, + "verdict": "pass" + }, + { + "text": "写完把这篇同步到公众号草稿箱", + "family": "sync_draft", + "bucket": "should_trigger", + "coverage": 0.769, + "coverage_hits": [ + "众号", + "公众", + "到公", + "号草", + "同步", + "把这", + "步到", + "稿箱", + "草稿", + "这篇" + ], + "families_hit": [], + "negative_hit": false, + "predicted_trigger": true, + "expected_trigger": true, + "verdict": "pass" + }, + { + "text": "帮我把这篇稿子群发给所有粉丝", + "family": "mass_send", + "bucket": "should_not_trigger", + "coverage": 0.25, + "coverage_hits": [ + "把这", + "群发", + "这篇" + ], + "families_hit": [], + "negative_hit": true, + "predicted_trigger": false, + "expected_trigger": false, + "verdict": "pass" + }, + { + "text": "把这篇小红书笔记改成知乎回答", + "family": "other_platform", + "bucket": "should_not_trigger", + "coverage": 0.462, + "coverage_hits": [ + "小红", + "把这", + "知乎", + "笔记", + "红书", + "这篇" + ], + "families_hit": [], + "negative_hit": true, + "predicted_trigger": false, + "expected_trigger": false, + "verdict": "pass" + }, + { + "text": "帮项目写一份 README 文档", + "family": "tech_doc", + "bucket": "should_not_trigger", + "coverage": 0.143, + "coverage_hits": [ + "文档" + ], + "families_hit": [], + "negative_hit": true, + "predicted_trigger": false, + "expected_trigger": false, + "verdict": "pass" + }, + { + "text": "把这周的代码变更总结成周报", + "family": "weekly_report", + "bucket": "should_not_trigger", + "coverage": 0.417, + "coverage_hits": [ + "代码", + "变更", + "周报", + "把这", + "码变" + ], + "families_hit": [], + "negative_hit": true, + "predicted_trigger": false, + "expected_trigger": false, + "verdict": "pass" + }, + { + "text": "给这个函数写个注释,解释一下逻辑", + "family": "code_comment", + "bucket": "should_not_trigger", + "coverage": 0.0, + "coverage_hits": [], + "families_hit": [], + "negative_hit": false, + "predicted_trigger": false, + "expected_trigger": false, + "verdict": "pass" + }, + { + "text": "帮我找几个公众号选题", + "family": "topic_only", + "bucket": "near_neighbor", + "coverage": 0.25, + "coverage_hits": [ + "众号", + "公众" + ], + "families_hit": [], + "negative_hit": false, + "predicted_trigger": false, + "expected_trigger": null, + "verdict": "boundary" + }, + { + "text": "给这篇文章配几张图", + "family": "image_only", + "bucket": "near_neighbor", + "coverage": 0.25, + "coverage_hits": [ + "文章", + "这篇" + ], + "families_hit": [], + "negative_hit": false, + "predicted_trigger": false, + "expected_trigger": null, + "verdict": "boundary" + } + ], + "summary": { + "scored": 13, + "mismatch": 0, + "pass_rate": 1.0 + } +} diff --git a/skills/le-wechat-article/scripts/check_article.py b/skills/le-wechat-article/scripts/check_article.py new file mode 100644 index 0000000..0c1f6b2 --- /dev/null +++ b/skills/le-wechat-article/scripts/check_article.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""公众号文章自检:机器能量化的硬规则与产出配额,不靠眼睛。 + +用法:python3 check_article.py [--type <类型>] [--strict] +退出码:有 ERROR → 1;仅 WARN/INFO → 0(--strict 时 WARN 也算 1)。 +脚本量不出的部分(加粗挑得对不对、套话是否承担功能)仍需人工判断。 +""" +import argparse +import re +import sys + +TYPE_RANGES = { + "简报": (1500, 3000), "测评": (2500, 5000), "复盘": (2500, 6000), + "技术": (2500, 6000), "叙事": (2000, 5000), +} +FILLER_PATTERNS = ["在当今", "随着{}的发展", "综上所述", "总而言之", "值得注意的是", + "不难发现", "让我们来看看", "下面让我们", "众所周知"] +HAN = r"\u4e00-\u9fff" + + +def strip_inline_code(text): + return re.sub(r"`[^`]*`", "", text) + + +def split_blocks(md): + """按行分类,标记代码围栏内外的行。""" + lines = md.split("\n") + fenced, blocks = False, [] + for ln in lines: + if ln.lstrip().startswith("```"): + fenced = not fenced + continue + blocks.append((ln, fenced)) + return blocks + + +def check(path, art_type, strict, allow_placeholder=False): + errors, warns, infos = [], [], [] + md = open(path, encoding="utf-8").read() + blocks = split_blocks(md) + + # ---- 标题层级 ---- + h1 = [ln for ln, f in blocks if not f and re.match(r"^# ", ln)] + bad_h = [ln for ln, f in blocks if not f and re.match(r"^(##|####+) ", ln)] + if len(h1) != 1: + errors.append(f"标题层级:全文应有且仅有一个 `# ` 主标题,当前 {len(h1)} 个") + if bad_h: + errors.append(f"标题层级:正文只允许 `###`,发现 {len(bad_h)} 处其他层级(如 {bad_h[0][:30]}…)") + + # ---- 占位残留 ---- + for ln, f in blocks: + if f: + continue + if re.search(r"placeholder|TODO[::]?", ln): + if allow_placeholder and re.match(r"^\s*!\[.*\]\(placeholder", ln): + continue # 草稿期图位占位合法:alt 里是信息位文字规划 + errors.append(f"占位残留:{ln.strip()[:50]}") + + # ---- 表格(按连续行分组,每张表报一次) ---- + groups, cur = [], [] + for ln, f in blocks: + if not f and ln.lstrip().startswith("|"): + cur.append(ln) + elif cur: + groups.append(cur) + cur = [] + if cur: + groups.append(cur) + for g in groups: + rows = [r for r in g if not re.match(r"^\s*\|[\s:|-]+\|\s*$", r)] + if not rows: + continue + cols = max(r.count("|") - 1 for r in rows) + if cols > 3: + errors.append(f"表格超过 3 列({cols} 列),移动端不可读,请改写为列表") + elif max(len(c) for c in rows[0].split("|")[1:-1]) > 12: + warns.append(f"表格单元格文字过长:{rows[0].strip()[:40]}…") + + # ---- front matter digest 与导语重复 ---- + fm = re.match(r"^---\n(.*?)\n---\n", md, re.S) + digest = "" + if fm: + m = re.search(r"^digest:\s*(.+)$", fm.group(1), re.M) + if m: + digest = m.group(1).strip().strip("\"'「」") + body = md[fm.end():] if fm else md + first_quote = "" + for ln, f in split_blocks(body): + if f or not ln.strip() or ln.startswith("# "): + continue # 跳过空行与主标题,找正文首个引用 + if ln.startswith(">"): + first_quote = ln.lstrip("> ").strip() + break + if digest and first_quote and digest[:20] == first_quote[:20]: + warns.append(f"摘要 ≠ 导语:digest 与正文首个引用开头 20 字相同,读者会同一句读两遍") + + # ---- 加粗配额 ---- + plain_texts, bolds = [], [] # plain_texts: (去加粗去代码文本, 本行是否有加粗) + section_has_bold, cur_section_bold, section_names, cur_section = [], False, [], None + for ln, f in blocks: + if f or ln.lstrip().startswith("|"): + continue + if re.match(r"^### ", ln): + if cur_section is not None: + section_names.append(cur_section) + section_has_bold.append(cur_section_bold) + cur_section, cur_section_bold = ln, False + plain_texts.append(("\n", True)) # 小节标题本身是落点,重置累计 + continue + line_bolds = re.findall(r"\*\*([^*]+)\*\*", ln) + bolds.extend(line_bolds) + if line_bolds: + cur_section_bold = True + plain = strip_inline_code(re.sub(r"\*\*[^*]+\*\*", "", ln)) + plain_texts.append((plain + "\n", bool(line_bolds))) + if cur_section is not None: + section_names.append(cur_section) + section_has_bold.append(cur_section_bold) + + for name, has in zip(section_names, section_has_bold): + if not has: + warns.append(f"加粗配额:小节「{name.lstrip('# ')}」全节无加粗落点") + + # 连续无加粗字符数(跨段累计;含加粗/小节标题/金句卡的行重置计数) + gap_runs, run = [], 0 + for text, has_bold in plain_texts: + if has_bold or re.match(r"^>\s*.{4,}\s*——", text.strip()): + run = 0 + continue + run += len(re.sub(r"\s+", "", text)) + if run > 200: + gap_runs.append(run) + run = 0 + if gap_runs: + warns.append(f"加粗配额:存在 {len(gap_runs)} 处连续 >200 字无加粗(最长 {max(gap_runs)} 字),读者划过整屏找不到重点") + + bold_chain = " / ".join(bolds) + if not bolds: + errors.append("加粗配额:全文无加粗——读者只看标题和加粗应能理解全篇,当前做不到") + + # ---- 金句卡 ---- + golden = [ln for ln, f in blocks if not f and re.match(r"^> .{4,} ——", ln)] + if len(golden) > 1: + warns.append(f"金句卡:`> 金句 —— 出处` 出现 {len(golden)} 处,至多 1 处,多了退化成装饰条") + + # ---- 中英文间距 / 引号 / 省略号(排除代码、链接、图片) ---- + scan_lines = [] + for ln, f in blocks: + if f or ln.lstrip().startswith("|"): + continue + clean = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", ln) # 图片 + clean = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", clean) # 链接留文字 + clean = strip_inline_code(clean) + scan_lines.append(clean) + scan = "\n".join(scan_lines) + spacing_hits = re.findall(f"[{HAN}][A-Za-z0-9]|[A-Za-z0-9][{HAN}]", scan) + if spacing_hits: + warns.append(f"中英文间距:{len(spacing_hits)} 处汉字与英数字之间缺一个半角空格,如:「{spacing_hits[0]}」") + curly = re.findall(r"[\u201c\u201d\u2018\u2019]", scan) + if curly: + warns.append(f"引号:发现 {len(curly)} 个中文弯引号,请统一改用直角引号「」『』") + ascii_q = re.findall(f'"[^"]*[{HAN}][^"]*"', scan) + if ascii_q: + warns.append(f"引号:{len(ascii_q)} 处用 ASCII 直引号包裹中文(如「{ascii_q[0]}」),请改用直角引号「」") + if re.search(r"\.\.\.|。{3,}", scan): + infos.append("省略号:请使用……(两个 U+2026),不要用三个句点") + + # ---- 高频套话 ---- + for pat in FILLER_PATTERNS: + p = pat.format("AI|科技|技术") if "{}" in pat else pat + hits = re.findall(p, scan) + if hits: + warns.append(f"套话检查:「{pat}」出现 {len(hits)} 次——保留与否取决于是否承担真实功能,请人工判断") + + # ---- 字数 ---- + text = re.sub(r"[#>*`|\-\[\]()!\s]", "", scan) + n = len(text) + if art_type in TYPE_RANGES: + lo, hi = TYPE_RANGES[art_type] + if n < lo: + infos.append(f"字数:约 {n} 字,低于「{art_type}」参考下限 {lo}——素材不足时允许,勿注水") + elif n > hi * 1.3: + warns.append(f"字数:约 {n} 字,明显超出「{art_type}」参考上限 {hi},检查是否有注水") + else: + infos.append(f"字数:约 {n} 字({art_type} 参考 {lo}–{hi})") + else: + infos.append(f"字数:约 {n} 字(未指定类型,无参考区间)") + + # ---- 输出 ---- + print("=" * 56) + print(f"公众号自检 · {path}") + print("=" * 56) + for tag, items in (("❌ ERROR", errors), ("⚠️ WARN", warns), ("ℹ️ INFO", infos)): + for it in items: + print(f"{tag} {it}") + if bold_chain: + print("-" * 56) + print("加粗串读提要(连读应是一篇能独立看懂的缩写版):") + print(" " + (bold_chain[:600] + ("…" if len(bold_chain) > 600 else ""))) + print("-" * 56) + total = len(errors) + len(warns) + print(f"结果:{len(errors)} 错误 / {len(warns)} 提醒 / {len(infos)} 信息") + if errors: + return 1 + if strict and warns: + print("--strict 模式:WARN 视为不通过") + return 1 + return 0 + + +def main(): + ap = argparse.ArgumentParser(description="公众号文章硬规则与产出配额自检") + ap.add_argument("article") + ap.add_argument("--type", choices=list(TYPE_RANGES), help="文章类型(提供字数参考区间)") + ap.add_argument("--strict", action="store_true", help="WARN 也视为不通过") + ap.add_argument("--allow-placeholder", action="store_true", + help="草稿模式:图位 ![…](placeholder) 不算错误(交付版必须去掉此开关)") + args = ap.parse_args() + sys.exit(check(args.article, args.type, args.strict, args.allow_placeholder)) + + +if __name__ == "__main__": + main() diff --git a/skills/le-wechat-article/scripts/md2wechat.py b/skills/le-wechat-article/scripts/md2wechat.py new file mode 100644 index 0000000..56e7c56 --- /dev/null +++ b/skills/le-wechat-article/scripts/md2wechat.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Markdown → 微信公众号可粘贴 HTML(全内联样式,零依赖)。 + +排版 = 骨架(结构装饰)× 色板(主题色)两层: + 骨架 工程 色条标题 + 主题色加粗 + 灰底金句卡——教程、技术、工程实践(默认) + 杂志 深色大标题 + 深灰粗体 + 装饰线金句卡——叙事、访谈、生活方式 + 简报 紧凑节奏 + 主题色小标题——更新简报、快讯、清单 + 色板 靛蓝 / 松绿 / 黛紫 / 绛红(--color 可自定义 #RRGGBB) + +用法: + python3 md2wechat.py article.md -o article.html # 工程骨架 + 靛蓝 + python3 md2wechat.py article.md --skeleton 杂志 --palette 松绿 # 指定骨架与色板 + python3 md2wechat.py article.md --list-themes # 看全部组合与适用场景 + python3 md2wechat.py article.md --preview # 三骨架对照页(375px 栏宽) +输出用浏览器打开,全选复制 → 粘贴进公众号编辑器。 +支持的语法子集:#(跳过)/ ### / 段落 / **加粗** / `行内代码` / [链接] / ![图片](src "题注") / +> 引用(含「—— 出处」识别为金句卡)/ - 与 1. 列表 / --- 分隔 / ```代码块``` / ≤3 列表格。 +""" +import argparse +import html +import re +import sys + +PALETTES = {"靛蓝": "#1A5CFF", "松绿": "#0F7B6C", "黛紫": "#6B4FBB", "绛红": "#B03A2E"} +SKELETONS = { + "工程": {"alias": "eng", "fit": "教程、技术解析、工程实践"}, + "杂志": {"alias": "mag", "fit": "叙事、访谈、生活方式、品牌"}, + "简报": {"alias": "brief", "fit": "更新简报、快讯、清单文"}, +} + + +def esc(t): + return html.escape(t, quote=False) + + +def darken(hexc, f=0.72): + hexc = hexc.lstrip("#") + r, g, b = (int(hexc[i:i + 2], 16) for i in (0, 2, 4)) + m = lambda v: max(0, int(v * f)) + return f"#{m(r):02x}{m(g):02x}{m(b):02x}" + + +def styles(sk, color, px): + """骨架差异全部参数化在这里:返回各元素的内联样式串。""" + n = int(px[:-2]) + dark = darken(color) + if sk == "杂志": + r, g, b = color.lstrip("#")[0:2], color.lstrip("#")[2:4], color.lstrip("#")[4:6] + hl = f"rgba({int(r,16)},{int(g,16)},{int(b,16)},0.12)" + return { + "p": f"font-size:{px};line-height:1.8;color:#333;letter-spacing:.5px;margin:0 0 22px;", + "strong": f"color:#111;font-weight:700;background:{hl};", + "link": f"color:{dark};text-decoration:none;border-bottom:1px solid {hl};", + "h3": (f"margin:34px 0 16px;font-size:{n + 1}px;font-weight:700;color:#111;" + f"border-bottom:2px solid #e8e8e8;padding-bottom:10px;line-height:1.5;"), + "quote": (f"margin:26px 0;padding:4px 16px;border-left:2px solid #ccc;" + f"font-size:{n - 1}px;line-height:1.8;color:#888;"), + "golden": (f"margin:30px 0;padding:20px 10px;text-align:center;" + f"border-top:1px solid {color};border-bottom:1px solid {color};" + f"font-size:{n + 1}px;line-height:1.9;color:{dark};font-weight:500;"), + "li": "margin:0 0 12px;line-height:1.8;", "li_ul": f"padding-left:1.4em;margin:0 0 22px;font-size:{px};line-height:1.8;color:#333;", + } + if sk == "简报": + return { + "p": f"font-size:{px};line-height:1.7;color:#3a3a3a;margin:0 0 16px;", + "strong": f"color:{color};font-weight:600;", + "link": f"color:{color};text-decoration:none;", + "h3": (f"margin:26px 0 12px;font-size:{n + 1}px;font-weight:600;color:{dark};" + f"line-height:1.5;"), + "quote": (f"margin:20px 0;padding:2px 14px;border-left:2px solid {color};" + f"font-size:{n - 1}px;line-height:1.7;color:#777;"), + "golden": (f"margin:24px 0;padding:12px 8px;text-align:center;" + f"font-size:{n + 1}px;line-height:1.8;color:{dark};font-weight:500;" + f"letter-spacing:1px;"), + "li": "margin:0 0 8px;line-height:1.7;", "li_ul": f"padding-left:1.4em;margin:0 0 16px;font-size:{px};line-height:1.7;color:#3a3a3a;", + } + # 工程(默认) + return { + "p": f"font-size:{px};line-height:1.75;color:#3f3f3f;letter-spacing:.4px;margin:0 0 20px;", + "strong": f"color:{color};font-weight:600;", + "link": f"color:{color};text-decoration:none;", + "h3": (f"margin:30px 0 16px;font-size:{n + 1}px;font-weight:600;color:{dark};" + f"border-left:4px solid {color};padding-left:10px;line-height:1.5;"), + "quote": (f"margin:24px 0;padding:12px 16px;border-left:3px solid #ddd;background:#f7f8fa;" + f"font-size:{n - 1}px;line-height:1.8;color:#888;"), + "golden": (f"margin:28px 0;padding:20px 24px;background:#f7f8fa;text-align:center;" + f"font-size:{n + 1}px;line-height:1.9;color:{dark};font-weight:500;"), + "li": "margin:0 0 10px;line-height:1.75;", "li_ul": f"padding-left:1.4em;margin:0 0 20px;font-size:{px};line-height:1.75;color:#3f3f3f;", + } + + +def inline(text, st): + """行内元素转换:先保护行内代码,再处理加粗/链接/图片。""" + codes = [] + + def stash(m): + codes.append(m.group(1)) + return f"\x00{len(codes) - 1}\x00" + + t = re.sub(r"`([^`]+)`", stash, text) + t = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"([^\"]*)\")?\)", + lambda m: (f'{esc(m.group(3))}' if m.group(3) else "")), + t) + t = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", + rf'\1', t) + t = re.sub(r"\*\*([^*]+)\*\*", rf'\1', t) + t = re.sub(r"\x00(\d+)\x00", + lambda m: f'' + f'{esc(codes[int(m.group(1))])}', t) + return t + + +def convert(md, sk, color, px): + st = styles(sk, color, px) + dark = darken(color) + lines = md.split("\n") + out, i, n = [], 0, len(lines) + in_front = lines and lines[0].strip() == "---" + front_done = not in_front + + def flush_list(items, ordered): + tag = "ol" if ordered else "ul" + bullet = re.compile(r"^\s*(?:\d+\.|-)\s+") + lis = "".join( + f'
  • ' + inline(bullet.sub("", it), st) + "
  • " + for it in items) + out.append(f'<{tag} style="{st["li_ul"]}">{lis}') + + while i < n: + ln = lines[i] + if not front_done: + if i > 0 and ln.strip() == "---": + front_done = True + i += 1 + continue + s = ln.strip() + if not s: + i += 1 + continue + if s.startswith("```"): + i += 1 + buf = [] + while i < n and not lines[i].strip().startswith("```"): + buf.append(lines[i]) + i += 1 + i += 1 + code = esc("\n".join(buf)) + out.append(f'
    {code}
    ') + continue + if s.startswith("# ") and not s.startswith("##"): + i += 1 # 主标题不进正文(填在公众号后台) + continue + if s.startswith("###") and not s.startswith("####"): + out.append(f'

    {inline(s.lstrip("#").strip(), st)}

    ') + i += 1 + continue + if re.match(r"^####+\s", s): + out.append(f'

    {inline(s.lstrip("#").strip(), st)}

    ') + i += 1 + continue + if re.match(r"^-{3,}$", s): + out.append('
    ') + i += 1 + continue + if s.startswith(">"): + buf = [] + while i < n and lines[i].strip().startswith(">"): + buf.append(lines[i].strip().lstrip(">").strip()) + i += 1 + text = "
    ".join(inline(b, st) for b in buf if b) + key = "golden" if "——" in text else "quote" + out.append(f'
    {text}
    ') + continue + if s.startswith("|"): + tbl = [] + while i < n and lines[i].strip().startswith("|"): + tbl.append(lines[i].strip()) + i += 1 + rows = [r for r in tbl if not re.match(r"^\|[\s:|-]+\|$", r)] + if rows: + cols = max(r.count("|") - 1 for r in rows) + if cols > 3: + print(f"⚠️ 表格超过 3 列({cols} 列),已跳过渲染——请先改写为列表", file=sys.stderr) + else: + trs = [] + for ri, r in enumerate(rows): + cells = [c.strip() for c in r.strip("|").split("|")] + head = (f"background:#f7f8fa;font-weight:600;color:{dark};" if ri == 0 else "") + tds = "".join( + f'{inline(c, st)}' + for c in cells) + trs.append(f"{tds}") + out.append('' + + "".join(trs) + "
    ") + continue + if re.match(r"^\s*-\s+", ln): + items = [] + while i < n and re.match(r"^\s*-\s+", lines[i]): + items.append(lines[i]) + i += 1 + flush_list(items, ordered=False) + continue + if re.match(r"^\s*\d+\.\s+", ln): + items = [] + while i < n and re.match(r"^\s*\d+\.\s+", lines[i]): + items.append(lines[i]) + i += 1 + flush_list(items, ordered=True) + continue + out.append(f'

    {inline(s, st)}

    ') + i += 1 + + return (f'
    ' + "".join(out) + "
    ") + + +SAMPLE = """### 小节标题示例 + +正文段落:这里是**主题加粗落点**,后面是[链接示例](https://example.com)与行内代码 `npm install`。 + +- **标签一**:说明文字 +- **标签二**:说明文字 + +> 值得截图转发的一句金句。 —— 出处 + +> 普通引用:补充说明的引文。 + +![图片示例](data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='120'%3E%3Crect width='100%25' height='100%25' fill='%23eee'/%3E%3C/svg%3E "题注") + +--- + +结尾段落,含**收束落点**。 +""" + + +def list_themes(): + print("骨架 × 色板(--skeleton 骨架 --palette 色板,或 --color '#RRGGBB' 自定义)\n") + for sk, meta in SKELETONS.items(): + print(f" {sk}(--skeleton {sk} 或 {meta['alias']})—— {meta['fit']}") + print(f" 色板:{' / '.join(f'{k} {v}' for k, v in PALETTES.items())}\n") + + +def resolve_skeleton(name): + if not name: + return "工程" + for sk, meta in SKELETONS.items(): + if name in (sk, meta["alias"]): + return sk + sys.exit(f"未知骨架 {name},可选:{' / '.join(SKELETONS)}(或其英文别名)") + + +def main(): + ap = argparse.ArgumentParser(description="Markdown → 微信公众号 HTML(骨架×色板,全内联样式)") + ap.add_argument("article", nargs="?") + ap.add_argument("-o", "--output") + ap.add_argument("--skeleton", help="骨架:工程 / 杂志 / 简报(默认 工程)") + ap.add_argument("--palette", choices=list(PALETTES)) + ap.add_argument("--theme", choices=list(PALETTES), help="兼容旧参数,等同 --palette") + ap.add_argument("--color", help="自定义主题色 #RRGGBB,覆盖色板") + ap.add_argument("--font-size", default="16px") + ap.add_argument("--list-themes", action="store_true", help="列出全部骨架与色板") + ap.add_argument("--preview", action="store_true", help="生成三骨架对照页(用内置样文)") + args = ap.parse_args() + + if args.list_themes: + list_themes() + return + sk = resolve_skeleton(args.skeleton) + color = args.color or PALETTES.get(args.palette or args.theme or "靛蓝") + if not re.match(r"^#[0-9A-Fa-f]{6}$", color): + sys.exit(f"颜色格式应为 #RRGGBB,收到 {color}") + + if args.preview: + blocks = [] + for sk_name in SKELETONS: + doc = convert(SAMPLE, sk_name, color, args.font_size) + blocks.append(f'

    骨架:{sk_name} · 色板 {color}

    ' + f'
    {doc}
    ') + page = ("主题预览" + f"{''.join(blocks)}") + out = args.output or "theme-preview.html" + open(out, "w", encoding="utf-8").write(page) + print(f"✔ {out}(骨架对照页,375px 栏宽 ≈ 手机宽度)——浏览器打开选一个顺眼的") + return + + if not args.article: + sys.exit("请提供 markdown 文件路径(或用 --list-themes / --preview)") + md = open(args.article, encoding="utf-8").read() + doc = convert(md, sk, color, args.font_size) + full = ("" + "公众号文章" + f"{doc}") + out = args.output or re.sub(r"\.md$", ".html", args.article) + open(out, "w", encoding="utf-8").write(full) + print(f"✔ {out}(骨架 {sk} · 主题色 {color})——浏览器打开后全选复制,粘贴进公众号编辑器") + + +if __name__ == "__main__": + main() diff --git a/skills/le-wechat-article/scripts/publish_draft.py b/skills/le-wechat-article/scripts/publish_draft.py new file mode 100644 index 0000000..f8af54a --- /dev/null +++ b/skills/le-wechat-article/scripts/publish_draft.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""把成稿同步到微信公众号草稿箱(只写草稿,绝不群发)。 + +前置条件: +1. 已认证的公众号(未认证账号调用草稿接口会返回 48001)。 +2. 环境变量 WECHAT_APP_ID、WECHAT_APP_SECRET(在 mp.weixin.qq.com 设置与开发→基本配置获取)。 +3. 调用方公网 IP 已加入公众号后台的 IP 白名单(家用宽带 IP 变化后需回后台更新)。 + +用法: + python3 publish_draft.py <文章目录> [--title 标题] [--digest 摘要] [--cover 封面图] \ + [--check] [--dry-run] [--author 作者] + --check 只验证凭证与 IP 白名单(调 token 接口),不写任何内容 + --dry-run 不联网,打印将上传的图片与将提交的草稿参数 +退出码:0 成功;1 失败(错误码已翻译成人话);2 凭证缺失或参数不合法。 +""" +import argparse +import json +import os +import re +import struct +import sys +import urllib.parse +import urllib.request + +API = "https://api.weixin.qq.com" +ERR = { + 40001: "AppSecret 错误或 access_token 已过期——核对 WECHAT_APP_SECRET,或稍后重试", + 40013: "AppID 无效——核对 WECHAT_APP_ID", + 40164: "本机公网 IP 不在白名单——把提示里的 IP 加到公众号后台「基本配置→IP白名单」后重试", + 40125: "AppSecret 无效或已重置——到后台重置 Secret 后更新环境变量", + 45009: "接口当日调用额度用完——明天再试", + 48001: "该账号无此 API 权限——草稿箱接口需要已认证公众号;未认证个人订阅号用不了", + 40113: "图片格式被素材接口拒绝(部分 PNG 编码会被挑剔)——转成 jpg 再试:" + "sips -s format jpeg cover.png --out cover.jpg(macOS 自带)," + "然后用 --cover cover.jpg 重跑", + 53401: "封面图校验失败——换一张(建议 900×383 以上的 2.35:1 图)", + 53402: "封面 media_id 无效或不是图片素材", +} + + +def die(msg, code=1): + print(f"❌ {msg}", file=sys.stderr) + sys.exit(code) + + +def wx_err(r): + """把微信返回体翻译成人话;成功返回 None。""" + code = r.get("errcode", 0) + if not code: + return None + hint = ERR.get(code, r.get("errmsg", "未知错误")) + return f"errcode {code}:{hint}({r.get('errmsg', '')})" + + +def http_json(url, data=None, timeout=30): + """GET(data=None)或 JSON POST,返回解析后的 dict。""" + req = urllib.request.Request(url, method="POST" if data is not None else "GET") + body = None + if data is not None: + body = json.dumps(data, ensure_ascii=False).encode("utf-8") + req.add_header("Content-Type", "application/json; charset=utf-8") + with urllib.request.urlopen(req, body, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def multipart(field, filename, content): + b = "----LeWeChatBoundary7MA4YWxk" + parts = [ + f"--{b}\r\nContent-Disposition: form-data; name=\"{field}\"; " + f"filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\n" + ] + body = parts[0].encode("utf-8") + content + f"\r\n--{b}--\r\n".encode("utf-8") + return body, b + + +def post_file(url, path): + with open(path, "rb") as f: + content = f.read() + body, boundary = multipart("media", os.path.basename(path), content) + req = urllib.request.Request(url, method="POST", data=body) + req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def img_size(path): + """读图片像素尺寸:PNG(IHDR)/ JPEG(SOF 帧),标准库解析。""" + with open(path, "rb") as f: + head = f.read(32) + f.seek(0) + data = f.read() + if head.startswith(b"\x89PNG"): + w, h = struct.unpack(">II", data[16:24]) + return w, h + if head.startswith(b"\xff\xd8"): # JPEG:找第一帧 SOF0/2 + i = 2 + while i < len(data) - 9: + if data[i] != 0xFF: + i += 1 + continue + marker = data[i + 1] + if marker in (0xC0, 0xC2): + h, w = struct.unpack(">HH", data[i + 5:i + 9]) + return w, h + if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7: + i += 2 + continue + seg_len = struct.unpack(">H", data[i + 2:i + 4])[0] + i += 2 + seg_len + raise ValueError(f"无法识别图片尺寸(支持 PNG/JPEG):{path}") + + +def crop_fractions(w, h, tw, th): + """最大居中裁剪框 → 微信 pic_crop 格式 x1_y1_x2_y2(0–1 百分比)。""" + if w / h >= tw / th: + ch, cw = h, h * tw / th + else: + cw, ch = w, w * th / tw + x1, y1 = (w - cw) / 2 / w, (h - ch) / 2 / h + x2, y2 = x1 + cw / w, y1 + ch / h + return f"{x1:.4f}_{y1:.4f}_{x2:.4f}_{y2:.4f}" + + +def get_token(app_id, secret): + q = urllib.parse.urlencode({"grant_type": "client_credential", + "appid": app_id, "secret": secret}) + try: + r = http_json(f"{API}/cgi-bin/token?{q}") + except urllib.error.URLError as e: + die(f"网络请求失败:{e}") + err = wx_err(r) + if err: + die(f"获取 access_token 失败——{err}") + return r["access_token"] + + +def main(): + ap = argparse.ArgumentParser(description="同步成稿到公众号草稿箱(不群发)") + ap.add_argument("dir", help="文章目录(含 article.html)") + ap.add_argument("--title", required=not any(a in sys.argv for a in ("--check", "--dry-run"))) + ap.add_argument("--digest", default="") + ap.add_argument("--author", default="") + ap.add_argument("--cover", help="封面图路径(默认在文章目录找 cover.png/jpg/jpeg/webp)") + ap.add_argument("--check", action="store_true", help="只验证凭证与 IP 白名单") + ap.add_argument("--no-crop", action="store_true", help="不提交封面裁剪框参数") + ap.add_argument("--dry-run", action="store_true", help="不联网,打印执行计划") + args = ap.parse_args() + + app_id = os.environ.get("WECHAT_APP_ID", "") + secret = os.environ.get("WECHAT_APP_SECRET", "") + + # --check 只验凭证与 IP,不需要文章产物 + if args.check: + if not (app_id and secret): + print("❌ 缺少环境变量 WECHAT_APP_ID / WECHAT_APP_SECRET\n" + " 获取路径:mp.weixin.qq.com → 设置与开发 → 基本配置\n" + " 设置方式:export WECHAT_APP_ID=xxx WECHAT_APP_SECRET=yyy(建议写进 ~/.zshrc)\n" + " 别把密钥写进任何 git 仓库。", file=sys.stderr) + sys.exit(2) + get_token(app_id, secret) # 失败时内部已 die 并翻译错误码 + print("✔ 凭证与 IP 白名单验证通过,可以同步草稿箱") + return + + # ---- 收集产物(dry-run 也走这段,但不联网) ---- + html_path = os.path.join(args.dir, "article.html") + if not os.path.isfile(html_path): + die(f"找不到 {html_path}——先跑 md2wechat.py 生成 HTML", 2) + html_text = open(html_path, encoding="utf-8").read() + + if args.title and len(args.title) > 64: + die(f"标题 {len(args.title)} 字,微信上限 64 字", 2) + if len(args.digest) > 120: + die(f"摘要 {len(args.digest)} 字,微信上限 120 字", 2) + + cover = args.cover or next( + (os.path.join(args.dir, f"cover{ext}") + for ext in (".jpg", ".jpeg", ".png", ".webp") + if os.path.isfile(os.path.join(args.dir, f"cover{ext}"))), None) + + # 正文里的本地图片 → 逐个经 uploadimg 换成微信域名 URL + local_imgs = re.findall(r'src="([^"]+)"', html_text) + local_imgs = [s for s in local_imgs if not s.startswith(("http://", "https://", "data:"))] + + if args.dry_run: + print("—— DRY RUN(不联网,不需要凭证)——") + print(f"标题:{args.title or '(未提供)'}") + print(f"摘要:{args.digest or '(空)'}({len(args.digest)}/120 字)") + print(f"封面:{cover or '❌ 未找到——用 --cover 指定或放 cover.png 进文章目录'}") + print(f"正文本地图片 {len(local_imgs)} 张:") + for s in local_imgs: + print(f" - {s}") + crop_note = "跳过(--no-crop)" if args.no_crop else f"2.35:1={crop_fractions(*img_size(cover), 2.35, 1)} · 1:1={crop_fractions(*img_size(cover), 1, 1)}" + print(f"裁剪框:{crop_note}") + print("执行动作:上传封面拿 thumb_media_id → 逐张上传正文图并替换 src → 提交 draft/add") + return + + if not (app_id and secret): + print("❌ 缺少环境变量 WECHAT_APP_ID / WECHAT_APP_SECRET\n" + " 获取路径:mp.weixin.qq.com → 设置与开发 → 基本配置\n" + " 设置方式:export WECHAT_APP_ID=xxx WECHAT_APP_SECRET=yyy(建议写进 ~/.zshrc)\n" + " 别把密钥写进任何 git 仓库。", file=sys.stderr) + sys.exit(2) + + if not args.title: + die("正式同步必须提供 --title", 2) + if not cover: + die("找不到封面图——先按封面 brief 生成 cover.png 放进文章目录,或用 --cover 指定", 2) + + token = get_token(app_id, secret) + + print("上传封面…") + r = post_file(f"{API}/cgi-bin/material/add_material?type=image&access_token={token}", cover) + err = wx_err(r) + if err: + die(f"封面上传失败——{err}") + thumb_media_id = r["media_id"] + print(f" ✔ thumb_media_id={thumb_media_id}") + + for s in local_imgs: + p = os.path.join(args.dir, s) + if not os.path.isfile(p): + die(f"正文图片不存在:{p}") + print(f"上传正文图 {s}…") + r = post_file(f"{API}/cgi-bin/media/uploadimg?access_token={token}", p) + err = wx_err(r) + if err: + die(f"正文图上传失败({s})——{err}") + html_text = html_text.replace(f'src="{s}"', f'src="{r["url"]}"') + print(f" ✔ {s} → {r['url']}") + + print("提交草稿…") + article = {"title": args.title, "content": html_text, + "thumb_media_id": thumb_media_id, + "need_open_comment": 0, "only_fans_can_comment": 0} + if not args.no_crop: + w, h = img_size(cover) + article["pic_crop_235_1"] = crop_fractions(w, h, 2.35, 1) + article["pic_crop_1_1"] = crop_fractions(w, h, 1, 1) + print(f" 裁剪框:2.35:1={article['pic_crop_235_1']} · 1:1={article['pic_crop_1_1']}") + if args.digest: + article["digest"] = args.digest + if args.author: + article["author"] = args.author + r = http_json(f"{API}/cgi-bin/draft/add?access_token={token}", {"articles": [article]}) + err = wx_err(r) + if err: + die(f"草稿提交失败——{err}") + print(f"✔ 已进入草稿箱(media_id={r['media_id']})\n" + f" 下一步:mp.weixin.qq.com → 内容与互动 → 发表 → 草稿,人工预览确认后再发表。\n" + f" 本 skill 到草稿箱为止,不执行群发。") + + +if __name__ == "__main__": + main()