fix(wechat): repair share preview on disk for sanitized delivery artifacts
Memind CI / Test, build, and release guards (push) Failing after 6s

Production WeChat delivery strips localPath from artifacts, so share-preview
auto-repair never wrote back to HTML. Resolve paths via publishDir +
relativePath, repair at materialize and delivery prep, and pass h5Root from
Portal so page.generate can patch missing meta before sending links.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-02 08:47:51 +08:00
parent 0a71370bf4
commit 1faba62f9b
11 changed files with 238 additions and 75 deletions
+27 -7
View File
@@ -8,17 +8,25 @@ export function evaluatePageGenerateSendableArtifacts({
confirmedArtifacts = [],
repairContext = {},
} = {}) {
const sendable = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
const verified = sendable.filter((artifact) => verifyPageArtifactContent(artifact).ok);
const publishDir = String(repairContext.publishDir ?? '').trim();
const previewOptions = { publishDir };
const sendable = selectSendableHtmlArtifacts({
verifiedArtifacts,
confirmedArtifacts,
publishDir,
});
const verified = sendable.filter((artifact) =>
verifyPageArtifactContent(artifact, previewOptions).ok,
);
const candidates = verified.length > 0 ? verified : sendable;
const ready = filterSharePreviewReadyArtifacts(candidates);
const ready = filterSharePreviewReadyArtifacts(candidates, previewOptions);
if (ready.length > 0) return ready;
const repaired = [];
for (const artifact of candidates) {
if (!verifyPageArtifactContent(artifact).ok) continue;
if (!verifyPageArtifactContent(artifact, previewOptions).ok) continue;
const result = repairArtifactSharePreview(artifact, repairContext);
if (result.ok && verifyArtifactSharePreview(artifact).ok) {
if (result.ok && verifyArtifactSharePreview(artifact, previewOptions).ok) {
repaired.push(artifact);
}
}
@@ -42,8 +50,10 @@ export function resolvePageGenerateOutcome({
}) {
const context = {
topic: String(topic || repairContext.topic || '').trim(),
publishDir: String(repairContext.publishDir ?? '').trim(),
...repairContext,
};
const previewOptions = { publishDir: context.publishDir };
const sendable = evaluatePageGenerateSendableArtifacts({
verifiedArtifacts,
confirmedArtifacts,
@@ -71,11 +81,21 @@ export function resolvePageGenerateOutcome({
}
const previewCandidates = filterSharePreviewReadyArtifacts(
selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }),
selectSendableHtmlArtifacts({
verifiedArtifacts,
confirmedArtifacts,
publishDir: context.publishDir,
}),
previewOptions,
);
if (previewCandidates.length === 0 && confirmedArtifacts.length > 0) {
const previewIssue = verifyArtifactSharePreview(
selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts })[0] ?? confirmedArtifacts[0],
selectSendableHtmlArtifacts({
verifiedArtifacts,
confirmedArtifacts,
publishDir: context.publishDir,
})[0] ?? confirmedArtifacts[0],
previewOptions,
);
return {
action: 'fail',
+7 -2
View File
@@ -5,6 +5,7 @@ import {
parseMindspaceCoverMeta,
upsertMindspaceCoverMeta,
} from '../../mindspace-cover-meta.mjs';
import { resolveArtifactLocalPath } from './page-artifact.mjs';
const RASTER_MIME_PATTERN = /^image\/(?:png|jpeg|webp)$/i;
@@ -121,7 +122,11 @@ export function isWechatAuxiliaryPageArtifact(artifact) {
|| /<[^>]+data-mindspace-page-role=["'](?:admin|auxiliary)["']/i.test(html);
}
export function verifyFreshWechatPageThumbnails(artifacts = [], images = []) {
export function verifyFreshWechatPageThumbnails(
artifacts = [],
images = [],
{ publishDir = '' } = {},
) {
if (!Array.isArray(artifacts) || artifacts.length === 0) {
return { ok: false, reason: 'missing_page_artifact', matches: [] };
}
@@ -136,7 +141,7 @@ export function verifyFreshWechatPageThumbnails(artifacts = [], images = []) {
const usedJobs = new Set();
const matches = [];
for (const artifact of eligibleArtifacts) {
const localPath = String(artifact?.localPath ?? '').trim();
const localPath = resolveArtifactLocalPath(artifact, publishDir);
if (!localPath || !fs.existsSync(localPath)) {
return { ok: false, reason: 'missing_page_artifact', artifact, matches, skippedArtifacts };
}
+52 -24
View File
@@ -1,30 +1,50 @@
import fs from 'node:fs';
import path from 'node:path';
const STUB_MARKERS = ['临时补出', '服务号兜底', '服务号自动补出简版页面'];
export function resolveArtifactLocalPath(artifact, publishDir = '') {
const localPath = String(artifact?.localPath ?? '').trim();
if (localPath) return localPath;
const relativePath = String(artifact?.relativePath ?? '').trim();
const root = String(publishDir ?? '').trim();
if (!relativePath || !root) return '';
const resolved = path.resolve(root, relativePath);
const normalizedRoot = path.resolve(root);
if (
resolved !== normalizedRoot &&
!resolved.startsWith(`${normalizedRoot}${path.sep}`)
) {
return '';
}
return resolved;
}
export function isStubPublicHtmlContent(content) {
const value = String(content ?? '');
return STUB_MARKERS.some((marker) => value.includes(marker));
}
export function artifactFileExists(artifact) {
export function artifactFileExists(artifact, { publishDir = '' } = {}) {
const localPath = resolveArtifactLocalPath(artifact, publishDir);
if (localPath) {
try {
return fs.existsSync(localPath) && fs.statSync(localPath).isFile();
} catch {
return false;
}
}
if (typeof artifact?.exists === 'boolean') {
return artifact.exists;
}
const localPath = String(artifact?.localPath ?? '').trim();
if (!localPath) return false;
try {
return fs.existsSync(localPath) && fs.statSync(localPath).isFile();
} catch {
return false;
}
return false;
}
export function isStubPublicHtmlArtifact(artifact) {
export function isStubPublicHtmlArtifact(artifact, { publishDir = '' } = {}) {
if (typeof artifact?.isStub === 'boolean') {
return artifact.isStub;
}
const localPath = String(artifact?.localPath ?? '').trim();
const localPath = resolveArtifactLocalPath(artifact, publishDir);
if (!localPath) return false;
try {
return isStubPublicHtmlContent(fs.readFileSync(localPath, 'utf8'));
@@ -34,27 +54,35 @@ export function isStubPublicHtmlArtifact(artifact) {
}
/** Real HTML artifacts only — never fall back to stub placeholders. */
export function filterSendableHtmlArtifacts(artifacts = []) {
return artifacts.filter((artifact) => artifactFileExists(artifact) && !isStubPublicHtmlArtifact(artifact));
export function filterSendableHtmlArtifacts(artifacts = [], options = {}) {
return artifacts.filter(
(artifact) =>
artifactFileExists(artifact, options) &&
!isStubPublicHtmlArtifact(artifact, options),
);
}
export function selectSendableHtmlArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) {
export function selectSendableHtmlArtifacts({
verifiedArtifacts = [],
confirmedArtifacts = [],
publishDir = '',
} = {}) {
const options = { publishDir };
const candidates = verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts;
return filterSendableHtmlArtifacts(candidates);
return filterSendableHtmlArtifacts(candidates, options);
}
export function verifyPageArtifactContent(artifact, { minBytes = 512 } = {}) {
if (!artifactFileExists(artifact)) {
export function verifyPageArtifactContent(artifact, { minBytes = 512, publishDir = '' } = {}) {
const localPath = resolveArtifactLocalPath(artifact, publishDir);
const resolvedArtifact = localPath ? { ...artifact, localPath } : artifact;
if (!artifactFileExists(resolvedArtifact, { publishDir })) {
return { ok: false, reason: 'missing_file' };
}
if (isStubPublicHtmlArtifact(artifact)) {
if (isStubPublicHtmlArtifact(resolvedArtifact, { publishDir })) {
return { ok: false, reason: 'stub_placeholder' };
}
if (!String(artifact?.localPath ?? '').trim()) {
if (
Number(artifact?.sizeBytes ?? 0) <
minBytes
) {
if (!localPath) {
if (Number(artifact?.sizeBytes ?? 0) < minBytes) {
return {
ok: false,
reason: 'too_small',
@@ -68,11 +96,11 @@ export function verifyPageArtifactContent(artifact, { minBytes = 512 } = {}) {
}
return { ok: true, reason: null };
}
const size = fs.statSync(artifact.localPath).size;
const size = fs.statSync(localPath).size;
if (size < minBytes) {
return { ok: false, reason: 'too_small' };
}
const content = fs.readFileSync(artifact.localPath, 'utf8');
const content = fs.readFileSync(localPath, 'utf8');
if (!/<(?:html|body|main|article)\b/i.test(content)) {
return { ok: false, reason: 'not_html_document' };
}
+50 -6
View File
@@ -1,8 +1,13 @@
import fs from 'node:fs';
import path from 'node:path';
import { injectBeforeDocumentClosingHead } from '../../html-document-injection.mjs';
import { normalizeCoverMetaSuggestion, upsertMindspaceCoverMeta } from '../../mindspace-cover-meta.mjs';
import { preparePublishedPlatformBrand } from '../../mindspace-page-tag.mjs';
import { artifactFileExists, verifyPageArtifactContent } from './page-artifact.mjs';
import {
artifactFileExists,
resolveArtifactLocalPath,
verifyPageArtifactContent,
} from './page-artifact.mjs';
import {
hasMindspaceCoverMeta,
hasPlatformBrandMarker,
@@ -18,6 +23,8 @@ function escapeMetaAttribute(value) {
.replaceAll('<', '&lt;');
}
export { resolveArtifactLocalPath } from './page-artifact.mjs';
export function extractPageTitle(html) {
const match = String(html ?? '').match(/<title[^>]*>([\s\S]*?)<\/title>/i);
return match?.[1]?.replace(/<[^>]+>/g, '').trim() ?? '';
@@ -70,20 +77,35 @@ export function repairSharePreviewHtml(html, { title = '', topic = '' } = {}) {
return next;
}
export function repairArtifactSharePreview(artifact, { topic = '' } = {}) {
if (!artifactFileExists(artifact)) {
function syncArtifactSharePreview(artifact, localPath) {
if (!artifact || !localPath) return artifact;
try {
const content = fs.readFileSync(localPath, 'utf8');
artifact.sharePreview = verifySharePreviewMeta(content);
artifact.exists = true;
artifact.isHtmlDocument = /<(?:html|body|main|article)\b/i.test(content);
} catch {
// Keep the caller's existing artifact metadata when disk read fails.
}
return artifact;
}
export function repairArtifactSharePreview(artifact, { topic = '', publishDir = '' } = {}) {
const localPath = resolveArtifactLocalPath(artifact, publishDir);
const resolvedArtifact = localPath ? { ...artifact, localPath } : artifact;
if (!artifactFileExists(resolvedArtifact)) {
return { ok: false, reason: 'missing_file', changes: [] };
}
if (!verifyPageArtifactContent(artifact).ok) {
if (!verifyPageArtifactContent(resolvedArtifact).ok) {
return { ok: false, reason: 'not_repairable_artifact', changes: [] };
}
const localPath = String(artifact.localPath ?? '').trim();
if (!localPath) {
return { ok: false, reason: 'missing_local_path', changes: [] };
}
const before = fs.readFileSync(localPath, 'utf8');
if (verifySharePreviewMeta(before).ok) {
syncArtifactSharePreview(artifact, localPath);
return { ok: true, reason: null, changes: [], alreadyReady: true };
}
@@ -99,9 +121,31 @@ export function repairArtifactSharePreview(artifact, { topic = '' } = {}) {
if (!hasPlatformBrandMarker(before) && hasPlatformBrandMarker(after)) changes.push('platform_brand');
fs.writeFileSync(localPath, after, 'utf8');
const verified = verifyArtifactSharePreview(artifact);
syncArtifactSharePreview(artifact, localPath);
const verified = verifyArtifactSharePreview(artifact, { publishDir });
if (!verified.ok) {
return { ok: false, reason: verified.reason ?? 'repair_incomplete', changes };
}
return { ok: true, reason: null, changes };
}
export function ensureArtifactsSharePreviewReady(
artifacts = [],
{ topic = '', publishDir = '' } = {},
) {
const next = [];
for (const artifact of artifacts) {
if (!artifact) continue;
if (artifact.auxiliary || artifact.isStub) {
next.push(artifact);
continue;
}
if (artifact.sharePreview?.ok === true) {
next.push(artifact);
continue;
}
repairArtifactSharePreview(artifact, { topic, publishDir });
next.push(artifact);
}
return next;
}
+23 -18
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import { artifactFileExists } from './page-artifact.mjs';
import { resolveArtifactLocalPath } from './page-artifact.mjs';
import { hasPlatformBrandMarker } from '../../mindspace-page-tag.mjs';
export { hasPlatformBrandMarker };
@@ -57,26 +58,30 @@ export function verifySharePreviewMeta(html) {
return { ok: true, reason: null };
}
export function verifyArtifactSharePreview(artifact) {
if (!artifactFileExists(artifact)) {
return { ok: false, reason: 'missing_file' };
}
if (!String(artifact?.localPath ?? '').trim()) {
if (
typeof artifact?.sharePreview?.ok ===
'boolean'
) {
return artifact.sharePreview;
export function verifyArtifactSharePreview(artifact, { publishDir = '' } = {}) {
const localPath = resolveArtifactLocalPath(artifact, publishDir);
if (localPath) {
try {
if (fs.existsSync(localPath) && fs.statSync(localPath).isFile()) {
const content = fs.readFileSync(localPath, 'utf8');
return verifySharePreviewMeta(content);
}
} catch {
// Fall back to cached sharePreview metadata below.
}
return {
ok: false,
reason: 'missing_share_preview_evaluation',
};
}
const content = fs.readFileSync(artifact.localPath, 'utf8');
return verifySharePreviewMeta(content);
if (
typeof artifact?.sharePreview?.ok ===
'boolean'
) {
return artifact.sharePreview;
}
return {
ok: false,
reason: 'missing_file',
};
}
export function filterSharePreviewReadyArtifacts(artifacts = []) {
return artifacts.filter((artifact) => verifyArtifactSharePreview(artifact).ok);
export function filterSharePreviewReadyArtifacts(artifacts = [], options = {}) {
return artifacts.filter((artifact) => verifyArtifactSharePreview(artifact, options).ok);
}
+26
View File
@@ -215,6 +215,32 @@ test('repairSharePreviewHtml injects missing description, cover, and platform br
assert.match(repaired, /data-mindspace-page-tag="platform-brand"/);
});
test('resolvePageGenerateOutcome repairs sanitized artifact using publishDir + relativePath', () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-sanitized-'));
const file = path.join(publishDir, 'public', 'page.html');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
`<!doctype html><html><head><title>安吉攻略</title></head><body><main>${'x'.repeat(600)}</main></body></html>`,
'utf8',
);
const artifact = {
relativePath: 'public/page.html',
url: 'https://m.tkmind.cn/MindSpace/u/public/page.html',
exists: true,
isHtmlDocument: true,
sharePreview: { ok: false, reason: 'missing_mindspace_cover' },
};
const outcome = resolvePageGenerateOutcome({
reply: { text: '页面已生成' },
confirmedArtifacts: [artifact],
topic: '帮我把这个安吉攻略生成页面',
repairContext: { publishDir },
});
assert.equal(outcome.action, 'send');
assert.equal(verifyArtifactSharePreview(artifact, { publishDir }).ok, true);
});
test('resolvePageGenerateOutcome repairs and sends when html lacks share preview meta', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-preview-'));
const file = path.join(dir, 'page.html');