fix(wechat): repair share preview on disk for sanitized delivery artifacts
Memind CI / Test, build, and release guards (push) Failing after 6s
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:
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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('<', '<');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user