diff --git a/mindspace-public-finish-sync.mjs b/mindspace-public-finish-sync.mjs index d1aa670..42e8b5e 100644 --- a/mindspace-public-finish-sync.mjs +++ b/mindspace-public-finish-sync.mjs @@ -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 { diff --git a/mindspace-wechat-html-delivery.mjs b/mindspace-wechat-html-delivery.mjs index 87984a4..1d0b11c 100644 --- a/mindspace-wechat-html-delivery.mjs +++ b/mindspace-wechat-html-delivery.mjs @@ -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 }, ); } } diff --git a/mindspace-wechat-html-delivery.test.mjs b/mindspace-wechat-html-delivery.test.mjs index 37b1d00..73121fe 100644 --- a/mindspace-wechat-html-delivery.test.mjs +++ b/mindspace-wechat-html-delivery.test.mjs @@ -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( diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index 46a71da..4ff7c3a 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -105,6 +105,7 @@ export async function bootstrapPortalIntegrationServices({ config: wechatMpConfig, userAuth, sessionAccess, + h5Root, htmlDeliveryAuthority: typeof mindSpacePublicFinish ?.prepareWechatHtmlDelivery === diff --git a/wechat-mp.mjs b/wechat-mp.mjs index be07606..c77747d 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -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(); diff --git a/wechat/handlers/page-generate.mjs b/wechat/handlers/page-generate.mjs index c873449..339d3c2 100644 --- a/wechat/handlers/page-generate.mjs +++ b/wechat/handlers/page-generate.mjs @@ -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', diff --git a/wechat/verify/generated-thumbnail.mjs b/wechat/verify/generated-thumbnail.mjs index 9f02e78..edd9de4 100644 --- a/wechat/verify/generated-thumbnail.mjs +++ b/wechat/verify/generated-thumbnail.mjs @@ -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 }; } diff --git a/wechat/verify/page-artifact.mjs b/wechat/verify/page-artifact.mjs index 9db1f97..74ac5ef 100644 --- a/wechat/verify/page-artifact.mjs +++ b/wechat/verify/page-artifact.mjs @@ -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' }; } diff --git a/wechat/verify/share-preview-repair.mjs b/wechat/verify/share-preview-repair.mjs index efec84d..5d48351 100644 --- a/wechat/verify/share-preview-repair.mjs +++ b/wechat/verify/share-preview-repair.mjs @@ -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(/