Files
memind/news-item-templates.mjs
john 028bb18dc0 feat(news): add single news-item page generation and early page-ready wait
Scheduled tasks can finish once the expected HTML is on disk and stable,
instead of waiting for Goose Finish. Adds news-item templates and scripts
for generating a single-event page and committing it as a WeChat draft.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 10:07:32 +08:00

214 lines
9.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 单条新闻落地页的模板契约。
*
* 这份契约同时约束两端:Agent 按它生成 HTML,news-engine 的块解析器按它把 HTML
* 拆成可编辑的块。类名是两端唯一的接口,改动必须同步 lib/wechat-draft-blocks.mjs。
*/
export const NEWS_ITEM_VERSION = '2026-09-22-v4-human-voice';
export const NEWS_ITEM_MIN_IMAGES = 6;
export const NEWS_ITEM_MIN_SECTIONS = 5;
export const NEWS_ITEM_MIN_CHARS = 1200;
export const NEWS_ITEM_STYLES = Object.freeze({
'wechat-feature': {
id: 'wechat-feature',
label: '公众号深度稿',
description: '导语 + 可选过渡 + 5~7 个小节(长短可不一,每节配图)+ 结尾编辑观点',
minImages: NEWS_ITEM_MIN_IMAGES,
minSections: NEWS_ITEM_MIN_SECTIONS,
minChars: NEWS_ITEM_MIN_CHARS,
},
brief: {
id: 'brief',
label: '快讯图文',
description: '紧凑结构:导语 + 3~4 个小节,适合突发短讯',
minImages: 4,
minSections: 3,
minChars: 700,
},
});
const BRIEF_CATEGORIES = new Set(['快讯', '突发', 'breaking']);
export function resolveNewsItemStyle(newsItem = {}, env = process.env) {
const explicit = String(env.MEMIND_NEWS_ITEM_STYLE ?? '').trim();
if (explicit && NEWS_ITEM_STYLES[explicit]) return explicit;
const requested = String(newsItem.layoutStyle ?? '').trim();
if (requested && NEWS_ITEM_STYLES[requested]) return requested;
const category = String(newsItem.category ?? '').trim().toLowerCase();
if (BRIEF_CATEGORIES.has(category)) return 'brief';
return 'wechat-feature';
}
// ---------------------------------------------------------------------------
// 结构契约(写进 Agent 提示词,也是块解析器的依据)
// ---------------------------------------------------------------------------
const STRUCTURE_CONTRACT = `【HTML 结构硬约束|类名不可改,下游要按类名解析】
<article class="news-item-article" data-style="{{STYLE}}">
<header class="article-hero">
<img class="hero-img" src="assets/hero.webp" alt="封面描述">
<h1 class="article-title">标题</h1>
<p class="article-meta">来源 · 日期</p>
</header>
<p class="article-lede">导语:80~140 字。从一个具体细节、问题或场景切入,不要以「近日/据报道」起笔。</p>
<nav class="article-toc"><ol><li>可选:用一句话预告下文,不要用「本文看点」作标题</li></ol></nav>
<section class="article-section">
<h2>具体的小节标题(写清本节讲什么)</h2>
<p>正文段落</p>
<figure><img src="assets/s1.webp" alt="配图描述"><figcaption>图说</figcaption></figure>
</section>
… 重复 {{MIN_SECTIONS}}~7 个 article-section,段落数可 1~5 段不等 …
<section class="article-verdict">
<h2>与题材相关的具体问题(禁止用「怎么看」「总结」)</h2>
<p>2~3 段编辑判断,可出现「我们认为」「值得留意的是」。</p>
</section>
</article>`;
const IMAGE_RULES = `【配图硬约束】
1. 至少 {{MIN_IMAGES}} 张真实 <img>:hero 1 张 + 每个 article-section 各 1 张。
2. 先 load_skill → image-generation 生成配图,再 load_skill → static-page-publish 落盘。
3. hero 用 hero preset;小节配图用 inline_image preset。
4. 已有新闻原图可作为 hero 参考,但小节配图必须逐个生成,不允许复用同一张。
5. 禁止 SVG 占位图、禁止 data: URI、禁止出现没有配图的 article-section。
6. <meta name="mindspace-cover" content="..."> 必须与 hero-img 同源。`;
const WRITING_RULES = `【写作硬约束】
1. 正文纯文字不少于 {{MIN_CHARS}} 字(不含标签与空白)。
2. 每个 article-section 1~5 段均可,段落长短错落;不要写成「首先/其次/最后」的要点罗列。
3. 小节标题必须具体(写清这一节讲什么),禁止「背景」「影响」「怎么看」「总结」等空标题。
4. 只使用 <h1> <h2> <p> <img> <figure> <figcaption> <nav> <ol> <li> <strong> <a> <section> <header> <article>,
加粗只用 <strong>,链接只用 <a href>。不要用表格、不要用行内 style 之外的复杂布局。
5. 事实必须来自给定来源,不要编造数字、人名和时间。至少引用 1 处来源中的具体数字、时间或原话,用「据…报道」标注。
6. 全文保持一位编辑在说话的感觉,可用「我们认为」「值得留意的是」,避免百科全书口吻。`;
const ANTI_AI_RULES = `【去 AI 味|与结构同等重要】
1. 禁止出现:本文看点、一文读懂、深度解读、全面解析、综上所述、值得注意的是、不可否认、
随着…不断发展、在当今社会、引发广泛关注、不容忽视、从某种意义上说。
2. 禁止以「近日」「近期」「据报道」作为文章第一句。
3. article-toc 可以省略;若保留,nav 内不要出现「本文看点」字样,只用自然语言预告。
4. article-verdict 的 h2 必须是与新闻相关的具体问题(如「油价还会涨多久?」),不要用「怎么看」。
5. 标题要信息量具体,避免「深度解读 XXX」「一文看懂 XXX」。
6. 写完后默读一遍:如果像通稿汇总而非编辑评论,就重写导语和结尾。`;
function fill(template, style) {
const spec = NEWS_ITEM_STYLES[style] ?? NEWS_ITEM_STYLES['wechat-feature'];
return template
.replace(/\{\{STYLE\}\}/g, spec.id)
.replace(/\{\{MIN_IMAGES\}\}/g, String(spec.minImages))
.replace(/\{\{MIN_SECTIONS\}\}/g, String(spec.minSections))
.replace(/\{\{MIN_CHARS\}\}/g, String(spec.minChars));
}
/**
* 生成给 Agent 的任务描述。
*/
export function buildNewsItemGenerationTaskSpec(newsItem = {}, env = process.env) {
const style = resolveNewsItemStyle(newsItem, env);
const spec = NEWS_ITEM_STYLES[style];
const title = String(newsItem.canonicalTitle ?? newsItem.title ?? '').trim();
const sourceLines = [
title ? `标题:${title}` : '',
newsItem.category ? `栏目:${newsItem.category}` : '',
newsItem.snippet ? `摘要:${newsItem.snippet}` : '',
newsItem.url ? `原文链接:${newsItem.url}` : '',
newsItem.sourceDomain ? `来源站点:${newsItem.sourceDomain}` : '',
newsItem.image ? `新闻原图:${newsItem.image}` : '',
].filter(Boolean);
const voiceLine = String(newsItem.accountVoice ?? '').trim();
const voiceRules = voiceLine
? [
'【本号口吻】',
voiceLine,
'全文语气、节奏、用词须符合上述定位;仍须遵守下方去 AI 味规则,不得写成通稿汇总。',
'',
]
: [];
const taskSpec = [
`围绕下面这条新闻,写一篇可以直接发公众号的${spec.label}并生成静态页面。`,
`版式:${spec.description}`,
newsItem.accountLabel ? `发布账号:${newsItem.accountLabel}` : '',
'',
'【新闻素材】',
...sourceLines,
'',
...voiceRules,
fill(STRUCTURE_CONTRACT, style),
'',
fill(IMAGE_RULES, style),
'',
fill(WRITING_RULES, style),
'',
fill(ANTI_AI_RULES, style),
'',
`【交付校验】写完后自查:article-section ≥ ${spec.minSections} 个、<img> ≥ ${spec.minImages} 张、`,
`正文纯文字 ≥ ${spec.minChars} 字。不达标就继续补写补图,不要提前交付。`,
`模板版本:${NEWS_ITEM_VERSION}`,
].join('\n');
return { style, spec, taskSpec };
}
// ---------------------------------------------------------------------------
// 生成结果校验
// ---------------------------------------------------------------------------
export function countNewsItemImages(html) {
const matches = String(html ?? '').match(/<img\b[^>]*>/gi) ?? [];
return matches.filter((tag) => {
const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1] ?? '';
if (!src) return false;
if (/^data:/i.test(src)) return false;
return !/\.svg(\?|$)/i.test(src);
}).length;
}
export function countNewsItemSections(html) {
return (String(html ?? '').match(/class=["'][^"']*\barticle-section\b[^"']*["']/gi) ?? []).length;
}
export function countNewsItemTextChars(html) {
const scope = /<article\b[^>]*>([\s\S]*?)<\s*\/\s*article\s*>/i.exec(String(html ?? ''))?.[1]
?? String(html ?? '');
return scope
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
.replace(/<[^>]*>/g, '')
.replace(/&[a-z#0-9]+;/gi, '')
.replace(/\s+/g, '')
.length;
}
/**
* 生成阶段的质量闸门:Agent 交付的落地页是否达标。
* 注意这是「生成是否合格」,推送前还有一道基于编辑后块结构的闸门。
*/
export function validateNewsItemHtml(html, { style = 'wechat-feature' } = {}) {
const spec = NEWS_ITEM_STYLES[style] ?? NEWS_ITEM_STYLES['wechat-feature'];
const source = String(html ?? '');
const imageCount = countNewsItemImages(source);
const sectionCount = countNewsItemSections(source);
const charCount = countNewsItemTextChars(source);
const issues = [];
if (!source.trim()) issues.push('页面为空');
if (!/<h1\b/i.test(source)) issues.push('缺少 <h1> 标题');
if (imageCount < spec.minImages) issues.push(`配图不足(${imageCount}/${spec.minImages})`);
if (sectionCount < spec.minSections) issues.push(`正文小节不足(${sectionCount}/${spec.minSections})`);
if (charCount < spec.minChars) issues.push(`正文字数不足(${charCount}/${spec.minChars})`);
if (/<img\b[^>]*src=["'][^"']*\.svg/i.test(source)) issues.push('存在 SVG 占位图');
return {
ok: issues.length === 0,
issues,
style: spec.id,
imageCount,
sectionCount,
charCount,
version: NEWS_ITEM_VERSION,
};
}