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
1 change: 1 addition & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ node ${PIPELINE_HOME}/scripts/orchestrator.mjs \
```

⚠️ 发布优先用 Orchestrator,不要手动拆 render/bundle/push。`--digest` 不传时会读取 frontmatter `summary` 并传到 relay;`--qr` 必须传绝对路径,避免 footer QR 在 render/preflight 间被拼成错误相对路径。
正文不要再手工插入同一张二维码。Orchestrator 检测到 Markdown 已引用 `--qr` 或 `FOOTER_QR_PATH` 指向的文件时会直接阻断,避免草稿中出现两张相同二维码。

## Step 1:渲染

Expand Down
63 changes: 63 additions & 0 deletions harness/test-bundle-image-fallback.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env node
// 验证 bundle 的 extractImagePaths 在 publish/vN/assets/ 找不到图时 fallback 到源 assets/
// 治理 D12 的回归测试:篇11/篇13 踩过的顺序 bug
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { extractImagePaths } from "../scripts/bundle_wechat_article.mjs";

const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "md2wechat-bundle-fallback-"));

try {
// 模拟文章目录结构:
// <tmp>/article.md
// <tmp>/assets/body-img.png ← 源 assets(图真实位置)
// <tmp>/publish/v1/article.html ← render 输出位置
// <tmp>/publish/v1/assets/ ← 空(bundle 还没拷图)
const articleDir = tmpRoot;
const sourceAssetsDir = path.join(articleDir, "assets");
const publishV1Dir = path.join(articleDir, "publish", "v1");
const publishAssetsDir = path.join(publishV1Dir, "assets");

fs.mkdirSync(sourceAssetsDir, { recursive: true });
fs.mkdirSync(publishAssetsDir, { recursive: true });
fs.writeFileSync(path.join(sourceAssetsDir, "body-img.png"), "fake-png");
fs.writeFileSync(path.join(articleDir, "article.md"), "# test\n![body](assets/body-img.png)\n");

// render 输出的 HTML 引用相对路径 assets/body-img.png
// 在 publish/v1/assets/ 里找不到(bundle 还没拷)
const html = '<img src="assets/body-img.png" alt="body">';
const htmlDir = publishV1Dir;

// ① 不传 sourceAssetsDir(旧行为):resolved 指向 publish/v1/assets/body-img.png,找不到
const oldResult = extractImagePaths(html, htmlDir);
assert.equal(oldResult.length, 1, "old: should find 1 image reference");
assert.equal(fs.existsSync(oldResult[0].resolved), false, "old: resolved path should not exist (bug)");

// ② 传 sourceAssetsDir(新行为):fallback 到源 assets/body-img.png,找到
const newResult = extractImagePaths(html, htmlDir, sourceAssetsDir);
assert.equal(newResult.length, 1, "new: should find 1 image reference");
assert.equal(fs.existsSync(newResult[0].resolved), true, "new: resolved should fallback to source assets and exist");
assert.equal(newResult[0].basename, "body-img.png", "new: basename preserved");

// ③ publish/v1/assets/ 已有图(bundle 后状态):不 fallback,用 publish 路径
fs.copyFileSync(path.join(sourceAssetsDir, "body-img.png"), path.join(publishAssetsDir, "body-img.png"));
const postBundleResult = extractImagePaths(html, htmlDir, sourceAssetsDir);
assert.equal(postBundleResult[0].resolved, path.join(publishAssetsDir, "body-img.png"), "post-bundle: should use publish path when exists, not source fallback");

// ④ 绝对路径不走 fallback(只对相对路径 fallback)
const absHtml = `<img src="${path.join(sourceAssetsDir, "body-img.png")}" alt="body">`;
const absResult = extractImagePaths(absHtml, htmlDir, sourceAssetsDir);
assert.equal(absResult.length, 1, "abs: absolute path still resolved");
assert.equal(fs.existsSync(absResult[0].resolved), true, "abs: absolute path exists");

// ⑤ http(s) / data URI 跳过
const remoteHtml = '<img src="https://example.com/x.png"><img src="data:image/png;base64,abc">';
const remoteResult = extractImagePaths(remoteHtml, htmlDir, sourceAssetsDir);
assert.equal(remoteResult.length, 0, "remote: http/data URIs should be skipped");

console.log("test-bundle-image-fallback: ok");
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
40 changes: 40 additions & 0 deletions harness/test-orchestrator-command-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
buildManualRelayCommand,
buildRelayDeploymentCheckCommand,
extractSummaryFromMarkdown,
findDuplicateFooterQrReferences,
resolvePipelinePaths,
runRelayDeploymentCheck,
runPublishDoctor,
} from "../scripts/orchestrator.mjs";

const result = buildManualRelayCommand({
Expand Down Expand Up @@ -83,6 +85,7 @@ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "md2wechat-orchestrator-")
try {
const articleDir = path.join(tmpRoot, "article");
const articlePath = path.join(articleDir, "enterprise-ai-carrier.md");
const assetDir = path.join(articleDir, "assets");
fs.mkdirSync(articleDir, { recursive: true });
fs.writeFileSync(articlePath, "# Enterprise AI Carrier\n", "utf8");

Expand Down Expand Up @@ -110,6 +113,43 @@ try {
assert.equal(plain.archiveDir, articleDir);
assert.equal(plain.outDir, explicitPlain);
assert.equal(plain.renderOut, path.join(articleDir, "enterprise-ai-carrier.html"));

fs.mkdirSync(assetDir, { recursive: true });
const qrPath = path.join(assetDir, "ai-world-qr.jpg");
fs.writeFileSync(qrPath, "qr", "utf8");
fs.writeFileSync(articlePath, "![AI 大世界](assets/ai-world-qr.jpg)\n", "utf8");
assert.deepEqual(
findDuplicateFooterQrReferences({
inputPath: articlePath,
envPath: path.join(tmpRoot, ".env"),
footerQrPath: qrPath,
}).map(({ source }) => source),
["assets/ai-world-qr.jpg"],
);

fs.writeFileSync(articlePath, "![外部二维码](https://example.com/ai-world-qr.jpg)\n", "utf8");
assert.deepEqual(
findDuplicateFooterQrReferences({
inputPath: articlePath,
envPath: path.join(tmpRoot, ".env"),
footerQrPath: qrPath,
}),
[],
);

fs.writeFileSync(articlePath, "![AI 大世界](assets/ai-world-qr.jpg)\n", "utf8");
const envPath = path.join(tmpRoot, ".env");
fs.writeFileSync(envPath, "WECHAT_TEST_APP_ID=appid\nWECHAT_TEST_APP_SECRET=secret\n", "utf8");
const doctor = runPublishDoctor({
inputPath: articlePath,
envPath,
account: "test",
autoPush: false,
dryRun: true,
thumbImage: "",
qrPath,
});
assert.ok(doctor.errors.some((error) => error.startsWith("footer QR would be inserted twice:")));
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
Expand Down
39 changes: 30 additions & 9 deletions scripts/bundle_wechat_article.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,24 @@ Behavior:
}

// ── 从 HTML 提取所有图片路径 ──
function extractImagePaths(html, baseDir) {
export function extractImagePaths(html, baseDir, sourceAssetsDir) {
// sourceAssetsDir: article 源目录的 assets/(用于 bundle 前 fallback)
const srcRegex = /<img[^>]+src=["']([^"']+)["']/gi;
const paths = [];
const regex = /src=["']([^"']+)["']/g;
let m;
while ((m = regex.exec(html)) !== null) {
while ((m = srcRegex.exec(html)) !== null) {
const src = m[1];
// 跳过 URL 和 data URI
if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) {
continue;
}
if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) continue;
const resolved = path.isAbsolute(src) ? src : path.resolve(baseDir, src);
paths.push({ original: src, resolved, basename: path.basename(src) });
// bundle 前图可能还在源 assets/(render 不拷图)。fallback 到源目录。
let finalResolved = resolved;
if (!fs.existsSync(resolved) && sourceAssetsDir) {
const sourceCandidate = path.join(sourceAssetsDir, path.basename(src));
if (fs.existsSync(sourceCandidate)) {
finalResolved = sourceCandidate;
}
}
paths.push({ original: src, resolved: finalResolved, basename: path.basename(src) });
}
return paths;
}
Expand Down Expand Up @@ -96,8 +102,23 @@ function main() {
const html = fs.readFileSync(htmlPath, "utf8");
const htmlDir = path.dirname(htmlPath);

// 推算源 article 目录的 assets/(render 不拷图,bundle 前 fallback 用)
// 约定:htmlPath 在 <article-dir>/publish/vN/article.html,源 assets 在 <article-dir>/assets
// 向上找最多 3 层,直到找到 article.md 同级 assets 目录
let sourceAssetsDir = null;
let probe = htmlDir;
for (let i = 0; i < 4; i++) {
const candidate = path.join(probe, "assets");
const siblingMd = fs.existsSync(path.join(probe, "article.md"));
if (siblingMd && fs.existsSync(candidate)) {
sourceAssetsDir = candidate;
break;
}
probe = path.dirname(probe);
}

// 提取图片路径
const images = extractImagePaths(html, htmlDir);
const images = extractImagePaths(html, htmlDir, sourceAssetsDir);

// 去重(按 resolved 路径)
const seen = new Set();
Expand Down
93 changes: 87 additions & 6 deletions scripts/orchestrator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,45 @@ export function resolvePipelinePaths({ inputPath, outDirArg = "" }) {
};
}

function runPublishDoctor({ inputPath, envPath, account, autoPush, dryRun, thumbImage, qrPath }) {
function extractMarkdownImageSources(markdown = "") {
const sources = [];
const imagePattern = /!\[[^\]]*\]\((?:<([^>]+)>|([^\s)]+))(?:\s+[^)]*)?\)/g;
let match;
while ((match = imagePattern.exec(markdown)) !== null) {
const source = String(match[1] || match[2] || "").trim();
if (source) sources.push(source);
}
return sources;
}

function realPathIfPresent(candidatePath) {
try {
return fs.realpathSync(candidatePath);
} catch {
return null;
}
}

export function findDuplicateFooterQrReferences({ inputPath, envPath, footerQrPath }) {
if (!inputPath || !footerQrPath || !fs.existsSync(inputPath)) return [];

const footerCandidates = [
footerQrPath,
path.resolve(path.dirname(envPath), footerQrPath),
]
.map(realPathIfPresent)
.filter(Boolean);
if (footerCandidates.length === 0) return [];

const sourceDir = path.dirname(inputPath);
const markdown = fs.readFileSync(inputPath, "utf8");
return extractMarkdownImageSources(markdown)
.filter((source) => !/^(?:https?:|data:)/i.test(source))
.map((source) => ({ source, realPath: realPathIfPresent(path.resolve(sourceDir, source)) }))
.filter(({ realPath }) => realPath && footerCandidates.includes(realPath));
}

export function runPublishDoctor({ inputPath, envPath, account, autoPush, dryRun, thumbImage, qrPath }) {
const errors = [];
const warnings = [];

Expand All @@ -236,6 +274,18 @@ function runPublishDoctor({ inputPath, envPath, account, autoPush, dryRun, thumb
if (!/(^|[\\/_.-])qr([\\/_.-]|$)/i.test(path.basename(footerQr))) {
warnings.push(`footer QR filename does not include "qr"; older preflight versions may not detect it: ${path.basename(footerQr)}`);
}
const duplicateQrReferences = findDuplicateFooterQrReferences({
inputPath,
envPath,
footerQrPath: footerQr,
});
if (duplicateQrReferences.length > 0) {
errors.push(
`footer QR would be inserted twice: Markdown already embeds ${duplicateQrReferences
.map(({ source }) => source)
.join(", ")}. Remove the Markdown image before using --qr or FOOTER_QR_PATH.`,
);
}
} else {
warnings.push("no --qr or FOOTER_QR_PATH configured; CTA footer may be absent");
}
Expand Down Expand Up @@ -481,12 +531,43 @@ class AutoHeal {
break;
}
case "image_size": {
const paths = f.details?.paths || this.extractImagePathsFromReport(f, htmlPath);
const fixed = this.fixOversizedImages(paths);
if (fixed) {
this.fixesApplied.push("images_compressed");
// image_size 失败有两种性质不同的原因:
// 1. exists:true && size>max —— 真·图超大,需要压缩
// 2. exists:false —— bundle 前的顺序误报(图在源 assets/,bundle 会拷过来)
// 第二种和 local_path_absence/image_cdn_count_match 的 bundle 前豁免同构。
// 不区分就把第二种当失败,会阻断正常流程(篇11/篇13 两次踩坑)。
const details = Array.isArray(f.details) ? f.details : (f.details?.oversized || []);
const sourceAssetsDir = path.join(path.dirname(this.mdPath), "assets");
const trulyMissing = [];
const oversizeOnly = [];
for (const d of details) {
if (d && d.exists === false) {
// 去 source assets 目录找;找到说明是 bundle 前误报,豁免
const basename = path.basename(d.path);
const sourceCandidate = path.join(sourceAssetsDir, basename);
if (!fs.existsSync(sourceCandidate)) {
trulyMissing.push(d.path);
}
// 找到则静默豁免(bundle 会拷过来)
} else if (d && d.exists === true) {
oversizeOnly.push(d.path);
}
}
let fixed = false;
if (oversizeOnly.length > 0) {
fixed = this.fixOversizedImages(oversizeOnly);
if (fixed) this.fixesApplied.push("images_compressed");
}
if (trulyMissing.length > 0) {
warn(`AutoHeal: image_size has ${trulyMissing.length} truly missing image(s): ${trulyMissing.join(", ")}`);
this.unhandledFailures.push(f.id);
} else if (!fixed && oversizeOnly.length === 0) {
// 全部是 bundle 前误报,豁免
info(`AutoHeal: image_size skipped — ${details.length} image(s) not yet bundled but exist in source assets, bundle will copy them`);
} else if (fixed) {
needsReRender = true;
} else {
} else if (oversizeOnly.length > 0) {
// 有真超大但压缩失败
this.unhandledFailures.push(f.id);
}
break;
Expand Down
Loading