Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

### Added

- **Digest/relay 契约**:`orchestrator` 新增 `--digest`,默认读取 frontmatter `summary`,并在自动/手动 relay 推送命令中显式传给 `create_wechat_draft.mjs`
- **发布证据持久化**:草稿 create/update + `draft/get` 后自动写入 `audit.log` 与 `push-result.json`;relay 路径把证据带回文章 `publish/vN/`
- **CATALOG Backlink**:Orchestrator 可发现或显式接收 CATALOG/slug,成功后回写当前 id、`pushed-draft`、audit 指针,并保留被替换的旧 id
- **Relay 对账**:新增 `reconcile_wechat_drafts.mjs`,区分 matched、stale、`published-or-deleted`、uncataloged 和 cover-missing;`--write` 只修复唯一 replacement candidate
Expand All @@ -18,6 +19,7 @@
- **Pipeline 自洽修补**:显式串联 render/preflight/orchestrator 的封面图和 `--skip-image-check` 参数,避免 `pre_image_missing` 隐式破坏默认入口
- **AutoHeal 阻断逻辑**:preflight JSON 改为完整解析,存在不可自动修复的 L1/Agent 失败时不再继续进入 bundle
- **Bundle/relay 契约**:封面图纳入 bundle,relay 目录日期由本地生成,远程推送命令正确传递标题、作者、封面和 `--crop-235-1`
- **发布摩擦沉淀**:补充 footer QR 绝对路径、参考资料 bullet 空行、手动 render 图片路径和正式推送默认 relay 的 gotchas
- **Preflight 误报收敛**:Markdown 卡片/表格计数跳过代码块和 inline code,source verification 只扫描可见文本数字
- **CI 覆盖**:`npm run check` 与 GitHub Lint workflow 覆盖 `harness/**/*.mjs`

Expand Down
21 changes: 13 additions & 8 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ metadata:

`Skill: "khazix-writer"`。
⚠️ 删尾部模板「投稿或爆料…」整行,替换身份。正文 > 800 字至少 1 张图。
详细规则:`references/gotchas.md` #G01–#G05 + `references/writing-rules.md`。
详细规则:`references/gotchas.md` #G01–#G05、#G34 + `references/writing-rules.md`。

---

Expand All @@ -37,10 +37,13 @@ metadata:
```bash
node ${PIPELINE_HOME}/scripts/orchestrator.mjs \
--input article.md --account YOUR_ACCOUNT \
--title "标题" --author "公众号作者" \
--title "标题" --digest "摘要" --author "公众号作者" \
--qr /abs/path/to/footer-qr.png \
--auto-fix --auto-push
```

⚠️ 发布优先用 Orchestrator,不要手动拆 render/bundle/push。`--digest` 不传时会读取 frontmatter `summary` 并传到 relay;`--qr` 必须传绝对路径,避免 footer QR 在 render/preflight 间被拼成错误相对路径。

---

## Step 1:渲染
Expand All @@ -49,10 +52,11 @@ node ${PIPELINE_HOME}/scripts/orchestrator.mjs \
node ${PIPELINE_HOME}/scripts/render_wechat_editorial.mjs \
--input <md> --output <html> \
--env ${PIPELINE_HOME}/.env \
--footer-qr /abs/path/to/footer-qr.png \
--lint-report-out <lint.json>
```
⚠️ `--env` 和 `--lint-report-out` 必须指定。禁止绕路用 inline import 替换 CLI。
Gotchas:`references/gotchas.md` #G06–#G10。
⚠️ `--env` 和 `--lint-report-out` 必须指定。`--footer-qr` 一律使用绝对路径。禁止绕路用 inline import 替换 CLI。
Gotchas:`references/gotchas.md` #G06–#G10、#G35–#G36

---

Expand Down Expand Up @@ -81,14 +85,15 @@ Gotchas:`references/gotchas.md` #G15–#G17。
```bash
ssh relay "cd <dir> && node create_wechat_draft.mjs \
--html article.html --thumb-image cover.png \
--lint-report lint.json --title '标题' --account <ACCOUNT> \
--lint-report lint.json --title '标题' --digest '摘要' --account <ACCOUNT> \
--audit-out audit.log --push-result-out push-result.json"
```
⚠️ 正式推送优先用 Orchestrator:它会把 relay 的 `audit.log` 和
`push-result.json` 带回文章 `publish/vN/`,并在可唯一定位时回写 CATALOG。
⚠️ 正式推送默认走 relay,并优先用 Orchestrator:它会显式传递 digest,把
relay 的 `audit.log` 和 `push-result.json` 带回文章 `publish/vN/`,并在可
唯一定位时回写 CATALOG。本机直推只有在确认当前 IP 已进微信白名单时才可用。
证据或 Backlink 不完整时即使微信已收稿也必须报非零。裁剪参数必须从
preflight 输出复制。标题 ≤ 21 中文字,digest ≤ 120 字。
Gotchas:`references/gotchas.md` #G18–#G21、#G31–#G33。
Gotchas:`references/gotchas.md` #G18–#G21、#G31–#G33、#G37–#G38

---

Expand Down
17 changes: 16 additions & 1 deletion harness/test-orchestrator-command-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { buildManualRelayCommand, resolvePipelinePaths } from "../scripts/orchestrator.mjs";
import {
buildManualRelayCommand,
extractSummaryFromMarkdown,
resolvePipelinePaths,
} from "../scripts/orchestrator.mjs";

const result = buildManualRelayCommand({
relayHost: "relay-host",
Expand All @@ -17,6 +21,7 @@ const result = buildManualRelayCommand({
slug: "slug",
author: "公众号作者",
openComment: "1",
digest: "短摘要:必须原样传到 relay。",
sourcePath: "/tmp/article source.md",
archiveDir: "/tmp/article-publish/v1",
catalogPath: "/tmp/article repo/CATALOG.md",
Expand All @@ -33,6 +38,7 @@ assert.match(result.command, /scp '\/tmp\/wechat bundle\/\.env'/);
assert.match(result.remoteDraftCmd, /--thumb-image 'cover\.png'/);
assert.match(result.remoteDraftCmd, /--crop-235-1 '0_0\.0035_1_0\.9965'/);
assert.match(result.remoteDraftCmd, /--title 'Title With Spaces'/);
assert.match(result.remoteDraftCmd, /--digest '短摘要:必须原样传到 relay。'/);
assert.match(result.remoteDraftCmd, /--audit-out 'audit\.log'/);
assert.match(result.remoteDraftCmd, /--push-result-out 'push-result\.json'/);
assert.match(result.remoteDraftCmd, /--source-path '\/tmp\/article source\.md'/);
Expand All @@ -41,6 +47,15 @@ assert.match(result.command, /audit\.log/);
assert.match(result.command, /update_wechat_catalog\.mjs/);
assert.doesNotMatch(result.command, /\\scp|\\ssh|\\ node/);

assert.equal(
extractSummaryFromMarkdown("---\ntitle: Test\nsummary: \"frontmatter 摘要\"\n---\n# H1\n"),
"frontmatter 摘要",
);
assert.equal(
extractSummaryFromMarkdown("summary: legacy 摘要\n\n# H1\n"),
"legacy 摘要",
);

const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "md2wechat-orchestrator-"));
try {
const articleDir = path.join(tmpRoot, "article");
Expand Down
5 changes: 5 additions & 0 deletions md2wechat.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,14 @@ md2wechat/
- [ ] 人格覆盖:替换「数字生命卡兹克」为用户的公众号身份
- [ ] 破折号 `——` 和中文双引号 `""` 必须在 Step 0 替换,不能等 lint 拦
- [ ] 插图检查:正文 > 800 字至少 1 张图,> 1500 字至少 2 张
- [ ] 参考资料连续 bullet 每条之间留空行,避免被段落长度检查合并误伤

### Step 1(渲染)
- [ ] 禁止绕路:不能用 inline import 替换 CLI 渲染
- [ ] `--env` 必须显式指定,否则 footer 不注入
- [ ] `--lint-report-out` 必须指定,否则 Audit Log 缺【渲染质检】区块
- [ ] `--footer-qr` 必须传绝对路径
- [ ] 手动 render 时,正文图片必须能从 HTML 输出目录解析;发布优先走 orchestrator

### Step 2(图片)
- [ ] 封面图必须 ≤ 2MB,超了用 `sips -Z` 压缩,覆盖原文件名
Expand All @@ -82,6 +85,8 @@ md2wechat/
- [ ] 裁剪参数必须从 preflight 输出中复制,不能猜测
- [ ] 标题 ≤ 21 个中文字(微信 64 字节限制)
- [ ] digest(summary)≤ 120 字(微信 digest 限制)
- [ ] 正式推送默认走 relay,除非确认本机出口 IP 已在微信白名单
- [ ] relay 远端命令必须显式传 `--digest`;orchestrator 应从 `--digest` 或 frontmatter `summary` 取值
- [ ] `publish/vN/audit.log` 与 `push-result.json` 必须落盘;stdout 不算持久证据
- [ ] relay 推送必须把两份证据带回本地再报完整成功
- [ ] CATALOG 可唯一定位时自动回写当前 id/status/audit;旧 id 留在 Notes
Expand Down
12 changes: 11 additions & 1 deletion references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

**Gotcha #G05**: `summary:` 是 GEO 第一依据 — 必须写成「AI 可直接引用的答案片段」(含核心结论 + 具体论据),不能只概括大意;≤ 120 字。

**Gotcha #G34**: 连续参考资料 bullet 会被段落长度检查合并 — 「参考资料」这类连续 `- link` 列表,每条之间留一个空行;否则可能被 L1 超长段落误伤。

---

## Step 1:渲染 HTML
Expand All @@ -31,6 +33,10 @@

**Gotcha #G10**: 卡片内部不支持表格 — `:::wechat-card` 里的 `|...|` 表格语法不会被解析,会被当成普通文本。表格必须放在卡片外部。

**Gotcha #G35**: `--footer-qr` 必须传绝对路径 — 相对路径会以 HTML 输出目录再次拼接,可能生成双重相对路径,导致 footer QR 在 preflight 或 relay 上找不到。

**Gotcha #G36**: 手动 render 时图片必须能从 HTML 输出目录解析 — 如果 HTML 输出到 `publish/v1/article.html`,正文 `assets/*.png` 需要在 `publish/v1/assets/` 存在;更推荐直接用 orchestrator,让 bundle 处理路径替换。

---

## Step 2:准备图片
Expand Down Expand Up @@ -65,6 +71,10 @@

**Gotcha #G21**: `orchestrator --auto-push` 会自动处理版本号递增 — 手动推送时才需要查已有最大版本号,`v{N+1}` 递增,永不覆盖已有版本。

**Gotcha #G37**: 正式推送默认走 relay — 本机 IP 未进入微信白名单时会触发 `40164 invalid ip`;除非确认当前出口 IP 已加入白名单,否则不要本机直推。

**Gotcha #G38**: relay 推送必须显式传 `--digest` — 不传时低层脚本会退回正文前 54 字,可能覆盖精心写好的摘要。Orchestrator 会从 `--digest` 或 frontmatter `summary` 读取并传到 relay。

---

## Step 4:回检验证
Expand All @@ -91,7 +101,7 @@

> `python3 -c "t='标题';print(len(t.encode('utf-8')))"` 预先算好再写 `--title`。

**Gotcha #G28**: digest > 128 字节触发微信 API 45004 错误 — frontmatter `summary:` 会被脚本作为 digest 传给微信。≤ 128 字节(约 42 个中文字)。**最安全的做法**:始终显式传 `--digest`,不要依赖 frontmatter summary。
**Gotcha #G28**: digest > 128 字节触发微信 API 45004 错误 — frontmatter `summary:` 会被脚本作为 digest 传给微信。≤ 128 字节(约 42 个中文字)。**最安全的做法**:让 Orchestrator 从 `--digest`frontmatter `summary` 读取后显式传给 relay,不要依赖低层脚本自动取正文前 54 字

> 实在想自动截断可在 Step 0 末尾用 `node -e "const s='...';console.log(s.slice(0,40))"` 预检。

Expand Down
42 changes: 41 additions & 1 deletion scripts/orchestrator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Required:

Optional:
--title <text> Article title (overrides H1 in MD)
--digest <text> Article digest; defaults to frontmatter summary
--author <text> Article author (default: WECHAT_DEFAULT_AUTHOR or \"公众号作者\")
--env <path> .env file path (default: ./.env)
--out-dir <dir> Bundle output directory (default: <article-dir>/publish/vN/bundle)
Expand Down Expand Up @@ -123,6 +124,39 @@ export function shellQuote(value = "") {
return `'${String(value).replace(/'/g, "'\\''")}'`;
}

function cleanYamlScalar(value = "") {
const trimmed = String(value || "").trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}

export function extractSummaryFromMarkdown(markdown = "") {
const text = String(markdown || "");
if (text.startsWith("---\n") || text.startsWith("---\r\n")) {
const lines = text.split(/\r?\n/);
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === "---") break;
const match = lines[i].match(/^summary\s*:\s*(.+)$/i);
if (match) return cleanYamlScalar(match[1]);
}
}
const legacy = text.match(/^summary\s*:\s*(.+)$/im);
return legacy ? cleanYamlScalar(legacy[1]) : "";
}

export function extractSummaryFromFile(mdPath) {
try {
return extractSummaryFromMarkdown(fs.readFileSync(mdPath, "utf8"));
} catch {
return "";
}
}

function formatLocalDateStamp(date = new Date()) {
const yyyy = String(date.getFullYear());
const mm = String(date.getMonth() + 1).padStart(2, "0");
Expand Down Expand Up @@ -226,6 +260,7 @@ export function buildManualRelayCommand({
slug,
author,
openComment,
digest = "",
sourcePath = "",
archiveDir = "",
catalogPath = "",
Expand Down Expand Up @@ -253,6 +288,7 @@ export function buildManualRelayCommand({
"--audit-out 'audit.log'",
"--push-result-out 'push-result.json'",
"--json",
...(digest ? [`--digest ${shellQuote(digest)}`] : []),
...(thumbImage ? [`--thumb-image ${shellQuote(path.basename(thumbImage))}`] : []),
...(cropSpec ? [`--crop-235-1 ${shellQuote(cropSpec)}`] : []),
].join(" ");
Expand Down Expand Up @@ -616,6 +652,7 @@ function main() {
const title = args.title;
const envPath = path.resolve(args.env || path.join(process.cwd(), ".env"));
const author = args.author || readEnvVar(envPath, "WECHAT_DEFAULT_AUTHOR") || "公众号作者";
const digest = args.digest ? String(args.digest).trim() : extractSummaryFromFile(inputPath);
const dryRun = args["dry-run"] || false;
const autoFix = args["auto-fix"] || false;
const thumbImage = args["thumb-image"] ? path.resolve(args["thumb-image"]) : null;
Expand Down Expand Up @@ -675,6 +712,7 @@ function main() {
console.log(`${C.dim}Input: ${inputPath}${C.reset}`);
console.log(`${C.dim}Account: ${account}${C.reset}`);
console.log(`${C.dim}Author: ${author}${C.reset}`);
console.log(`${C.dim}Digest: ${digest ? `${digest.length} chars` : "(fallback to正文前54字)"}${C.reset}`);
console.log(`${C.dim}Dry-run: ${dryRun}${C.reset}`);
console.log(`${C.dim}Auto-fix: ${autoFix}${C.reset}`);
console.log(`${C.dim}Archive: ${archiveDir}${C.reset}`);
Expand Down Expand Up @@ -911,9 +949,10 @@ function main() {
const remoteTitle = title || slug;
const remoteThumbArg = thumbImage ? ` --thumb-image ${shellQuote(path.basename(thumbImage))}` : "";
const remoteCropArg = cropSpec ? ` --crop-235-1 ${shellQuote(cropSpec)}` : "";
const remoteDigestArg = digest ? ` --digest ${shellQuote(digest)}` : "";
const remoteCmd = [
relayHost,
`cd ${shellQuote(remoteDir)} && node ${shellQuote(`${scriptsDir}/create_wechat_draft.mjs`)} --html ${shellQuote(path.basename(renderOut))}${remoteThumbArg} --lint-report ${shellQuote(path.basename(lintOut))} --title ${shellQuote(remoteTitle)} --author ${shellQuote(author)} --account ${shellQuote(account)} --open-comment ${shellQuote(openComment)} --source-path ${shellQuote(inputPath)} --audit-out ${shellQuote("audit.log")} --push-result-out ${shellQuote("push-result.json")} --json${remoteCropArg}`,
`cd ${shellQuote(remoteDir)} && node ${shellQuote(`${scriptsDir}/create_wechat_draft.mjs`)} --html ${shellQuote(path.basename(renderOut))}${remoteThumbArg} --lint-report ${shellQuote(path.basename(lintOut))} --title ${shellQuote(remoteTitle)} --author ${shellQuote(author)} --account ${shellQuote(account)} --open-comment ${shellQuote(openComment)} --source-path ${shellQuote(inputPath)} --audit-out ${shellQuote("audit.log")} --push-result-out ${shellQuote("push-result.json")} --json${remoteDigestArg}${remoteCropArg}`,
];
const pushResult = spawnSync("ssh", remoteCmd, { encoding: "utf8", stdio: "pipe", maxBuffer: 1024 * 1024 });

Expand Down Expand Up @@ -1012,6 +1051,7 @@ function main() {
slug,
author,
openComment,
digest,
sourcePath: inputPath,
archiveDir,
catalogPath,
Expand Down
Loading