diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 66eddd5..be07606 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -2649,6 +2649,7 @@ export function createWechatMpService({ bareCompletionReply, htmlGenerationNeedsRetry, replyHasPublicLinks, + topic: wechatIntent?.topic ?? intent?.agentText ?? '', }); if (pageOutcome.action === 'session_retry') { throw new Error('stale_session_poisoned_completion'); @@ -2967,6 +2968,7 @@ export function createWechatMpService({ bareCompletionReply, htmlGenerationNeedsRetry, replyHasPublicLinks, + topic: wechatIntent?.topic ?? intent?.agentText ?? '', }); 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 d07546f..c873449 100644 --- a/wechat/handlers/page-generate.mjs +++ b/wechat/handlers/page-generate.mjs @@ -1,12 +1,28 @@ import { buildPagePublishFailureText } from '../prompts/page-generate.mjs'; import { selectSendableHtmlArtifacts, verifyPageArtifactContent } from '../verify/page-artifact.mjs'; +import { repairArtifactSharePreview } from '../verify/share-preview-repair.mjs'; import { filterSharePreviewReadyArtifacts, verifyArtifactSharePreview } from '../verify/share-preview.mjs'; -export function evaluatePageGenerateSendableArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) { +export function evaluatePageGenerateSendableArtifacts({ + verifiedArtifacts = [], + confirmedArtifacts = [], + repairContext = {}, +} = {}) { const sendable = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }); const verified = sendable.filter((artifact) => verifyPageArtifactContent(artifact).ok); const candidates = verified.length > 0 ? verified : sendable; - return filterSharePreviewReadyArtifacts(candidates); + const ready = filterSharePreviewReadyArtifacts(candidates); + if (ready.length > 0) return ready; + + const repaired = []; + for (const artifact of candidates) { + if (!verifyPageArtifactContent(artifact).ok) continue; + const result = repairArtifactSharePreview(artifact, repairContext); + if (result.ok && verifyArtifactSharePreview(artifact).ok) { + repaired.push(artifact); + } + } + return repaired; } /** @@ -21,8 +37,18 @@ export function resolvePageGenerateOutcome({ bareCompletionReply = false, htmlGenerationNeedsRetry = false, replyHasPublicLinks = false, + topic = '', + repairContext = {}, }) { - const sendable = evaluatePageGenerateSendableArtifacts({ verifiedArtifacts, confirmedArtifacts }); + const context = { + topic: String(topic || repairContext.topic || '').trim(), + ...repairContext, + }; + const sendable = evaluatePageGenerateSendableArtifacts({ + verifiedArtifacts, + confirmedArtifacts, + repairContext: context, + }); if (sendable.length > 0) { return { action: 'send', artifacts: sendable }; diff --git a/wechat/verify/share-preview-repair.mjs b/wechat/verify/share-preview-repair.mjs new file mode 100644 index 0000000..efec84d --- /dev/null +++ b/wechat/verify/share-preview-repair.mjs @@ -0,0 +1,107 @@ +import fs from 'node:fs'; +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 { + hasMindspaceCoverMeta, + hasPlatformBrandMarker, + hasShareDescription, + verifyArtifactSharePreview, + verifySharePreviewMeta, +} from './share-preview.mjs'; + +function escapeMetaAttribute(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<'); +} + +export function extractPageTitle(html) { + const match = String(html ?? '').match(/]*>([\s\S]*?)<\/title>/i); + return match?.[1]?.replace(/<[^>]+>/g, '').trim() ?? ''; +} + +export function inferCoverMeta({ title = '', topic = '' } = {}) { + const source = `${title} ${topic}`.trim(); + let tag = '报告'; + let emoji = '📄'; + let accent = '#667eea'; + let accent2 = '#764ba2'; + if (/攻略|旅行|游记|半日|地图|景点|路线/.test(source)) { + tag = '旅行'; + emoji = '🏞️'; + accent = '#2e7d32'; + accent2 = '#1565c0'; + } else if (/新闻|早报|热点|资讯|日报/.test(source)) { + tag = '资讯'; + emoji = '📰'; + accent = '#2563eb'; + accent2 = '#0f172a'; + } else if (/诗|诗词|散文|文学/.test(source)) { + tag = '文艺'; + emoji = '🌿'; + accent = '#2d5016'; + accent2 = '#1a2a3a'; + } + const subtitle = (title || topic || 'MindSpace 页面').trim().slice(0, 80); + return normalizeCoverMetaSuggestion({ tag, emoji, accent, accent2, subtitle, cover: '' }); +} + +export function repairSharePreviewHtml(html, { title = '', topic = '' } = {}) { + let next = String(html ?? ''); + const resolvedTitle = String(title || extractPageTitle(next) || '').trim(); + const summary = (topic || resolvedTitle || 'MindSpace 页面').trim().slice(0, 160); + + if (!hasShareDescription(next)) { + const meta = ``; + next = injectBeforeDocumentClosingHead(next, `${meta}\n`); + } + + if (!hasMindspaceCoverMeta(next)) { + next = upsertMindspaceCoverMeta(next, inferCoverMeta({ title: resolvedTitle, topic })); + } + + if (!hasPlatformBrandMarker(next)) { + next = preparePublishedPlatformBrand(next); + } + + return next; +} + +export function repairArtifactSharePreview(artifact, { topic = '' } = {}) { + if (!artifactFileExists(artifact)) { + return { ok: false, reason: 'missing_file', changes: [] }; + } + if (!verifyPageArtifactContent(artifact).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) { + return { ok: true, reason: null, changes: [], alreadyReady: true }; + } + + const title = extractPageTitle(before); + const after = repairSharePreviewHtml(before, { title, topic }); + if (after === before) { + return { ok: false, reason: 'repair_noop', changes: [] }; + } + + const changes = []; + if (!hasShareDescription(before) && hasShareDescription(after)) changes.push('description'); + if (!hasMindspaceCoverMeta(before) && hasMindspaceCoverMeta(after)) changes.push('mindspace_cover'); + if (!hasPlatformBrandMarker(before) && hasPlatformBrandMarker(after)) changes.push('platform_brand'); + + fs.writeFileSync(localPath, after, 'utf8'); + const verified = verifyArtifactSharePreview(artifact); + if (!verified.ok) { + return { ok: false, reason: verified.reason ?? 'repair_incomplete', changes }; + } + return { ok: true, reason: null, changes }; +} diff --git a/wechat/wechat-channel.test.mjs b/wechat/wechat-channel.test.mjs index 4d47dc6..b01b04b 100644 --- a/wechat/wechat-channel.test.mjs +++ b/wechat/wechat-channel.test.mjs @@ -22,6 +22,7 @@ import { verifyArtifactSharePreview, verifySharePreviewMeta, } from './verify/share-preview.mjs'; +import { repairSharePreviewHtml } from './verify/share-preview-repair.mjs'; test('classifyWechatIntent detects page.generate', () => { const intent = classifyWechatIntent({ @@ -205,20 +206,30 @@ test('verifySharePreviewMeta requires mindspace-cover, description, and platform assert.equal(hasPlatformBrandMarker(withAll), true); }); -test('resolvePageGenerateOutcome fails when html lacks share preview meta', () => { +test('repairSharePreviewHtml injects missing description, cover, and platform brand', () => { + const html = `安吉德清半日攻略
${'内容'.repeat(200)}
`; + const repaired = repairSharePreviewHtml(html, { topic: '帮我把这个安吉攻略生成页面' }); + assert.equal(verifySharePreviewMeta(repaired).ok, true); + assert.match(repaired, /name="description"/); + assert.match(repaired, /name="mindspace-cover"/); + assert.match(repaired, /data-mindspace-page-tag="platform-brand"/); +}); + +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'); writePreviewReadyHtml(file, { withCover: false }); const outcome = resolvePageGenerateOutcome({ reply: { text: '页面已生成 https://m.tkmind.cn/MindSpace/u/public/page.html' }, confirmedArtifacts: [{ localPath: file, relativePath: 'public/page.html' }], + topic: '安吉攻略页面', }); - assert.equal(outcome.action, 'fail'); - assert.equal(outcome.reason, 'missing_mindspace_cover'); - assert.match(outcome.failureText, /预览元数据/); + assert.equal(outcome.action, 'send'); + assert.equal(outcome.artifacts.length, 1); + assert.equal(verifyArtifactSharePreview({ localPath: file }).ok, true); }); -test('resolvePageGenerateOutcome fails when html lacks platform brand marker', () => { +test('resolvePageGenerateOutcome repairs and sends when html lacks platform brand marker', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-brand-')); const file = path.join(dir, 'page.html'); writePreviewReadyHtml(file, { withBrand: false }); @@ -226,9 +237,9 @@ test('resolvePageGenerateOutcome fails when html lacks platform brand marker', ( reply: { text: '页面已生成' }, confirmedArtifacts: [{ localPath: file, relativePath: 'public/page.html' }], }); - assert.equal(outcome.action, 'fail'); - assert.equal(outcome.reason, 'missing_platform_brand'); - assert.match(outcome.failureText, /平台品牌/); + assert.equal(outcome.action, 'send'); + assert.equal(outcome.artifacts.length, 1); + assert.equal(hasPlatformBrandMarker(fs.readFileSync(file, 'utf8')), true); }); test('resolvePageGenerateOutcome sends when share preview meta is present', () => {