feat(wechat): require share preview meta before page link delivery
Block service-account page links when HTML lacks mindspace-cover and description so WeChat forwards always get TKMind branding and thumbnail. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
import { buildPagePublishFailureText } from '../prompts/page-generate.mjs';
|
import { buildPagePublishFailureText } from '../prompts/page-generate.mjs';
|
||||||
import { selectSendableHtmlArtifacts, verifyPageArtifactContent } from '../verify/page-artifact.mjs';
|
import { selectSendableHtmlArtifacts, verifyPageArtifactContent } from '../verify/page-artifact.mjs';
|
||||||
|
import { filterSharePreviewReadyArtifacts } from '../verify/share-preview.mjs';
|
||||||
|
|
||||||
export function evaluatePageGenerateSendableArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) {
|
export function evaluatePageGenerateSendableArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) {
|
||||||
const sendable = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
const sendable = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
||||||
const verified = sendable.filter((artifact) => verifyPageArtifactContent(artifact).ok);
|
const verified = sendable.filter((artifact) => verifyPageArtifactContent(artifact).ok);
|
||||||
return verified.length > 0 ? verified : sendable;
|
const candidates = verified.length > 0 ? verified : sendable;
|
||||||
|
return filterSharePreviewReadyArtifacts(candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +44,17 @@ export function resolvePageGenerateOutcome({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previewCandidates = filterSharePreviewReadyArtifacts(
|
||||||
|
selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }),
|
||||||
|
);
|
||||||
|
if (previewCandidates.length === 0 && confirmedArtifacts.length > 0) {
|
||||||
|
return {
|
||||||
|
action: 'fail',
|
||||||
|
failureText: buildPagePublishFailureText({ missingSharePreview: true }),
|
||||||
|
reason: 'missing_share_preview',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!String(reply?.text ?? '').trim() && sendable.length === 0) {
|
if (!String(reply?.text ?? '').trim() && sendable.length === 0) {
|
||||||
return {
|
return {
|
||||||
action: 'fail',
|
action: 'fail',
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {})
|
|||||||
'2. 阅读技能说明后,用 sandbox-fs 的 `write_file` 或 `edit_file` 写入 `public/*.html`。',
|
'2. 阅读技能说明后,用 sandbox-fs 的 `write_file` 或 `edit_file` 写入 `public/*.html`。',
|
||||||
'3. 禁止 shell/cat/heredoc/cp 写 HTML(不会出现在公网 MindSpace)。',
|
'3. 禁止 shell/cat/heredoc/cp 写 HTML(不会出现在公网 MindSpace)。',
|
||||||
'4. 写完后确认目标文件已落盘,且内容是用户要的完整页面(不是占位 stub)。',
|
'4. 写完后确认目标文件已落盘,且内容是用户要的完整页面(不是占位 stub)。',
|
||||||
'5. 回复里只给一个正式域名链接;没有落盘就不要发链接。',
|
'5. 在 `<head>` 写入微信分享卡片所需元数据(必须):',
|
||||||
|
' - `<meta name="description" content="一句话摘要">`',
|
||||||
|
' - `<meta name="mindspace-cover" content=\'{"tag":"主题","accent":"#主色","accent2":"#辅色","subtitle":"卖点","cover":"assets/hero.jpg"}\'>`',
|
||||||
|
' tag/accent/subtitle 必须与页面主题一致;有 hero 图时 cover 指向同目录 raster 图(jpg/png)。',
|
||||||
|
'6. 页脚加 `<footer data-mindspace-page-tag="platform-brand">` 以显示 TKMind 智趣 品牌。',
|
||||||
|
'7. 回复里只给一个正式域名链接;没有落盘或缺少上述元数据就不要发链接。',
|
||||||
'',
|
'',
|
||||||
buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }),
|
buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }),
|
||||||
`用户需求:${topic}`,
|
`用户需求:${topic}`,
|
||||||
@@ -33,7 +38,13 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {})
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPagePublishFailureText() {
|
export function buildPagePublishFailureText({ missingSharePreview = false } = {}) {
|
||||||
|
if (missingSharePreview) {
|
||||||
|
return [
|
||||||
|
'这次页面已落盘,但缺少微信分享卡片所需的预览元数据(mindspace-cover 与摘要),所以我先不发链接。',
|
||||||
|
'请直接重发一次完整页面需求,我会重新按 static-page-publish 技能生成带预览图的页面。',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'这次页面没有按服务号页面技能真正生成成功,所以我先不发占位链接。',
|
'这次页面没有按服务号页面技能真正生成成功,所以我先不发占位链接。',
|
||||||
'请直接重发一次完整页面需求,我会重新按 static-page-publish 技能链路生成。',
|
'请直接重发一次完整页面需求,我会重新按 static-page-publish 技能链路生成。',
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import { artifactFileExists } from './page-artifact.mjs';
|
||||||
|
|
||||||
|
const RASTER_IMAGE_PATTERN = /\.(png|jpe?g|webp|gif)(?:[?#]|$)/i;
|
||||||
|
|
||||||
|
export function hasMindspaceCoverMeta(html) {
|
||||||
|
return /<meta[^>]*name=["']mindspace-cover["']/i.test(String(html ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasShareDescription(html) {
|
||||||
|
const source = String(html ?? '');
|
||||||
|
return (
|
||||||
|
/<meta[^>]+property=["']og:description["']/i.test(source) ||
|
||||||
|
/<meta[^>]+name=["']description["']/i.test(source)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRasterShareImageHint(html) {
|
||||||
|
const source = String(html ?? '');
|
||||||
|
if (/<meta[^>]+property=["']og:image["'][^>]+content=["'][^"']+/i.test(source)) {
|
||||||
|
const match =
|
||||||
|
source.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i) ??
|
||||||
|
source.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
|
||||||
|
const url = match?.[1] ?? '';
|
||||||
|
if (url && !/\.svg(?:[?#]|$)/i.test(url)) return true;
|
||||||
|
}
|
||||||
|
const coverMatch = source.match(/<meta[^>]*name=["']mindspace-cover["'][^>]*content=(["'])([\s\S]*?)\1/i);
|
||||||
|
if (!coverMatch?.[2]) return false;
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(coverMatch[2].replaceAll('"', '"'));
|
||||||
|
const cover = String(meta.cover ?? meta.image ?? '').trim();
|
||||||
|
if (!cover) return false;
|
||||||
|
if (/^https?:\/\//i.test(cover)) return RASTER_IMAGE_PATTERN.test(cover);
|
||||||
|
return RASTER_IMAGE_PATTERN.test(cover) || !/\.svg(?:[?#]|$)/i.test(cover);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WeChat link cards need mindspace-cover (for thumbnail.png) + description.
|
||||||
|
* Visual pages should also reference a raster hero in cover/image when possible.
|
||||||
|
*/
|
||||||
|
export function verifySharePreviewMeta(html) {
|
||||||
|
if (!hasMindspaceCoverMeta(html)) {
|
||||||
|
return { ok: false, reason: 'missing_mindspace_cover' };
|
||||||
|
}
|
||||||
|
if (!hasShareDescription(html)) {
|
||||||
|
return { ok: false, reason: 'missing_description' };
|
||||||
|
}
|
||||||
|
return { ok: true, reason: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyArtifactSharePreview(artifact) {
|
||||||
|
if (!artifactFileExists(artifact)) {
|
||||||
|
return { ok: false, reason: 'missing_file' };
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(artifact.localPath, 'utf8');
|
||||||
|
return verifySharePreviewMeta(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSharePreviewReadyArtifacts(artifacts = []) {
|
||||||
|
return artifacts.filter((artifact) => verifyArtifactSharePreview(artifact).ok);
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@ import { guardScheduleConfirmationReply, looksLikeScheduleConfirmation } from '.
|
|||||||
import { resolvePageGenerateOutcome } from './handlers/page-generate.mjs';
|
import { resolvePageGenerateOutcome } from './handlers/page-generate.mjs';
|
||||||
import { buildGreetingText, resolveSyncReply } from './handlers/sync-replies.mjs';
|
import { buildGreetingText, resolveSyncReply } from './handlers/sync-replies.mjs';
|
||||||
import { buildWechatAgentPrompt } from './prompts/chat-general.mjs';
|
import { buildWechatAgentPrompt } from './prompts/chat-general.mjs';
|
||||||
|
import {
|
||||||
|
hasMindspaceCoverMeta,
|
||||||
|
hasShareDescription,
|
||||||
|
verifyArtifactSharePreview,
|
||||||
|
verifySharePreviewMeta,
|
||||||
|
} from './verify/share-preview.mjs';
|
||||||
|
|
||||||
test('classifyWechatIntent detects page.generate', () => {
|
test('classifyWechatIntent detects page.generate', () => {
|
||||||
const intent = classifyWechatIntent({
|
const intent = classifyWechatIntent({
|
||||||
@@ -113,3 +119,52 @@ test('verifyPageArtifactContent rejects small stub files', () => {
|
|||||||
assert.equal(verifyPageArtifactContent(artifact).ok, false);
|
assert.equal(verifyPageArtifactContent(artifact).ok, false);
|
||||||
assert.equal(filterSendableHtmlArtifacts([artifact]).length, 0);
|
assert.equal(filterSendableHtmlArtifacts([artifact]).length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function writePreviewReadyHtml(file, { withCover = true } = {}) {
|
||||||
|
const coverMeta = withCover
|
||||||
|
? '<meta name="mindspace-cover" content=\'{"tag":"旅行","accent":"#ff6b35","accent2":"#24243e","subtitle":"测试页面"}\'>'
|
||||||
|
: '';
|
||||||
|
fs.writeFileSync(
|
||||||
|
file,
|
||||||
|
`<!doctype html><html><head>
|
||||||
|
<meta name="description" content="一句话摘要">
|
||||||
|
${coverMeta}
|
||||||
|
</head><body><main>${'x'.repeat(600)}</main></body></html>`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('verifySharePreviewMeta requires mindspace-cover and description', () => {
|
||||||
|
const withBoth = '<meta name="description" content="摘要"><meta name="mindspace-cover" content=\'{"tag":"旅行"}\'>';
|
||||||
|
assert.equal(verifySharePreviewMeta(withBoth).ok, true);
|
||||||
|
assert.equal(verifySharePreviewMeta('<meta name="description" content="摘要">').ok, false);
|
||||||
|
assert.equal(hasMindspaceCoverMeta(withBoth), true);
|
||||||
|
assert.equal(hasShareDescription(withBoth), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolvePageGenerateOutcome fails 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' }],
|
||||||
|
});
|
||||||
|
assert.equal(outcome.action, 'fail');
|
||||||
|
assert.equal(outcome.reason, 'missing_share_preview');
|
||||||
|
assert.match(outcome.failureText, /预览元数据/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolvePageGenerateOutcome sends when share preview meta is present', () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-preview-ok-'));
|
||||||
|
const file = path.join(dir, 'page.html');
|
||||||
|
writePreviewReadyHtml(file);
|
||||||
|
const artifact = { localPath: file, relativePath: 'public/page.html' };
|
||||||
|
assert.equal(verifyArtifactSharePreview(artifact).ok, true);
|
||||||
|
const outcome = resolvePageGenerateOutcome({
|
||||||
|
reply: { text: '页面已生成' },
|
||||||
|
confirmedArtifacts: [artifact],
|
||||||
|
});
|
||||||
|
assert.equal(outcome.action, 'send');
|
||||||
|
assert.equal(outcome.artifacts.length, 1);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user