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
+3 -1
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import { extractStaticPageLinks, materializePrivateAssetsInPublicHtmlFiles } from './mindspace-chat-save.mjs';
import { DOWNLOADABLE_FILE_PATTERN } from './mindspace-html-download-links.mjs';
import { scheduleWorkspaceHtmlThumbnailSidecars } from './mindspace-workspace-thumbnails.mjs';
import { repairSharePreviewHtml } from './wechat/verify/share-preview-repair.mjs';
/**
* Public HTML finish-sync invariants (regression guard — do not simplify away).
@@ -397,7 +398,8 @@ export function materializeMissingPublicHtmlWrites({ messages, publishDir }) {
}
try {
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.writeFileSync(destination, artifact.content, 'utf8');
const preparedContent = repairSharePreviewHtml(String(artifact.content ?? ''), {});
fs.writeFileSync(destination, preparedContent, 'utf8');
materialized.push(artifact.relativePath);
scheduleWorkspaceHtmlThumbnailSidecars(root, artifact.relativePath);
} catch {
+25 -5
View File
@@ -17,6 +17,9 @@ import {
import {
verifySharePreviewMeta,
} from './wechat/verify/share-preview.mjs';
import {
ensureArtifactsSharePreviewReady,
} from './wechat/verify/share-preview-repair.mjs';
const PUBLIC_HTML_LINK_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
@@ -397,11 +400,26 @@ export function prepareWechatHtmlDeliveryAtWorkspace({
requestStartedAt,
})
: [];
const confirmedArtifacts = uniqueArtifacts([
...publishedArtifacts,
...expectedArtifacts,
...recentArtifacts,
]);
const confirmedArtifacts = ensureArtifactsSharePreviewReady(
uniqueArtifacts([
...publishedArtifacts,
...expectedArtifacts,
...recentArtifacts,
]),
{
publishDir,
topic: String(
intent?.agentText ?? intent?.displayText ?? '',
).trim(),
},
).map(
(artifact) =>
readArtifactDescriptor(artifact.relativePath, {
publishDir,
buildCanonicalUrl,
copyIntoPublic: false,
}) ?? artifact,
);
const linkedFilenames =
collectLinkedHtmlFilenames(reply?.text);
const matchedArtifacts =
@@ -610,6 +628,7 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({
verifyFreshWechatPageThumbnails(
workspaceArtifacts,
images,
{ publishDir },
);
let repair = null;
if (
@@ -638,6 +657,7 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({
verifyFreshWechatPageThumbnails(
workspaceArtifacts,
images,
{ publishDir },
);
}
}
+11 -12
View File
@@ -81,17 +81,16 @@ test('MindSpace prepares WeChat HTML delivery with logical artifacts only', (t)
},
});
assert.equal(
fs.readFileSync(
path.join(
publishDir,
'public',
'hello.html',
),
'utf8',
const written = fs.readFileSync(
path.join(
publishDir,
'public',
'hello.html',
),
content,
'utf8',
);
assert.match(written, /name="description"/);
assert.match(written, /data-mindspace-page-tag="platform-brand"/);
assert.equal(
result.confirmedArtifacts.length,
1,
@@ -107,12 +106,12 @@ test('MindSpace prepares WeChat HTML delivery with logical artifacts only', (t)
isStub: false,
isHtmlDocument: true,
sharePreview: {
ok: false,
reason: 'missing_description',
ok: true,
reason: null,
},
auxiliary: false,
sizeBytes:
Buffer.byteLength(content),
Buffer.byteLength(written),
},
);
assert.equal(
@@ -105,6 +105,7 @@ export async function bootstrapPortalIntegrationServices({
config: wechatMpConfig,
userAuth,
sessionAccess,
h5Root,
htmlDeliveryAuthority:
typeof mindSpacePublicFinish
?.prepareWechatHtmlDelivery ===
+13
View File
@@ -61,6 +61,7 @@ import {
collectWechatGeneratedImages,
} from './wechat/verify/generated-thumbnail.mjs';
import { resolveBillingTokenState } from './billing-token-state.mjs';
import { resolveMindSpaceUserPublishDir } from './mindspace-runtime-config.mjs';
import {
buildPageDataCollectFailureText,
} from './mindspace-page-data-finish-guard.mjs';
@@ -1503,6 +1504,7 @@ export function createWechatMpService({
pageDataDeliveryReviewer = null,
wechatFetch = undiciFetch,
linkExists = defaultPublicHtmlLinkExists,
h5Root = '',
logger = console,
}) {
const sessionStore = resolveSessionAccess({ userAuth, sessionAccess });
@@ -1513,6 +1515,11 @@ export function createWechatMpService({
}
const requireFreshPageThumbnail = config.requireFreshPageThumbnail === true;
const repairFreshPageThumbnail = config.repairFreshPageThumbnail === true;
const resolveUserPublishDir = (userId) => {
const normalizedUserId = String(userId ?? '').trim();
if (!normalizedUserId || !String(h5Root ?? '').trim()) return '';
return resolveMindSpaceUserPublishDir(h5Root, { id: normalizedUserId });
};
config = {
...loadWechatMpConfig({}),
...config,
@@ -2650,6 +2657,9 @@ export function createWechatMpService({
htmlGenerationNeedsRetry,
replyHasPublicLinks,
topic: wechatIntent?.topic ?? intent?.agentText ?? '',
repairContext: {
publishDir: resolveUserPublishDir(user.userId),
},
});
if (pageOutcome.action === 'session_retry') {
throw new Error('stale_session_poisoned_completion');
@@ -2969,6 +2979,9 @@ export function createWechatMpService({
htmlGenerationNeedsRetry,
replyHasPublicLinks,
topic: wechatIntent?.topic ?? intent?.agentText ?? '',
repairContext: {
publishDir: resolveUserPublishDir(user.userId),
},
});
if (pageOutcome.action === 'session_retry' || pageOutcome.action === 'fail') {
const text = pageOutcome.failureText ?? buildPagePublishFailureText();
+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');