le-wechat-article v1.0:Leroy 专属公众号写作工坊(类型路由 / 分层审读 / 排版转换 / 草稿箱同步)
This commit is contained in:
@@ -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 味指纹阈值与机器自检思想。
|
||||||
@@ -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-<slug>/`)
|
||||||
|
|
||||||
|
用户只说「帮我写篇公众号文章」没给主题 → 先给 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)
|
||||||
@@ -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 分钟)未被改动",
|
||||||
|
"审读环节实际执行(有意见清单输出),修改采纳/驳回有理由",
|
||||||
|
"长文阈值未到时可合并审读,但机器自检不可跳过"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 主职责,但若在公众号文章语境下可接"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 字
|
||||||
|
- **审读重点**:事件与经历是否真实、外部事实可否追溯;观点是否由细节支撑;结构是否自然、有无强行升华;作者声音是否稳定、情绪是否来自素材
|
||||||
|
|
||||||
|
## 篇幅的共同原则
|
||||||
|
|
||||||
|
区间是参考不是配额:结构完整、逻辑连贯优先,素材不足时短于下限也可交付(在审读报告中注明),不为凑字数注水。
|
||||||
@@ -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(公众号正文标准)
|
||||||
|
|
||||||
|
## 敏感词清单(可选)
|
||||||
|
|
||||||
|
一行一个,审读时逐词扫描,命中即必改;留空则只按通用合规类别检查:
|
||||||
|
|
||||||
|
(待填写)
|
||||||
@@ -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 方法论。仅借方法,未复制其表达与依赖。
|
||||||
@@ -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 在当次任务明确要求或作者画像明确配置时才加
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 常用事实卡
|
||||||
|
|
||||||
|
写作涉及 Leroy 自己的项目、工具链或经历时必读本卡,取用其中事实可免于重新询问;本卡没写的事实仍按事实核查规则处理(问 Leroy 或标待确认)。与作者画像分工:**画像管风格,事实卡管事实**。
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
- 写作第 2 步(校准)时与作者画像一起加载
|
||||||
|
- 空着不影响流程;Leroy 随时口述更新,改完即时生效
|
||||||
|
- 本卡属于个人资产,内容不复制进文章交付物(文章引用的是最终表述,不是卡片原文)
|
||||||
|
|
||||||
|
## 项目与产品
|
||||||
|
|
||||||
|
(每个常写的项目一条:名称、一句话定位、关键链接、当前版本/状态)
|
||||||
|
|
||||||
|
- (待填写,如:MetaSkill —— Leroy 的 Agent Skill 工坊项目,…)
|
||||||
|
|
||||||
|
## 工具链与环境
|
||||||
|
|
||||||
|
(常在文章里出现的工具及其准确名称/版本约定,防拼写与版本张冠李戴)
|
||||||
|
|
||||||
|
- (待填写,如:主力模型 GLM-5.3;编辑器 ZCode / Claude Code / Codex …)
|
||||||
|
|
||||||
|
## 经历时间线
|
||||||
|
|
||||||
|
(常被引用的个人经历:事件 + 绝对日期,防时间漂移)
|
||||||
|
|
||||||
|
- (待填写,如:2026-09 开始运营公众号 …)
|
||||||
|
|
||||||
|
## 固定表述
|
||||||
|
|
||||||
|
(对同一事物统一说法,如产品名中英文、缩写约定)
|
||||||
|
|
||||||
|
- (待填写)
|
||||||
@@ -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)的视觉路由。仅借方法。
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""公众号文章自检:机器能量化的硬规则与产出配额,不靠眼睛。
|
||||||
|
|
||||||
|
用法:python3 check_article.py <article.md> [--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="草稿模式:图位  不算错误(交付版必须去掉此开关)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
sys.exit(check(args.article, args.type, args.strict, args.allow_placeholder))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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 栏宽)
|
||||||
|
输出用浏览器打开,全选复制 → 粘贴进公众号编辑器。
|
||||||
|
支持的语法子集:#(跳过)/ ### / 段落 / **加粗** / `行内代码` / [链接] /  /
|
||||||
|
> 引用(含「—— 出处」识别为金句卡)/ - 与 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'<img src="{esc(m.group(2))}" style="max-width:100%;display:block;margin:16px auto;'
|
||||||
|
f'border-radius:6px;"'
|
||||||
|
+ (f'<figcaption style="text-align:center;font-size:13px;color:#999;'
|
||||||
|
f'margin:8px 0 20px;">{esc(m.group(3))}</figcaption>' if m.group(3) else "")),
|
||||||
|
t)
|
||||||
|
t = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)",
|
||||||
|
rf'<a href="\2" style="{st["link"]}">\1</a>', t)
|
||||||
|
t = re.sub(r"\*\*([^*]+)\*\*", rf'<strong style="{st["strong"]}">\1</strong>', t)
|
||||||
|
t = re.sub(r"\x00(\d+)\x00",
|
||||||
|
lambda m: f'<code style="background:#f2f3f5;color:#c7254e;border-radius:3px;'
|
||||||
|
f'padding:2px 5px;font-size:14px;font-family:Menlo,Consolas,monospace;">'
|
||||||
|
f'{esc(codes[int(m.group(1))])}</code>', 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'<li style="{st["li"]}">' + inline(bullet.sub("", it), st) + "</li>"
|
||||||
|
for it in items)
|
||||||
|
out.append(f'<{tag} style="{st["li_ul"]}">{lis}</{tag}>')
|
||||||
|
|
||||||
|
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'<pre style="background:#f6f8fa;border-radius:8px;padding:14px 16px;'
|
||||||
|
f'overflow-x:auto;font-size:13.5px;line-height:1.6;margin:0 0 20px;'
|
||||||
|
f'font-family:Menlo,Consolas,monospace;color:#24292e;">{code}</pre>')
|
||||||
|
continue
|
||||||
|
if s.startswith("# ") and not s.startswith("##"):
|
||||||
|
i += 1 # 主标题不进正文(填在公众号后台)
|
||||||
|
continue
|
||||||
|
if s.startswith("###") and not s.startswith("####"):
|
||||||
|
out.append(f'<h3 style="{st["h3"]}">{inline(s.lstrip("#").strip(), st)}</h3>')
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if re.match(r"^####+\s", s):
|
||||||
|
out.append(f'<p style="margin:24px 0 12px;font-weight:600;color:{dark};'
|
||||||
|
f'font-size:{px};">{inline(s.lstrip("#").strip(), st)}</p>')
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if re.match(r"^-{3,}$", s):
|
||||||
|
out.append('<hr style="border:none;border-top:1px solid #eaeaea;margin:28px 0;"/>')
|
||||||
|
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 = "<br/>".join(inline(b, st) for b in buf if b)
|
||||||
|
key = "golden" if "——" in text else "quote"
|
||||||
|
out.append(f'<blockquote style="{st[key]}">{text}</blockquote>')
|
||||||
|
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'<td style="border:1px solid #eaeaea;padding:8px 10px;'
|
||||||
|
f'font-size:{int(px[:-2]) - 1}px;{head}">{inline(c, st)}</td>'
|
||||||
|
for c in cells)
|
||||||
|
trs.append(f"<tr>{tds}</tr>")
|
||||||
|
out.append('<table style="border-collapse:collapse;margin:0 auto 20px;">'
|
||||||
|
+ "".join(trs) + "</table>")
|
||||||
|
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'<p style="{st["p"]}">{inline(s, st)}</p>')
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return (f'<section style="max-width:578px;margin:0 auto;font-family:'
|
||||||
|
f'-apple-system,BlinkMacSystemFont,\'PingFang SC\',\'Hiragino Sans GB\','
|
||||||
|
f'\'Microsoft YaHei\',sans-serif;">' + "".join(out) + "</section>")
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE = """### 小节标题示例
|
||||||
|
|
||||||
|
正文段落:这里是**主题加粗落点**,后面是[链接示例](https://example.com)与行内代码 `npm install`。
|
||||||
|
|
||||||
|
- **标签一**:说明文字
|
||||||
|
- **标签二**:说明文字
|
||||||
|
|
||||||
|
> 值得截图转发的一句金句。 —— 出处
|
||||||
|
|
||||||
|
> 普通引用:补充说明的引文。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
结尾段落,含**收束落点**。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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'<h2 style="text-align:center;font-size:15px;color:#999;'
|
||||||
|
f'margin:40px 0 12px;">骨架:{sk_name} · 色板 {color}</h2>'
|
||||||
|
f'<div style="max-width:375px;margin:0 auto;border:1px solid #ddd;'
|
||||||
|
f'padding:16px 14px;">{doc}</div>')
|
||||||
|
page = ("<!DOCTYPE html><html><head><meta charset='utf-8'><title>主题预览</title></head>"
|
||||||
|
f"<body>{''.join(blocks)}</body></html>")
|
||||||
|
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 = ("<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
||||||
|
"<title>公众号文章</title></head>"
|
||||||
|
f"<body style='background:#fff;'>{doc}</body></html>")
|
||||||
|
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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user