028bb18dc0
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>
214 lines
9.5 KiB
JavaScript
214 lines
9.5 KiB
JavaScript
/**
|
||
* 单条新闻落地页的模板契约。
|
||
*
|
||
* 这份契约同时约束两端: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,
|
||
};
|
||
}
|