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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@

## [Unreleased]

### Added

- **发布证据持久化**:草稿 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

### Fixed

- **Pipeline 自洽修补**:显式串联 render/preflight/orchestrator 的封面图和 `--skip-image-check` 参数,避免 `pre_image_missing` 隐式破坏默认入口
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,19 @@ node scripts/orchestrator.mjs \

它会自动完成:创建远程目录 → SCP 上传 bundle(`.env` 单独传输)→ SSH 远程执行 `create_wechat_draft.mjs`。手动分步命令详见 SKILL.md Step 3。

验证通过后,Orchestrator 必须把两份证据带回文章的同一
`publish/vN/`:

- `audit.log`:`draft/get` 回检后的可读审计
- `push-result.json`:`media_id`、`thumb_media_id`、`errcode`、账号、
推送时间、源稿路径和 verification 状态

如果输入稿所在仓存在可唯一发现的 `CATALOG.md`,Orchestrator 会按
文章目录推导 slug 并回写当前 `media_id`、`pushed-draft` 状态和 audit
指针。可用 `--catalog` / `--catalog-slug` 显式指定;仓库确实没有 CATALOG
契约时用 `--no-catalog`。微信已收稿但证据返回或 CATALOG 回写失败时,
命令返回非零并保留可取得的 `push-result.json`,不能把它误报为完整成功。

### Step 4:回检验证

推送后 `create_wechat_draft.mjs` **自动从微信 API 拉回草稿** 输出 Audit Log。关键检查项:
Expand All @@ -279,6 +292,22 @@ node scripts/orchestrator.mjs \

> **必须逐行核验**:拿到 Audit Log 后,打开回检表格逐项比对。`errcode: 0` ≠ 完成。

周期对账必须走 relay(本机出口 IP 未必在微信白名单):

```bash
node scripts/reconcile_wechat_drafts.mjs \
--catalog /absolute/article-repo/CATALOG.md \
--account YOUR_ACCOUNT \
--env /absolute/md2wechat/.env \
--date YYYY-MM-DD \
--report /absolute/reconcile-report.json
```

默认只读。仅在确认唯一标题匹配的替换 id 后,`--write` 才会修复
CATALOG;真正从草稿箱消失的记录始终标为 `published-or-deleted`,不会因
`freepublish` 返回 `48001` 或草稿消失而自动断言已发布。封面为空的草稿
会在 `cover_missing` 中单列。

### Step 5:完成前核查(skill-compliance-harness)

**管线最后一道门。** 在所有步骤执行完毕、告知用户"完成"之前,先加载合规核查 skill:
Expand Down
20 changes: 15 additions & 5 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,20 +81,30 @@ 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 '标题' --account <ACCOUNT> \
--audit-out audit.log --push-result-out push-result.json"
```
⚠️ 裁剪参数必须从 preflight 输出复制。标题 ≤ 21 中文字,digest ≤ 120 字。
Gotchas:`references/gotchas.md` #G18–#G21。
⚠️ 正式推送优先用 Orchestrator:它会把 relay 的 `audit.log` 和
`push-result.json` 带回文章 `publish/vN/`,并在可唯一定位时回写 CATALOG。
证据或 Backlink 不完整时即使微信已收稿也必须报非零。裁剪参数必须从
preflight 输出复制。标题 ≤ 21 中文字,digest ≤ 120 字。
Gotchas:`references/gotchas.md` #G18–#G21、#G31–#G33。

---

## Step 4:回检

推送成功后 Audit Log 自动输出。逐行核对 `references/audit-log.md` 中的检查项。
推送成功后 Audit Log 自动落盘并可输出。逐行核对
`references/audit-log.md` 中的检查项;结构化结果见同目录
`push-result.json`。
```bash
node ${PIPELINE_HOME}/scripts/preflight-check.mjs --file <audit.log> --require-render-qa

node ${PIPELINE_HOME}/scripts/reconcile_wechat_drafts.mjs \
--catalog <CATALOG.md> --account <ACCOUNT> --env ${PIPELINE_HOME}/.env
```
Gotchas:`references/gotchas.md` #G22–#G24。
对账默认只读且经 relay;草稿消失只标 `published-or-deleted`,封面空值单列。
Gotchas:`references/gotchas.md` #G22–#G24、#G31–#G33。

---

Expand Down
41 changes: 41 additions & 0 deletions examples/sample-push-result.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"schema_version": "md2wechat-push-result/v1",
"media_id": "sample-current-media-id",
"thumb_media_id": "sample-cover-media-id",
"errcode": 0,
"errmsg": "ok",
"account": "YOUR_ACCOUNT",
"pushed_at": "2026-07-17T08:00:00.000Z",
"source_path": "/absolute/article/path/article.md",
"html_path": "/relay/workdir/article.html",
"title": "示例文章",
"operation": "draft/add",
"completion_status": "verified",
"verification": {
"status": "passed",
"checked_at": "2026-07-17T08:00:02.000Z",
"metrics": {
"imgCount": 3,
"cdnCount": 3,
"h2Count": 5,
"cardCount": 1,
"darkCardCount": 1,
"lightCardCount": 0,
"quoteCount": 1,
"styleCount": 98,
"positionCount": 0,
"filterCount": 0
}
},
"evidence": {
"audit_path": "/absolute/article/path/publish/v3/audit.log",
"push_result_path": "/absolute/article/path/publish/v3/push-result.json"
},
"backlink": {
"status": "updated",
"catalog_path": "/absolute/article-repo/CATALOG.md",
"catalog_slug": "article-slug",
"old_media_id": "sample-superseded-media-id",
"audit_pointer": "publish/v3/audit.log"
}
}
12 changes: 12 additions & 0 deletions harness/test-orchestrator-command-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ const result = buildManualRelayCommand({
slug: "slug",
author: "公众号作者",
openComment: "1",
sourcePath: "/tmp/article source.md",
archiveDir: "/tmp/article-publish/v1",
catalogPath: "/tmp/article repo/CATALOG.md",
catalogSlug: "enterprise-ai-carrier",
thumbImage: "/tmp/cover.png",
cropSpec: "0_0.0035_1_0.9965",
envInBundle: true,
Expand All @@ -29,6 +33,12 @@ 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, /--audit-out 'audit\.log'/);
assert.match(result.remoteDraftCmd, /--push-result-out 'push-result\.json'/);
assert.match(result.remoteDraftCmd, /--source-path '\/tmp\/article source\.md'/);
assert.match(result.command, /push-result\.json/);
assert.match(result.command, /audit\.log/);
assert.match(result.command, /update_wechat_catalog\.mjs/);
assert.doesNotMatch(result.command, /\\scp|\\ssh|\\ node/);

const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "md2wechat-orchestrator-"));
Expand All @@ -43,6 +53,8 @@ try {
assert.equal(first.outDir, path.join(articleDir, "publish", "v1", "bundle"));
assert.equal(first.renderOut, path.join(articleDir, "publish", "v1", "enterprise-ai-carrier.html"));
assert.equal(first.lintOut, path.join(articleDir, "publish", "v1", "enterprise-ai-carrier-lint.json"));
assert.equal(first.auditOut, path.join(articleDir, "publish", "v1", "audit.log"));
assert.equal(first.pushResultOut, path.join(articleDir, "publish", "v1", "push-result.json"));

fs.mkdirSync(path.join(articleDir, "publish", "v1"), { recursive: true });
const second = resolvePipelinePaths({ inputPath: articlePath });
Expand Down
101 changes: 101 additions & 0 deletions harness/test-publish-evidence-contract.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
deriveCatalogSlug,
discoverCatalogPath,
finalizePushResultBacklink,
inspectCatalogTarget,
updateCatalogAfterPush,
} from "../scripts/lib/publish-evidence.mjs";
import { persistVerifiedDraftEvidence } from "../scripts/create_wechat_draft.mjs";

const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "md2wechat-evidence-"));
try {
const articleDir = path.join(tmpRoot, "articles", "2026", "2026-07-17-contract-test");
const publishDir = path.join(articleDir, "publish", "v2");
const sourcePath = path.join(articleDir, "article.md");
const auditPath = path.join(publishDir, "audit.log");
const pushResultPath = path.join(publishDir, "push-result.json");
const catalogPath = path.join(tmpRoot, "CATALOG.md");
fs.mkdirSync(publishDir, { recursive: true });
fs.writeFileSync(sourcePath, "# Contract test\n", "utf8");
const persisted = persistVerifiedDraftEvidence({
audit: {
timestamp: "2026-07-17T08:00:00.000Z",
source: { mdPath: sourcePath, htmlPath: path.join(publishDir, "article.html"), title: "Contract Test", author: "Author", account: "XINZHE" },
assets: [],
lintReport: null,
push: { status: "ok", mediaId: "new-media-id-12345678901234567890", thumbMediaId: "thumb-id" },
verify: { imgCount: 1, cdnCount: 1, h2Count: 1, cardCount: 0, darkCardCount: 0, lightCardCount: 0, quoteCount: 0, styleCount: 60, positionCount: 0, filterCount: 0 },
},
pushResult: {
schema_version: "md2wechat-push-result/v1",
media_id: "new-media-id-12345678901234567890",
thumb_media_id: "thumb-id",
errcode: 0,
completion_status: "verified",
verification: { status: "passed" },
},
auditOutPath: auditPath,
pushResultOutPath: pushResultPath,
});
assert.equal(persisted.auditPath, auditPath);
assert.equal(persisted.pushResultPath, pushResultPath);
assert.match(fs.readFileSync(auditPath, "utf8"), /media_id: new-media-id-12345678901234567890/);
fs.writeFileSync(catalogPath, [
"# Article Catalog",
"",
"| Date | Slug | Title | Status | Draftbox | WeChat media_id | Notes |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| 2026-07-17 | `contract-test` | Contract Test | drafting | 未推送草稿箱 | `old-media-id-12345678901234567890` | 初始备注 |",
"",
].join("\n"), "utf8");

assert.equal(discoverCatalogPath(sourcePath), catalogPath);
assert.equal(deriveCatalogSlug(sourcePath), "contract-test");
assert.equal(inspectCatalogTarget({ catalogPath, slug: "contract-test" }).row.status, "drafting");

const update = updateCatalogAfterPush({
catalogPath,
slug: "contract-test",
mediaId: "new-media-id-12345678901234567890",
account: "XINZHE",
sourcePath,
auditPath,
});
assert.equal(update.changed, true);
assert.equal(update.old_media_id, "old-media-id-12345678901234567890");

const catalog = fs.readFileSync(catalogPath, "utf8");
assert.match(catalog, /\| pushed-draft \|/);
assert.match(catalog, /`new-media-id-12345678901234567890`/);
assert.match(catalog, /回执见 `publish\/v2\/audit\.log`/);
assert.match(catalog, /原 media_id `old-media-id-12345678901234567890` 已失效并更新/);

const finalResult = finalizePushResultBacklink({ pushResultPath, auditPath, catalogUpdate: update });
assert.equal(finalResult.evidence.audit_path, auditPath);
assert.equal(finalResult.evidence.push_result_path, pushResultPath);
assert.equal(finalResult.backlink.catalog_slug, "contract-test");

const second = updateCatalogAfterPush({
catalogPath,
slug: "contract-test",
mediaId: "new-media-id-12345678901234567890",
account: "XINZHE",
sourcePath,
auditPath,
});
assert.equal(second.changed, false);

assert.throws(
() => inspectCatalogTarget({ catalogPath, slug: "missing" }),
/matched 0 rows/,
);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}

console.log("Publish evidence contract passed.");
111 changes: 111 additions & 0 deletions harness/test-reconcile-wechat-drafts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
applyReconciliationRepairs,
buildReconciliationReport,
} from "../scripts/reconcile_wechat_drafts.mjs";

const workerSource = fs.readFileSync(
new URL("../scripts/lib/wechat-reconcile-worker.mjs", import.meta.url),
"utf8",
);
assert.match(workerSource, /await fetch\(/);
assert.doesNotMatch(workerSource, /execFileSync|WECHAT_.*APP_SECRET.*console/);

const catalog = [
"# Article Catalog",
"",
"| Date | Slug | Title | Status | Draftbox | WeChat media_id | Notes |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| 2026-07-16 | `symbiotic-writing` | 花了一天跟 AI 讨论代码,我们碰撞出了灵感 | pushed-draft | 已推送新褶草稿箱 | `id-symbiotic` | audit |",
"| 2026-07-16 | `weknora-agent-programming` | 当 LLM 开始写代码——Agent 编程的 10 条安全底线 | pushed-draft | 已推送新褶草稿箱 | `id-agent` | audit |",
"| 2026-07-16 | `weknora-taste` | WeKnora 代码里的四种品味 | pushed-draft | 已推送新褶草稿箱 | `id-taste` | audit |",
"| 2026-07-16 | `weknora-rag-deepdive` | 手把手拆一个生产级 RAG 系统 | pushed-draft | 已推送新褶草稿箱 | `id-rag` | audit |",
"| 2026-07-07 | `older-cataloged` | Older Cataloged Draft | pushed-draft | 已推送草稿箱 | `id-older` | audit |",
"| 2026-07-16 | `already-published` | Published Draft | published | 已发布 | `id-published` | manual |",
"",
].join("\n");

const snapshot = {
account: "XINZHE",
queried_at: "2026-07-17T12:00:00.000Z",
draft_total: 7,
freepublish: { available: false, errcode: 48001 },
drafts: [
{ media_id: "id-symbiotic", title: "花了一天跟AI讨论代码,我们碰撞出了灵感", thumb_media_id: "thumb-ok" },
{ media_id: "id-agent", title: "当LLM开始写代码——Agent编程的10条安全底线", thumb_media_id: "" },
{ media_id: "id-taste", title: "WeKnora代码里的四种品味", thumb_media_id: "" },
{ media_id: "id-rag", title: "手把手拆一个生产级RAG系统", thumb_media_id: "" },
{ media_id: "id-older", title: "Older Cataloged Draft", thumb_media_id: "thumb-old" },
{ media_id: "external-1", title: "External One", thumb_media_id: "thumb-e1" },
{ media_id: "external-2", title: "External Two", thumb_media_id: "thumb-e2" },
],
};

const report = buildReconciliationReport({
catalogMarkdown: catalog,
catalogPath: "/tmp/CATALOG.md",
snapshot,
account: "XINZHE",
date: "2026-07-16",
});

assert.equal(report.summary.matched, 4);
assert.equal(report.summary.stale_ids, 0);
assert.equal(report.summary.invalid_ids, 0);
assert.equal(report.summary.absent_from_draftbox, 0);
assert.equal(report.summary.uncataloged_drafts, 2);
assert.equal(report.summary.cover_missing_selected, 3);
assert.equal(report.publication_inference.allowed, false);
assert.equal(report.live_snapshot.freepublish.errcode, 48001);

const driftedCatalog = catalog.replace("`id-taste`", "`old-id-taste`");
const staleReport = buildReconciliationReport({
catalogMarkdown: driftedCatalog,
snapshot,
account: "XINZHE",
slugs: ["weknora-taste", "already-published"],
});
assert.equal(staleReport.summary.stale_ids, 1);
assert.equal(staleReport.stale_ids[0].replacement_media_id, "id-taste");
assert.equal(staleReport.absent_from_draftbox.length, 0);

const absentReport = buildReconciliationReport({
catalogMarkdown: catalog.replace("| published |", "| pushed-draft |"),
snapshot,
account: "XINZHE",
slugs: ["already-published"],
});
assert.equal(absentReport.summary.absent_from_draftbox, 1);
assert.equal(absentReport.absent_from_draftbox[0].state, "published-or-deleted");
assert.equal(absentReport.absent_from_draftbox[0].publication_assertion, "forbidden");

const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "md2wechat-reconcile-write-"));
try {
const catalogPath = path.join(tmpRoot, "CATALOG.md");
const articleDir = path.join(tmpRoot, "articles", "2026", "2026-07-16-weknora-taste");
const auditPath = path.join(articleDir, "publish", "v1", "audit.log");
fs.mkdirSync(path.dirname(auditPath), { recursive: true });
fs.writeFileSync(catalogPath, driftedCatalog, "utf8");
fs.writeFileSync(auditPath, "media_id: id-taste\n", "utf8");
const writeReport = buildReconciliationReport({
catalogMarkdown: driftedCatalog,
catalogPath,
snapshot,
account: "XINZHE",
slugs: ["weknora-taste"],
});
const repairs = applyReconciliationRepairs({ report: writeReport, catalogPath, account: "XINZHE" });
assert.equal(repairs.length, 1);
const repairedCatalog = fs.readFileSync(catalogPath, "utf8");
assert.match(repairedCatalog, /`id-taste`/);
assert.match(repairedCatalog, /原 media_id `old-id-taste` 已失效并更新/);
assert.match(repairedCatalog, /回执见 `publish\/v1\/audit\.log`/);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}

console.log("WeChat draft reconciliation contract passed.");
Loading
Loading