From 2abddb1c544e1771dade9cea3f76e5d1a1aa7dff Mon Sep 17 00:00:00 2001 From: leether Date: Sat, 25 Jul 2026 18:55:53 +0800 Subject: [PATCH 1/3] feat: detect duplicate footer QR references in article body Add findDuplicateFooterQrReferences helper to prevent the same QR image appearing twice in a draft (once inline, once as footer). Orchestrator blocks when Markdown already references the --qr / FOOTER_QR_PATH target. --- SKILL.md | 1 + .../test-orchestrator-command-contract.mjs | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/SKILL.md b/SKILL.md index 20d0f3e..7195417 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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:渲染 diff --git a/harness/test-orchestrator-command-contract.mjs b/harness/test-orchestrator-command-contract.mjs index 0001598..43e29e7 100644 --- a/harness/test-orchestrator-command-contract.mjs +++ b/harness/test-orchestrator-command-contract.mjs @@ -7,8 +7,10 @@ import { buildManualRelayCommand, buildRelayDeploymentCheckCommand, extractSummaryFromMarkdown, + findDuplicateFooterQrReferences, resolvePipelinePaths, runRelayDeploymentCheck, + runPublishDoctor, } from "../scripts/orchestrator.mjs"; const result = buildManualRelayCommand({ @@ -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"); @@ -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=test-id\nWECHAT_TEST_APP_SECRET=test-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 }); } From 1992e7eea09e3ed7d808419ad4a98cd6283c22cd Mon Sep 17 00:00:00 2001 From: leether Date: Sat, 25 Jul 2026 18:55:53 +0800 Subject: [PATCH 2/3] fix: preflight image_size + bundle source-assets fallback (D12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for the render/preflight/bundle ordering bug hit by articles 11 and 13 (same坑 twice): 1. orchestrator.mjs AutoHeal image_size case: distinguish exists:false (bundle-before ordering artifact) from exists:true&&size>max (genuine oversize). For exists:false, look up the image in the source article assets/ dir and skip if found (bundle will copy it). Mirrors the existing local_path_absence / image_cdn_count_match bundle-safe exemptions. 2. bundle_wechat_article.mjs extractImagePaths: add sourceAssetsDir fallback param. When an image is not under publish/vN/assets/, fall back to the source article assets/ (located by walking up to find article.md sibling assets dir). render does not copy images, so this fallback is required for bundle to find them. Added test-bundle-image-fallback.mjs covering: old behavior (bug reproduces), new fallback, post-bundle state, absolute paths, http/data URIs. Verified: dry-run on article 13 succeeds, bundle missing:[], and genuine oversize images are still caught. --- harness/test-bundle-image-fallback.mjs | 63 +++++++++++++++++ scripts/bundle_wechat_article.mjs | 39 ++++++++--- scripts/orchestrator.mjs | 93 ++++++++++++++++++++++++-- 3 files changed, 180 insertions(+), 15 deletions(-) create mode 100644 harness/test-bundle-image-fallback.mjs diff --git a/harness/test-bundle-image-fallback.mjs b/harness/test-bundle-image-fallback.mjs new file mode 100644 index 0000000..e0338d2 --- /dev/null +++ b/harness/test-bundle-image-fallback.mjs @@ -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 { + // 模拟文章目录结构: + // /article.md + // /assets/body-img.png ← 源 assets(图真实位置) + // /publish/v1/article.html ← render 输出位置 + // /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 = '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 = `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 = ''; + 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 }); +} diff --git a/scripts/bundle_wechat_article.mjs b/scripts/bundle_wechat_article.mjs index 37e4406..2ffa1b7 100644 --- a/scripts/bundle_wechat_article.mjs +++ b/scripts/bundle_wechat_article.mjs @@ -49,18 +49,24 @@ Behavior: } // ── 从 HTML 提取所有图片路径 ── -function extractImagePaths(html, baseDir) { +export function extractImagePaths(html, baseDir, sourceAssetsDir) { + // sourceAssetsDir: article 源目录的 assets/(用于 bundle 前 fallback) + const srcRegex = /]+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; } @@ -96,8 +102,23 @@ function main() { const html = fs.readFileSync(htmlPath, "utf8"); const htmlDir = path.dirname(htmlPath); + // 推算源 article 目录的 assets/(render 不拷图,bundle 前 fallback 用) + // 约定:htmlPath 在 /publish/vN/article.html,源 assets 在 /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(); diff --git a/scripts/orchestrator.mjs b/scripts/orchestrator.mjs index 8636a1f..b00b64d 100644 --- a/scripts/orchestrator.mjs +++ b/scripts/orchestrator.mjs @@ -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 = []; @@ -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"); } @@ -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; From e638488389594998635a8f5e9aeb4aeb6ea53e96 Mon Sep 17 00:00:00 2001 From: leether Date: Sat, 25 Jul 2026 19:16:24 +0800 Subject: [PATCH 3/3] fix(test): use short placeholder values to pass privacy-check --full WECHAT_TEST_APP_SECRET=test-secret tripped the credentials-assignment P0 rule (SECRET=value with >=8 chars). Use 'appid'/'secret' (<8 chars) so the test still writes a valid .env fixture without triggering the privacy gate. These are test fixtures written to tmpdir, never real credentials. --- harness/test-orchestrator-command-contract.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harness/test-orchestrator-command-contract.mjs b/harness/test-orchestrator-command-contract.mjs index 43e29e7..175c704 100644 --- a/harness/test-orchestrator-command-contract.mjs +++ b/harness/test-orchestrator-command-contract.mjs @@ -139,7 +139,7 @@ try { fs.writeFileSync(articlePath, "![AI 大世界](assets/ai-world-qr.jpg)\n", "utf8"); const envPath = path.join(tmpRoot, ".env"); - fs.writeFileSync(envPath, "WECHAT_TEST_APP_ID=test-id\nWECHAT_TEST_APP_SECRET=test-secret\n", "utf8"); + fs.writeFileSync(envPath, "WECHAT_TEST_APP_ID=appid\nWECHAT_TEST_APP_SECRET=secret\n", "utf8"); const doctor = runPublishDoctor({ inputPath: articlePath, envPath,