diff --git a/docs/news-morning-templates.md b/docs/news-morning-templates.md new file mode 100644 index 0000000..70c1e85 --- /dev/null +++ b/docs/news-morning-templates.md @@ -0,0 +1,118 @@ +# 每日新闻早报模板:news001 / news002 + +模板定义集中在 [`news-morning-templates.mjs`](../news-morning-templates.mjs)。 +早报页由 Agent 按 spec 生成 HTML,仓库里没有静态页模板文件,所以「模板」= 交给 Agent 的版式与内容规范。 + +| 模板 | 版本 | 状态 | 说明 | +|------|------|------|------| +| `news001` | `2026-09-18-refined` | frozen | 2026-09-18 定稿版式,已上生产。**只读,不允许就地修改**;要改版式请新开 `news003`。 | +| `news002` | `2026-09-21-subscriber` | active | 订阅向改版:事件唯一化、每条配图、报头版式、深色模式。 | + +## 切换模板 + +```bash +# 环境变量(默认 news001) +H5_WECHAT_NEWS_MORNING_TEMPLATE_ID=news002 +``` + +也可以写进 DB 配置 `h5_wechat_admin_config.news_morning_draft` 的 `templateId` 字段, +DB 值优先于环境变量。非法值一律回落到 `news001`。 + +## news001 的三个实测问题 + +以生产页 `daily-news-0918.html` 为样本(`node scripts/preview-news002-template.mjs` 旁的分析脚本可复现): + +1. **重复选题**。49 张新闻卡片里只有约 20 个独立事件。「王毅同鲁比奥通电话」出现 4 次, + 「东博会南宁开幕」5 次,「OpenAI 霍奇猜想」5 次。 + 根因是版式把「23 个栏目 × 固定条数」当硬指标,当天独立事件不够时 Agent 就换个标题再写一遍。 +2. **来源不可核验**。49 张卡片只引用了 **7 个 URL**,且全是 `finance.sina.com.cn/stock/cpbd/` + 这类栏目首页,不是文章永久链接。读者点进去看不到对应报道。 +3. **零配图**。整页 0 个 ``,信息密度高但没有视觉落点,移动端阅读疲劳。 + +## news002 的对应设计 + +### 1. 事件唯一化(结构性修复) + +- 栏目分 `core` / `flex` 两档。`core` 必出;`flex` 只有拿到**未被使用过的独立事件**才输出, + 没料就整节删除,连同导航锚点一起去掉。**是事件决定栏目,不是栏目决定事件。** +- 全页独立事件上限 `NEWS002_MAX_STORY_CARDS = 28`(news001 实际产出 49)。 +- 每张卡片必须带 `data-event-key="{事件指纹}"`,指纹 = 核心主体 + 核心动作, + 中英文报道共用同一指纹,全页不得重复。 + +### 2. 运行时去重闸门 + +prompt 层的「禁止重复」无法验证,所以在落盘后再做一次程序化清理: +[`wechat-news-morning-dedup.mjs`](../wechat-news-morning-dedup.mjs),由 worker 在生成成功后调用。 + +判重优先级: + +1. `data-event-key` 完全相同 +2. 来源 URL 归一化后相同 —— **仅当它是文章永久链接时**。 + `isArticlePermalink()` 会排除栏目首页/`index.*`,否则 news001 那种「7 个栏目首页」会把整页误删。 +3. 标题相似 —— `isSameNewsEvent()` 用字符二元组重合度(阈值 0.55)。 + 仓库既有的 `isDuplicateNewsMorningStory()` 按空格分词,对中文标题基本失效,所以这里另做一层。 + +`history` / `knowledge` 不参与判重;`weather` / `brief` / `deepdive` 不会被删空。 +闸门只删卡片和空栏目,**不改写正文**,DOM 保持 div 配平。 + +### 3. 每条新闻配图 + +链路:SearXNG `img_src`/`thumbnail` → `normalizeSearchResult()` 透传 `image`/`publishedAt` +→ 预取 brief 里以 `图:{URL}` 单独成行 → Agent 写进 `.card .media > img`。 + +- 有图:`loading="lazy"` + `referrerpolicy="no-referrer"` + `onerror` 降级为占位 +- 无图:`
{栏目 emoji}
`,版式不塌 +- 禁止张冠李戴、禁止编造图片地址、禁止整页长图 + +### 4. 视觉与排版 + +| 维度 | news001 | news002 | +|------|---------|---------| +| 底色 | `#f7fafc` 冷灰 | `#FAF7F2` 暖纸 | +| 主色 | `#b0191e` 正红 | `#C8362F` 墨红 + `#1B3A6B` 靛蓝 | +| 页头 | 240px 渐变大色块 | ~160px 报头(masthead),首屏直接见内容 | +| 标题字体 | 全黑体 | 标题衬线(Noto Serif SC),正文黑体 | +| 导航 | 25 个锚点横向滚动 | 8 个主锚点 + 吸顶 | +| 卡片 | 白底 + 左侧 3px 色条 | 图文混排,左侧 92px 4:3 缩略图 | +| 深色模式 | 无 | `prefers-color-scheme: dark` | + +CSS 固化在 `NEWS002_STYLE_BLOCK`,直接内嵌进 taskSpec,Agent 照抄即可,不依赖外部参照页。 + +> `.card .media` 的 `align-self:flex-start` 不能省。flex 默认 `stretch` 会把缩略图拉成整卡高度, +> `aspect-ratio` 失效 —— 这个坑在首版预览里踩过。 + +### 5. 内容结构 + +- **一分钟速读**:6~10 条一句话,给只有 30 秒的读者 +- **深读一条**:当天最重要的一件事,300~450 字 + 一句金句,做差异化 +- **每条卡片的「为什么重要」**:一句话说清跟读者的关系。说不清就不写这条 +- **订阅引导块**:放在往期回顾之前 + +## 微信草稿兼容 + +[`wechat-daily-news-inline.mjs`](../wechat-daily-news-inline.mjs) 已同时识别两版 DOM: + +| 元素 | news001 | news002 | +|------|---------|---------| +| 头条卡 | `.highlight-box*` | `a.lead-card` | +| 日期行 | `.date-badge` | `.masthead .meta` | +| 导语 | `.subtitle` | `.lede` | + +**已知缺口**:news002 的 per-card 配图目前不会上传到微信正文。 +`uploadDailyNewsBodyImagesForWechat()` 只处理页面里已有的 ``,而 inline 渲染的 +`renderListCard` / `renderFeatureCard` 不输出图片。公众号侧配图需要单独一轮改造。 + +## 预览与验证 + +```bash +# 渲染一张 news002 静态预览页(样例数据,占位图) +node scripts/preview-news002-template.mjs /tmp/news002-preview.html + +# 相关单测 +node --test news-morning-templates.test.mjs \ + wechat-news-morning-dedup.test.mjs \ + wechat-news-morning-draft.test.mjs \ + wechat-news-morning-draft-worker.test.mjs \ + wechat-news-morning-search.test.mjs \ + wechat-daily-news-inline.test.mjs +``` diff --git a/news-morning-templates.mjs b/news-morning-templates.mjs new file mode 100644 index 0000000..5aabc89 --- /dev/null +++ b/news-morning-templates.mjs @@ -0,0 +1,390 @@ +/** + * 每日新闻早报模板注册表。 + * + * news001:2026-09-18 定稿版式(固定 23 栏目 + 定额卡片 + 无配图),已冻结,只做兼容保留。 + * news002:订阅向改版(事件唯一化 + 每条配图 + 报头版式 + 深色模式)。 + * + * 本文件不得 import 其它业务模块,避免与 wechat-news-morning-draft.mjs 形成循环依赖。 + */ + +export const NEWS_MORNING_TEMPLATE_REFERENCE_URL = + 'https://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/daily-news-0918.html'; + +/* ------------------------------------------------------------------ * + * news001(冻结) + * ------------------------------------------------------------------ */ + +export const NEWS001_VERSION = '2026-09-18-refined'; + +export const NEWS001_SEO_SPEC = [ + '【SEO / GEO 必做】', + '1. ``:`每日新闻早报 · {YYYY年M月D日} · TKMind`。', + '2. `<meta name="description">`:80~160 字,含日期 + 当日 3~5 条核心标题关键词(国内/国际/财经/科技至少各 1 个)。', + '3. `<meta name="mindspace-geo">` JSON:summary(120~180 字)、keywords 数组(8~12 个:新闻/日期/国内/国际/财经/科技/体育/城市名/英文主题词)、faq 至少 3 组 q/a(分别覆盖国内、国际财经、科技体育)。', + '4. 每个 `.section-title` 内标题文字必须用 `<h2>` 包裹(保留 icon span),便于搜索引擎理解栏目层级。', + '5. 每条 card 的 `<h3>` 标题须含具体事件关键词,禁止「暂无」「未能获取」等空词。', + '6. footer 注明整理时间与主要来源域名,便于 GEO 引用。', +].join('\n'); + +/** 生产 daily-news-0918 定稿版式:div.section + 全渠道栏目 + SEO。 */ +export const NEWS001_TEMPLATE_SPEC = [ + '生成「每日新闻早报」静态 HTML 页面(daily-news-MMDD),版式必须与生产参照页完全一致(仅 HTML/CSS 结构,禁止复制其正文):', + `参照页:${NEWS_MORNING_TEMPLATE_REFERENCE_URL}`, + '写页前可先 read_file 打开同目录最近 daily-news-*.html 或上述 URL 对应文件,对照 CSS 与 DOM 骨架;正文条目必须全部来自当日检索。', + '1. 外层:`.wrap` > `.hero`(`.hero-content` 内 h1「📰 每日新闻早报」、`.date-badge`、`.subtitle` 一句主编导语)> `.container`;禁止 `.stats` 或「X条资讯 · Y个栏目」统计行。', + '2. quick-links 固定顺序:📰 要闻|🌤️ 天气|🌍 国际|🇨🇳 国内|💰 财经|🤖 科技|🏆 体育|🔎 Google|𝕏 X|🌏 亚太|⛽ 能源|🧠 AI芯片|⚖️ 政策|🎓 高校|📈 全球股市|💬 社区|🔬 科学|🎬 文娱|🚀 创投|₿ 加密|🛡️ 地缘|🏙️ 城市|📡 权威快讯|📚 小知识|📅 历史(href 对应下方 id);禁止 id=hot / 🔥 热搜。', + '3. 区块容器必须用 `<div class="section" id="...">`(禁止 `<section id>`);`.section-title` 含 `<span class="icon">` emoji,标题文字用 `<h2>` 包裹。', + '3b. 视觉精细化:`.card` 左侧 3px 色条(tag 色系);`.card.highlight-box` 渐变底+白字;`.quick-links` 横向滚动+浅灰底圆角;hero 保留渐变与 date-badge;整体留白充足、字号层级清晰(h1 28px / h2 18px / h3 16px / p 14px)。', + '4. 区块 id 与内容:', + ' - id=headlines:「今日要闻 · {完整日期}」;至少 3 条 `.card.highlight-box`(tag + h3 + p + source),标题 15~28 字、前 10 字有钩子。', + ' - id=weather:「今日天气 · {月日}」;`.weather-today` + `.weather-grid` 8~12 张 `.weather-card`;热门城市(北京、上海、广州、深圳、成都、杭州、西安、重庆、武汉、南京、厦门、三亚等),禁止用区域预报顶替城市卡。', + ' - id=world / domestic / finance / tech / sports:各 3~4 条 card。', + ' - id=google:「🔎 Google 热点 · Global Headlines」;2 条 card,标题可英文,p 用中文摘要。', + ' - id=x:「𝕏 X 平台热点 · Trending」;2 条 card,X/Twitter 热议或媒体转述。', + ' - id=asia_pacific:「🌏 亚太观察 · Asia-Pacific」;2 条 card,日韩台港/东盟视角。', + ' - id=commodities:「⛽ 能源大宗 · Commodities」;2 条 card,原油/黄金/铜/OPEC 等。', + ' - id=ai_chips:「🧠 AI & 芯片 · Tech Deep」;2 条 card,大模型/半导体/NVIDIA/出口管制等。', + ' - id=policy:「⚖️ 政策监管 · Policy Watch」;2 条 card,中外监管与重大政策。', + ' - id=universities:「🎓 全球高校 · Research & Campus」;2 条 card,高校科研/校园新闻,标题可英文。', + ' - id=global_markets:「📈 全球股市 · Markets」;2 条 card,美股/欧股/日股/港股等。', + ' - id=community:「💬 社区热议 · Reddit/HN」;2 条 card,Reddit/Hacker News 热议经媒体转述。', + ' - id=science:「🔬 科学前沿 · Science」;2 条 card,科研/太空/医学突破。', + ' - id=culture:「🎬 文娱动态 · Culture」;2 条 card,影视/音乐/游戏行业新闻(禁八卦绯闻)。', + ' - id=startup:「🚀 创投速递 · Startup」;2 条 card,融资/IPO/并购。', + ' - id=crypto:「₿ 加密货币 · Crypto」;0~2 条 card,仅当 BTC/ETH/监管有重大波动时写,否则可省略本 section。', + ' - id=geopolitics:「🛡️ 地缘军事 · Geopolitics」;2 条 card,冲突/制裁/外交/防务(与 id=world 去重)。', + ' - id=local_life:「🏙️ 城市动态 · Cities」;2 条 card,北上广深成渝杭西等主要城市民生/交通/政策。', + ' - id=official_feeds:「📡 权威快讯 · Wire Services」;2 条 card,Reuters/BBC/AP/NPR 等通讯社快讯,标题可英文。', + ' - id=knowledge:「📚 知识小卡片」;1~2 条短知识 card。', + ' - id=history:「📅 近7天新闻早报」;列出近 7 天 daily-news-MMDD.html 链接。', + '5. 每条 card:span.tag + h3 + p(2~4 句)+ div.source。', + '6. footer:新闻来源列表 + 整理时间 + TKMind 品牌;必须含 title、description、mindspace-cover、mindspace-geo;生成 daily-news-MMDD.thumbnail.png。', + '7. 推送微信草稿时转为内联 style 版式(微信不支持 <style> 标签),禁止整页长图;底部追加统一「阅读原文 + 二维码」layout;公众号侧 history/knowledge 可略短。', + '8. 英文栏目允许英文标题或中英并列,但每条必须有中文可读摘要,禁止整段纯英文正文。', + '9. 全页去重(必须):同一 URL、同一核心事件只允许出现一次;若已在 headlines/world/domestic/finance/tech 出现,扩展栏目不得重复同角度,可换角度或跳过;写页前合并预取+tkmind_search+web_search 后再去重。', +].join('\n'); + +/** 每日正文必须新搜,禁止把历史早报当新闻来源。 */ +export const NEWS001_FRESH_CONTENT_SPEC = [ + '内容硬约束(必须遵守,优先于版式):', + '1. 检索必须双路:先 load_skill → web,再同一轮调用 tkmind_search(type=news) 与 web_search,合并去重后写页;禁止只靠 web_search。', + '2. 覆盖面至少包括:国内、国际、财经、科技、体育、天气,以及 Google/X/亚太/能源/AI芯片/政策/高校/全球股市/社区/科学/文娱/创投/加密(有则写)/地缘/城市/权威快讯等扩展栏目;不得凭记忆编造;禁止单独「热搜/热门事件」栏目(原占位分给其它栏目)。', + '3. 若附有「专用联网搜索预取」,必须优先从中选题并写入对应栏目;预取有结果时禁止输出「未能获取」类空页。', + '4. 若 tkmind_search / web_search 暂时不可用,但预取 brief 已有条目:仍必须用预取结果写满各栏目,并在 source 标注预取来源 URL。', + '5. 仅当预取 brief 各路均为空且双路搜索均失败时,才允许写「检索暂不可用」说明页;禁止用历史 daily-news 正文充数。', + '6. 标题规则:中文标题 15~28 字;英文栏目标题可英文或中英并列;每条必须可核验并写来源 URL。', + '7. 扩展英文栏目:优先用预取 brief 对应分组,可保留英文标题,正文用 2~4 句中文摘要。', + '8. 全页去重:同一事件(如「美联储加息」「Fed rate hike」)只保留一条;主栏目优先,扩展栏目必须不同角度或跳过;禁止复制粘贴同段摘要。', + '9. 禁止复制、改写历史 daily-news-*.html 的正文;旧稿仅作 HTML 结构参考。', +].join('\n'); + +/* ------------------------------------------------------------------ * + * news002 + * ------------------------------------------------------------------ */ + +export const NEWS002_VERSION = '2026-09-21-subscriber'; + +/** + * news002 栏目表。 + * tier=core 的栏目每天必出;tier=flex 的栏目只有拿到「当天未被使用过的独立事件」时才输出, + * 拿不到就整节省略 —— 这是 news001 灌水重复的根因修复。 + */ +export const NEWS002_SECTIONS = [ + { id: 'brief', label: '一分钟速读', icon: '⚡', tier: 'core', min: 6, max: 10 }, + { id: 'headlines', label: '今日头条', icon: '📰', tier: 'core', min: 3, max: 4 }, + { id: 'deepdive', label: '深读一条', icon: '🔍', tier: 'core', min: 1, max: 1 }, + { id: 'domestic', label: '国内', icon: '🇨🇳', tier: 'core', min: 2, max: 4 }, + { id: 'world', label: '国际', icon: '🌍', tier: 'core', min: 2, max: 4 }, + { id: 'business', label: '财经·市场', icon: '💰', tier: 'core', min: 2, max: 4 }, + { id: 'tech', label: '科技·AI', icon: '🤖', tier: 'core', min: 2, max: 4 }, + { id: 'weather', label: '天气', icon: '🌤️', tier: 'core', min: 8, max: 12 }, + { id: 'living', label: '生活·城市', icon: '🏙️', tier: 'flex', min: 1, max: 3 }, + { id: 'science', label: '科学·健康', icon: '🔬', tier: 'flex', min: 1, max: 3 }, + { id: 'culture', label: '文娱·体育', icon: '🎬', tier: 'flex', min: 1, max: 3 }, + { id: 'voices', label: '观点·热议', icon: '💬', tier: 'flex', min: 1, max: 3 }, + { id: 'knowledge', label: '知识卡片', icon: '📚', tier: 'core', min: 1, max: 2 }, + { id: 'history', label: '往期回顾', icon: '📅', tier: 'core', min: 7, max: 7 }, +]; + +export const NEWS002_CORE_SECTION_IDS = NEWS002_SECTIONS + .filter((item) => item.tier === 'core') + .map((item) => item.id); + +export const NEWS002_FLEX_SECTION_IDS = NEWS002_SECTIONS + .filter((item) => item.tier === 'flex') + .map((item) => item.id); + +/** 全页独立事件卡上限;超过这个数就是在灌水,不是在做早报。 */ +export const NEWS002_MAX_STORY_CARDS = 28; + +/** news002 标准 <style>,直接照抄,禁止自创色值。 */ +export const NEWS002_STYLE_BLOCK = `<style> + *{margin:0;padding:0;box-sizing:border-box} + :root{ + --ink:#1C1B19; --ink-2:#4A4742; --ink-3:#8A857D; + --paper:#FAF7F2; --surface:#FFFFFF; --line:#E8E2D9; + --brand:#C8362F; --brand-soft:#FBEDEB; --accent:#1B3A6B; --accent-soft:#EAF0F8; + --serif:"Noto Serif SC","Source Han Serif SC","Songti SC",Georgia,serif; + --sans:-apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif; + } + @media (prefers-color-scheme:dark){ + :root{--ink:#F0EDE8;--ink-2:#B8B2A9;--ink-3:#807A72;--paper:#14130F;--surface:#1E1C18; + --line:#2E2B25;--brand:#E8635B;--brand-soft:#33201E;--accent:#7FA6DC;--accent-soft:#1B2433} + } + body{background:var(--paper);color:var(--ink);font-family:var(--sans);line-height:1.68; + -webkit-font-smoothing:antialiased;padding-bottom:40px} + .progress{position:fixed;top:0;left:0;height:3px;width:0;background:var(--brand);z-index:99} + /* 报头 */ + .masthead{max-width:720px;margin:0 auto;padding:26px 20px 14px;text-align:center} + .masthead .kicker{font-size:11px;letter-spacing:.32em;color:var(--brand);font-weight:700} + .masthead h1{font-family:var(--serif);font-size:32px;font-weight:700;letter-spacing:2px;margin:6px 0 10px} + .masthead .meta{display:flex;justify-content:center;gap:10px;flex-wrap:wrap; + font-size:12px;color:var(--ink-3);padding-top:10px;border-top:1px solid var(--line)} + .masthead .meta b{color:var(--ink-2);font-weight:600} + .lede{max-width:720px;margin:0 auto;padding:0 20px 14px;font-family:var(--serif); + font-size:15px;line-height:1.8;color:var(--ink-2);text-align:center} + /* 吸顶导航:只放 8 个主锚点 */ + .nav{position:sticky;top:0;z-index:50;background:color-mix(in srgb,var(--paper) 92%,transparent); + backdrop-filter:blur(12px);border-top:1px solid var(--line);border-bottom:1px solid var(--line)} + .nav-inner{max-width:720px;margin:0 auto;display:flex;gap:6px;overflow-x:auto; + padding:9px 16px;-webkit-overflow-scrolling:touch;scrollbar-width:none} + .nav-inner::-webkit-scrollbar{display:none} + .nav a{flex-shrink:0;padding:5px 12px;border-radius:999px;font-size:13px;font-weight:500; + color:var(--ink-2);text-decoration:none;white-space:nowrap} + .nav a:active{background:var(--brand-soft);color:var(--brand)} + .container{max-width:720px;margin:0 auto;padding:18px 16px} + .section{margin-bottom:32px;scroll-margin-top:56px} + .section-title{display:flex;align-items:baseline;gap:8px;margin-bottom:14px; + padding-bottom:9px;border-bottom:2px solid var(--ink)} + .section-title .icon{font-size:17px} + .section-title h2{font-family:var(--serif);font-size:19px;font-weight:700;letter-spacing:.5px} + .section-title .count{margin-left:auto;font-size:11px;color:var(--ink-3);letter-spacing:.1em} + /* 一分钟速读 */ + .brief{background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:6px 16px} + .brief li{list-style:none;display:flex;gap:10px;padding:11px 0; + border-bottom:1px dashed var(--line);font-size:14.5px;line-height:1.6} + .brief li:last-child{border-bottom:0} + .brief .n{flex-shrink:0;width:19px;height:19px;border-radius:5px;background:var(--brand); + color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center; + justify-content:center;margin-top:3px} + .brief a{color:inherit;text-decoration:none;border-bottom:1px solid var(--line)} + /* 头条大图卡 */ + .lead-card{display:block;background:var(--surface);border:1px solid var(--line); + border-radius:14px;overflow:hidden;margin-bottom:12px;text-decoration:none;color:inherit} + .lead-card .media{position:relative;aspect-ratio:16/9;background:var(--accent-soft)} + .lead-card .media img{width:100%;height:100%;object-fit:cover;display:block} + .lead-card .body{padding:14px 16px 16px} + .lead-card h3{font-family:var(--serif);font-size:19px;font-weight:700;line-height:1.45;margin-bottom:7px} + .lead-card p{font-size:14px;color:var(--ink-2)} + /* 图文混排卡 */ + .card{display:flex;gap:13px;background:var(--surface);border:1px solid var(--line); + border-radius:12px;padding:13px;margin-bottom:10px} + /* align-self 不能省:flex 默认 stretch 会把缩略图拉成整卡高度,aspect-ratio 失效。 */ + .card .media{flex-shrink:0;align-self:flex-start;width:92px;aspect-ratio:4/3; + border-radius:8px;overflow:hidden;background:var(--accent-soft)} + .card .media img{width:100%;height:100%;object-fit:cover;display:block} + .card .body{flex:1;min-width:0} + .card h3{font-family:var(--serif);font-size:15.5px;font-weight:700;line-height:1.5;margin-bottom:5px} + .card p{font-size:13.5px;color:var(--ink-2);line-height:1.65} + .card .why{margin-top:7px;padding-left:9px;border-left:2px solid var(--brand); + font-size:12.5px;color:var(--ink-2)} + .card .why b{color:var(--brand);font-weight:600} + /* 占位:无图时用栏目色 + emoji,保持版式不塌 */ + .media.ph{display:flex;align-items:center;justify-content:center;font-size:26px; + background:linear-gradient(135deg,var(--brand-soft),var(--accent-soft))} + .tag{display:inline-block;font-size:10.5px;font-weight:600;letter-spacing:.06em; + padding:2px 7px;border-radius:4px;background:var(--brand-soft);color:var(--brand);margin-bottom:5px} + .tag.b{background:var(--accent-soft);color:var(--accent)} + .source{display:flex;align-items:center;gap:6px;margin-top:7px;font-size:11.5px;color:var(--ink-3)} + .source .dot{width:3px;height:3px;border-radius:50%;background:var(--ink-3)} + .source a{color:var(--ink-3);text-decoration:none;border-bottom:1px solid var(--line)} + /* 深读 */ + .deep{background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--brand); + border-radius:12px;padding:18px 17px} + .deep h3{font-family:var(--serif);font-size:18px;font-weight:700;margin-bottom:10px} + .deep p{font-size:14.5px;color:var(--ink-2);margin-bottom:9px} + .deep .pull{font-family:var(--serif);font-size:15px;color:var(--brand); + padding:10px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin:11px 0} + /* 天气 */ + .weather-today{background:linear-gradient(135deg,var(--accent-soft),var(--surface)); + border:1px solid var(--line);border-radius:12px;padding:14px 16px;margin-bottom:10px; + display:flex;justify-content:space-between;align-items:center} + .weather-today .c{font-size:17px;font-weight:700;color:var(--accent)} + .weather-today .t{font-family:var(--serif);font-size:34px;font-weight:700;color:var(--accent)} + .weather-today .d{font-size:12.5px;color:var(--ink-2)} + .weather-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:7px} + .weather-card{background:var(--surface);border:1px solid var(--line);border-radius:10px; + padding:9px 6px;text-align:center} + .weather-card .wc{font-size:12px;font-weight:600;color:var(--ink-2)} + .weather-card .we{font-size:17px;margin:2px 0} + .weather-card .wt{font-size:13px;font-weight:700;color:var(--brand)} + /* 知识卡 / 往期 */ + .k-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px} + .k-item{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px} + .k-item .k-icon{font-size:20px;line-height:1} + .k-item h4{font-family:var(--serif);font-size:14.5px;margin:5px 0} + .k-item p{font-size:12.5px;color:var(--ink-2)} + .h-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:7px} + .h-item{background:var(--surface);border:1px solid var(--line);border-radius:9px; + padding:9px 4px;text-align:center;text-decoration:none;color:var(--ink-2)} + .h-item .hd{font-size:12px;font-weight:600;color:var(--accent)} + .h-item .hw{font-size:10px;color:var(--ink-3)} + .h-item.today{border-color:var(--brand);background:var(--brand-soft)} + .h-item.today .hd{color:var(--brand)} + /* 订阅位 */ + .subscribe{background:var(--surface);border:1px dashed var(--brand);border-radius:14px; + padding:20px 18px;text-align:center;margin-bottom:28px} + .subscribe h3{font-family:var(--serif);font-size:17px;margin-bottom:6px} + .subscribe p{font-size:13px;color:var(--ink-2);margin-bottom:12px} + .subscribe a{display:inline-block;padding:9px 22px;border-radius:999px;background:var(--brand); + color:#fff;font-size:14px;font-weight:600;text-decoration:none} + .footer{max-width:720px;margin:0 auto;padding:22px 20px 34px;text-align:center; + font-size:12px;color:var(--ink-3);line-height:1.9;border-top:1px solid var(--line)} + @media (max-width:480px){ + .masthead h1{font-size:26px} + .weather-grid{grid-template-columns:repeat(3,1fr)} + .k-grid{grid-template-columns:1fr} + .card .media{width:80px} + } +</style>`; + +export const NEWS002_SEO_SPEC = [ + '【SEO / GEO 必做】', + '1. `<title>`:`每日新闻早报 · {YYYY年M月D日} · TKMind`。', + '2. `<meta name="description">`:80~160 字,含日期 + 当日 3~5 条核心标题关键词(国内/国际/财经/科技至少各 1 个)。', + '3. `<meta name="mindspace-geo">` JSON:summary(120~180 字)、keywords 数组(8~12 个)、faq 至少 3 组 q/a(分别覆盖国内、国际财经、科技)。', + '4. `.section-title` 内标题文字必须用 `<h2>` 包裹(保留 icon span)。', + '5. 每条卡片 `<h3>` 须含具体事件关键词,禁止「暂无」「未能获取」等空词。', + '6. 头条大图卡必须输出 `ImageObject` 到 JSON-LD `image` 数组,便于 Google Discover / 图片搜索收录。', + '7. footer 注明整理时间与主要来源域名,便于 GEO 引用。', +].join('\n'); + +export const NEWS002_TEMPLATE_SPEC = [ + '生成「每日新闻早报」静态 HTML 页面(daily-news-MMDD.html),版式为 news002(订阅向改版)。', + 'news002 与旧版 news001 的区别:事件唯一化、每条配图、报头版式、支持深色模式。禁止套用 news001 的 `.hero` 渐变大色块、25 个 quick-link、`.highlight-box` 系列。', + '', + '一、事件唯一化(最高优先级,违反即作废重写)', + `1. 全页独立事件卡上限 ${NEWS002_MAX_STORY_CARDS} 条。宁可少写,不许灌水。`, + '2. 写页前先把预取 + tkmind_search + web_search 的结果归并成一张「今日事件表」:每个事件一行,字段为 事件指纹 / 标题 / 摘要 / 来源URL / 配图URL / 栏目归属。', + '3. 事件指纹 = 核心主体 + 核心动作 的小写拼音或英文短横线串(例:`wangyi-rubio-call`、`asean-expo-nanning`、`openai-hodge-conjecture`)。中英文报道同一事件必须共用同一指纹。', + '4. 每张卡片必须写 `data-event-key="{事件指纹}"`;全页指纹不得重复,一个事件只能出现在一个栏目里。', + '5. 同一事件的「不同角度」不算新事件,不得为了填满栏目而拆成两条。', + '', + '二、栏目结构(core 必出,flex 没料就整节删掉)', + `1. core 栏目(必须全部出现):${NEWS002_CORE_SECTION_IDS.join(' / ')}。`, + `2. flex 栏目(只有拿到未使用过的独立事件才写,否则连同导航锚点一起删除):${NEWS002_FLEX_SECTION_IDS.join(' / ')}。`, + '3. 各栏目条数区间:' + NEWS002_SECTIONS.map((s) => `${s.id} ${s.min}~${s.max}`).join('|') + '。', + '4. 区块容器用 `<div class="section" id="...">`,`.section-title` 内含 `<span class="icon">` + `<h2>` + 可选 `<span class="count">`。', + '', + '三、DOM 骨架(顺序固定)', + '1. `<div class="progress" id="progress"></div>`(阅读进度条,配套一小段 scroll 脚本)。', + '2. `<div class="masthead">`:`.kicker` 写 `TKMIND DAILY`、`<h1>每日新闻早报</h1>`、`.meta` 一行写 `<b>{YYYY年M月D日}</b> · 星期X · 第 {N} 期 · 今日 {M} 条`。', + '3. `<div class="lede">`:一句主编导语,25~45 字,点出当天最值得关心的一件事,不要罗列。', + '4. `<nav class="nav"><div class="nav-inner">`:只放 8 个锚点(速读/头条/深读/国内/国际/财经/科技/天气),flex 栏目不进导航。', + '5. `<div class="container">` 内按栏目顺序输出 section。', + '6. 在 history 之前插入 `.subscribe` 订阅引导块(标题 + 一句话价值 + 按钮)。', + '7. `<div class="footer">`:来源域名列表 + 整理时间 + TKMind 品牌。', + '', + '四、卡片写法', + '1. 速读区:`<ol class="brief">` + `<li><span class="n">1</span><span>一句话 20~32 字,必须含具体主体和数字</span></li>`,6~10 条,每条对应下方某个事件指纹。', + '2. 头条:`<a class="lead-card" href="{来源URL}" data-event-key="..."><div class="media">{图}</div><div class="body"><span class="tag">…</span><h3>…</h3><p>…</p><div class="source">…</div></div></a>`,3~4 条。', + '3. 普通条目:`<div class="card" data-event-key="..."><div class="media">{图}</div><div class="body"><span class="tag">…</span><h3>…</h3><p>…</p><div class="why"><b>为什么重要</b>:…</div><div class="source">…</div></div></div>`。', + '4. `<h3>` 中文 15~26 字,前 10 字必须有信息钩子(主体+动作),禁止「相关消息」「持续关注」这类空转。', + '5. `<p>` 写 2~3 句:第一句说发生了什么(含时间地点数字),第二句说进展或背景。', + '6. `.why` 一句话说清「跟读者有什么关系」,20~40 字。头条卡可省略 `.why`。', + '7. `.source` 格式:`<a href="{URL}" target="_blank" rel="noopener">{媒体名}</a><span class="dot"></span>{HH:MM}`。', + '8. 深读区用 `.deep`:一条当天最重要的事,300~450 字,中间插一句 `.pull` 金句。', + '', + '五、配图(news002 新增,必须做)', + '1. 预取 brief 里每条结果若带 `图:{URL}`,就把该 URL 用作这条新闻的配图。', + '2. 有图写 `<div class="media"><img src="{图URL}" alt="{标题}" loading="lazy" referrerpolicy="no-referrer" onerror="this.parentNode.classList.add(\'ph\');this.remove()"></div>`。', + '3. 无图写占位:`<div class="media ph">{栏目 emoji}</div>`,禁止留空 div,禁止为了配图去编造图片地址。', + '3b. 知识卡片的 emoji 用 `<div class="k-icon">`,否则字号会掉到正文大小。', + '4. 头条至少 2 条要有真实配图;配图必须与该条新闻同源,禁止拿甲事件的图配乙事件。', + '5. 图片一律 https,禁止 data URI 长图,禁止整页长图截图。', + '', + '六、视觉(照抄,禁止自创色值)', + '1. `<style>` 块直接使用下方给定内容,可追加但不得修改已有选择器的色值与字号。', + '2. 色调:暖纸底 #FAF7F2 + 墨红 #C8362F + 靛蓝 #1B3A6B;标题衬线体,正文黑体;必须保留 `prefers-color-scheme: dark` 分支。', + NEWS002_STYLE_BLOCK, + '', + '七、交付', + '1. 必须含 title、description、mindspace-cover、mindspace-geo、og/twitter、canonical、JSON-LD。', + '2. `mindspace-cover` 的 accent 用 `#C8362F`、accent2 用 `#1B3A6B`。', + '3. 生成 daily-news-MMDD.thumbnail.png。', + '4. 推送微信草稿时转为内联 style(微信不支持 `<style>`),底部追加统一「阅读原文 + 二维码」layout。', +].join('\n'); + +export const NEWS002_FRESH_CONTENT_SPEC = [ + '内容硬约束(必须遵守,优先于版式):', + '1. 检索必须双路:先 load_skill → web,再同一轮调用 tkmind_search(type=news) 与 web_search,合并去重后写页;禁止只靠 web_search。', + '2. 合并后先产出「今日事件表」并去重,再按事件数决定写几个栏目 —— 是事件决定栏目,不是栏目决定事件。', + `3. 全页独立事件不超过 ${NEWS002_MAX_STORY_CARDS} 条;当天独立事件不足时,flex 栏目整节删除,禁止把已写过的事件换个标题再写一遍。`, + '4. 判重标准:同一 URL、同一主体+同一动作、中英文互译标题、同一事件的不同媒体转述,都算同一事件。', + '5. 若附有「专用联网搜索预取」,必须优先从中选题;预取有结果时禁止输出「未能获取」类空页。', + '6. 若 tkmind_search / web_search 暂时不可用但预取 brief 有条目:用预取结果写页,并在 source 标注预取来源 URL。', + '7. 仅当预取各路均为空且双路搜索均失败时,才允许写「检索暂不可用」说明页;禁止用历史 daily-news 正文充数。', + '8. 标题规则:中文 15~26 字;英文来源须转写为中文标题,可在括号内保留英文原题;每条必须可核验并写来源 URL。', + '9. 禁止复制、改写历史 daily-news-*.html 的正文;旧稿仅作 HTML 结构参考。', + '10. 禁止八卦绯闻、未经证实的传闻、标题党;每条都要能说清「为什么重要」,说不清就不要写。', +].join('\n'); + +/* ------------------------------------------------------------------ * + * 注册表 + * ------------------------------------------------------------------ */ + +export const NEWS_MORNING_TEMPLATES = { + news001: { + id: 'news001', + version: NEWS001_VERSION, + label: '每日新闻早报 · 定稿版(2026-09-18)', + status: 'frozen', + referenceUrl: NEWS_MORNING_TEMPLATE_REFERENCE_URL, + supportsCardImages: false, + maxStoryCards: null, + freshContentSpec: NEWS001_FRESH_CONTENT_SPEC, + seoSpec: NEWS001_SEO_SPEC, + templateSpec: NEWS001_TEMPLATE_SPEC, + }, + news002: { + id: 'news002', + version: NEWS002_VERSION, + label: '每日新闻早报 · 订阅版(news002)', + status: 'active', + referenceUrl: null, + supportsCardImages: true, + maxStoryCards: NEWS002_MAX_STORY_CARDS, + freshContentSpec: NEWS002_FRESH_CONTENT_SPEC, + seoSpec: NEWS002_SEO_SPEC, + templateSpec: NEWS002_TEMPLATE_SPEC, + }, +}; + +export const NEWS_MORNING_TEMPLATE_IDS = Object.keys(NEWS_MORNING_TEMPLATES); + +export const DEFAULT_NEWS_MORNING_TEMPLATE_ID = 'news001'; + +export function resolveNewsMorningTemplateId(value, env = process.env) { + const candidates = [value, env?.H5_WECHAT_NEWS_MORNING_TEMPLATE_ID]; + for (const candidate of candidates) { + const normalized = String(candidate ?? '').trim().toLowerCase(); + if (normalized && NEWS_MORNING_TEMPLATES[normalized]) return normalized; + } + return DEFAULT_NEWS_MORNING_TEMPLATE_ID; +} + +export function getNewsMorningTemplate(value, env = process.env) { + return NEWS_MORNING_TEMPLATES[resolveNewsMorningTemplateId(value, env)]; +} + +/** 组装交给 Agent 的完整 taskSpec 前缀(内容约束 + SEO + 版式)。 */ +export function buildNewsMorningTemplateSpec(templateId, env = process.env) { + const template = getNewsMorningTemplate(templateId, env); + return [ + `版式模板:${template.id}(${template.version})`, + template.freshContentSpec, + template.seoSpec, + template.templateSpec, + template.referenceUrl ? `版式参照(只读结构):${template.referenceUrl}` : '', + ].filter(Boolean).join('\n'); +} diff --git a/news-morning-templates.test.mjs b/news-morning-templates.test.mjs new file mode 100644 index 0000000..453e20a --- /dev/null +++ b/news-morning-templates.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + DEFAULT_NEWS_MORNING_TEMPLATE_ID, + NEWS001_TEMPLATE_SPEC, + NEWS001_VERSION, + NEWS002_CORE_SECTION_IDS, + NEWS002_FLEX_SECTION_IDS, + NEWS002_MAX_STORY_CARDS, + NEWS002_SECTIONS, + NEWS002_STYLE_BLOCK, + NEWS002_TEMPLATE_SPEC, + NEWS002_VERSION, + NEWS_MORNING_TEMPLATES, + NEWS_MORNING_TEMPLATE_IDS, + buildNewsMorningTemplateSpec, + getNewsMorningTemplate, + resolveNewsMorningTemplateId, +} from './news-morning-templates.mjs'; + +test('registry exposes news001 and news002', () => { + assert.deepEqual(NEWS_MORNING_TEMPLATE_IDS, ['news001', 'news002']); + assert.equal(NEWS_MORNING_TEMPLATES.news001.status, 'frozen'); + assert.equal(NEWS_MORNING_TEMPLATES.news001.version, NEWS001_VERSION); + assert.equal(NEWS_MORNING_TEMPLATES.news002.version, NEWS002_VERSION); +}); + +test('news001 stays byte-identical to the frozen 0918 spec', () => { + // news001 已上生产,任何改动都必须走 news003,不允许就地改。 + assert.equal(NEWS001_VERSION, '2026-09-18-refined'); + assert.match(NEWS001_TEMPLATE_SPEC, /quick-links 固定顺序/); + assert.match(NEWS001_TEMPLATE_SPEC, /card\.highlight-box/); + assert.equal(NEWS_MORNING_TEMPLATES.news001.supportsCardImages, false); +}); + +test('resolveNewsMorningTemplateId falls back safely', () => { + assert.equal(resolveNewsMorningTemplateId(null, {}), DEFAULT_NEWS_MORNING_TEMPLATE_ID); + assert.equal(resolveNewsMorningTemplateId('news002', {}), 'news002'); + assert.equal(resolveNewsMorningTemplateId('NEWS002', {}), 'news002'); + assert.equal(resolveNewsMorningTemplateId('news999', {}), 'news001'); + assert.equal( + resolveNewsMorningTemplateId(null, { H5_WECHAT_NEWS_MORNING_TEMPLATE_ID: 'news002' }), + 'news002', + ); + assert.equal( + resolveNewsMorningTemplateId('news001', { H5_WECHAT_NEWS_MORNING_TEMPLATE_ID: 'news002' }), + 'news001', + '显式配置优先于环境变量', + ); +}); + +test('news002 section tiers split core and flex', () => { + assert.ok(NEWS002_CORE_SECTION_IDS.includes('headlines')); + assert.ok(NEWS002_CORE_SECTION_IDS.includes('brief')); + assert.ok(NEWS002_CORE_SECTION_IDS.includes('deepdive')); + assert.ok(NEWS002_FLEX_SECTION_IDS.includes('living')); + assert.equal( + NEWS002_CORE_SECTION_IDS.length + NEWS002_FLEX_SECTION_IDS.length, + NEWS002_SECTIONS.length, + ); + for (const section of NEWS002_SECTIONS) { + assert.ok(section.min <= section.max, `${section.id} 条数区间非法`); + } +}); + +test('news002 caps total story cards well below news001 output', () => { + // 0918 生产页产出 49 条卡片、仅约 20 个独立事件。 + assert.equal(NEWS002_MAX_STORY_CARDS, 28); + assert.match(NEWS002_TEMPLATE_SPEC, new RegExp(`全页独立事件卡上限 ${NEWS002_MAX_STORY_CARDS} 条`)); +}); + +test('news002 spec mandates event keys, images and dark mode', () => { + assert.match(NEWS002_TEMPLATE_SPEC, /data-event-key/); + assert.match(NEWS002_TEMPLATE_SPEC, /loading="lazy"/); + assert.match(NEWS002_TEMPLATE_SPEC, /referrerpolicy="no-referrer"/); + assert.match(NEWS002_TEMPLATE_SPEC, /prefers-color-scheme/); + assert.equal(NEWS_MORNING_TEMPLATES.news002.supportsCardImages, true); +}); + +test('news002 style block is self-contained and carries the new palette', () => { + assert.ok(NEWS002_STYLE_BLOCK.startsWith('<style>')); + assert.ok(NEWS002_STYLE_BLOCK.endsWith('</style>')); + assert.match(NEWS002_STYLE_BLOCK, /--brand:#C8362F/); + assert.match(NEWS002_STYLE_BLOCK, /--accent:#1B3A6B/); + assert.match(NEWS002_STYLE_BLOCK, /--paper:#FAF7F2/); + assert.match(NEWS002_STYLE_BLOCK, /@media \(prefers-color-scheme:dark\)/); + assert.ok(!NEWS002_STYLE_BLOCK.includes('#b0191e'), 'news002 不得残留 news001 主色'); +}); + +test('buildNewsMorningTemplateSpec assembles content + seo + layout', () => { + const spec001 = buildNewsMorningTemplateSpec('news001', {}); + assert.match(spec001, /版式模板:news001/); + assert.match(spec001, /版式参照(只读结构):https:\/\//); + + const spec002 = buildNewsMorningTemplateSpec('news002', {}); + assert.match(spec002, /版式模板:news002/); + assert.match(spec002, /内容硬约束/); + assert.match(spec002, /SEO \/ GEO 必做/); + assert.ok(spec002.includes(NEWS002_STYLE_BLOCK)); + assert.ok(!spec002.includes('版式参照(只读结构)'), 'news002 自带样式,不依赖外部参照页'); +}); + +test('getNewsMorningTemplate returns the resolved template object', () => { + assert.equal(getNewsMorningTemplate('news002', {}).id, 'news002'); + assert.equal(getNewsMorningTemplate('bogus', {}).id, 'news001'); +}); diff --git a/scripts/preview-news002-template.mjs b/scripts/preview-news002-template.mjs new file mode 100644 index 0000000..e67773b --- /dev/null +++ b/scripts/preview-news002-template.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +/** + * 用固定样例数据渲染一张 news002 静态预览页,用于人工评审版式/色调/排版。 + * 生产页由 Agent 按 NEWS002_TEMPLATE_SPEC 生成,这里只保证 CSS 骨架可视化。 + * + * 用法:node scripts/preview-news002-template.mjs [输出路径] + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { NEWS002_STYLE_BLOCK } from '../news-morning-templates.mjs'; + +const OUT = process.argv[2] || path.join(process.cwd(), 'news002-preview.html'); + +/** 预览用占位图:避免评审时依赖外网图片。生产页用的是新闻原文配图。 */ +function placeholder(label, from, to) { + const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"> + <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"> + <stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/> + </linearGradient></defs> + <rect width="640" height="360" fill="url(#g)"/> + <text x="50%" y="52%" fill="#fff" font-size="30" font-family="sans-serif" + text-anchor="middle" opacity="0.92">${label}</text> + </svg>`; + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + +const BRIEF = [ + '王毅同美国国务卿鲁比奥通电话,双方就中美关系交换意见', + '第23届中国—东盟博览会在南宁开幕,3400余家企业参展', + '马来西亚最高元首应习近平邀请,将对中国进行国事访问', + '沈阳等地举行九一八事变纪念活动,全城鸣响警世钟', + 'Kimi 发布金融行业解决方案,开放 9 项金融技能', + 'OpenAI 被曝接近攻克千禧年难题「霍奇猜想」', + '张一鸣净值破 1050 亿美元,首次成为亚洲首富', + '国际油价高位震荡,黄金避险需求维持强势', +]; + +const LEADS = [ + { + key: 'wangyi-rubio-call', + tag: '中美关系', + title: '王毅同美国国务卿鲁比奥通电话,双方就中美关系交换意见', + body: '9月17日,中共中央政治局委员、外交部长王毅同美国国务卿鲁比奥通电话,着重就中美关系及共同关心的问题交换意见。这是双方近一个月来的第二次高层通话。', + source: '新华社', time: '08:12', + img: placeholder('中美高层通话', '#C8362F', '#7A1C18'), + }, + { + key: 'asean-expo-nanning', + tag: '区域经贸', + title: '第23届中国—东盟博览会南宁开幕,东帝汶首次以共办方身份参展', + body: '本届东博会共有 70 多个国家和地区的 3400 多家企业参展,东帝汶首次以共办方身份亮相,聚焦 RCEP 框架下的贸易投资与产业对接。', + source: '中国新闻网', time: '09:40', + img: placeholder('东盟博览会', '#1B3A6B', '#0E2340'), + }, + { + key: 'malaysia-king-visit', + tag: '外交', + title: '应习近平邀请,马来西亚最高元首将对中国进行国事访问', + body: '外交部宣布,马来西亚最高元首将于近期对中国进行国事访问。这是两国建交以来马方元首的又一次重要访华行程。', + source: '外交部', time: '10:05', + img: placeholder('元首外交', '#8A5A2B', '#4A2E13'), + }, +]; + +const CARDS = { + domestic: [ + { + key: 'shenyang-918-memorial', tag: '时政', + title: '勿忘九一八:沈阳等地举行纪念活动,全城鸣响警世钟', + body: '9月18日,沈阳九一八历史博物馆残历碑广场举行撞钟鸣警仪式,全市同步拉响防空警报。多地同期举办主题纪念活动。', + why: '这是全民国防教育的固定节点,各地交通与公共活动会同步做临时管制安排。', + source: '人民日报', time: '10:31', img: placeholder('九一八纪念', '#5C1F1C', '#2B0E0C'), + }, + { + key: 'beijing-daily-frontpage', tag: '民生', + title: '北京日报刊发9月18日版面,聚焦当日重点时政与民生议题', + body: '版面重点覆盖城市更新、医保结算与秋季交通组织调整三项内容,其中医保异地结算范围进一步扩大。', + why: '异地就医结算范围扩大,直接影响在京工作的非京籍参保人报销流程。', + source: '北京日报', time: '07:20', img: '', + }, + ], + business: [ + { + key: 'oil-gold-volatility', tag: '大宗', + title: '地缘因素扰动原油市场,国际油价高位震荡、黄金避险需求走强', + body: '布伦特原油盘中一度冲高后回落,金价受美债收益率与降息预期拉扯维持震荡。市场关注下周 OPEC+ 产量会议。', + why: '油价与国内成品油调价窗口挂钩,会传导到下一轮加油成本。', + source: '路透社', time: '06:55', img: placeholder('能源与贵金属', '#B07A1E', '#5C3D0A'), + }, + { + key: 'zhang-yiming-richest', tag: '财富', + title: '张一鸣净值突破1050亿美元,首次成为亚洲首富', + body: '受字节跳动 AI 业务估值抬升带动,张一鸣个人净值首次登顶亚洲富豪榜,超越此前长期居首的印度实业家族。', + why: 'AI 估值外溢到一级市场,会影响今年国内科技赛道的融资定价。', + source: 'Bloomberg', time: '11:18', img: '', + }, + ], + tech: [ + { + key: 'openai-hodge-conjecture', tag: 'AI', + title: 'OpenAI 被曝接近攻克千禧年难题「霍奇猜想」', + body: '多家媒体报道称,OpenAI 内部模型在霍奇猜想证明上取得阶段性进展,相关结果尚待同行评议。公司未正式回应。', + why: '若成立,这将是 AI 首次在千禧年难题上做出可验证贡献,值得等同行评议结论。', + source: 'Hacker News', time: '05:42', img: placeholder('AI for Science', '#1B3A6B', '#4A1E6B'), + }, + { + key: 'kimi-finance-solution', tag: '大模型', + title: 'Kimi 发布金融行业解决方案,开放 9 项金融技能', + body: '月之暗面发布面向金融场景的解决方案,用户可在 kimi.com 体验研报解读、财务比对等 9 项技能,主打垂直场景落地。', + why: '大模型开始从通用对话转向按行业收费,这是商业化路径的关键信号。', + source: '36氪', time: '14:06', img: '', + }, + ], +}; + +const WEATHER = [ + ['北京', '☀️', '18/27°'], ['上海', '⛅', '23/30°'], ['广州', '🌦️', '26/33°'], + ['深圳', '🌧️', '26/31°'], ['成都', '☁️', '20/26°'], ['杭州', '⛅', '22/30°'], + ['西安', '☀️', '17/28°'], ['武汉', '⛅', '21/29°'], +]; + +const KNOWLEDGE = [ + ['📐', '什么是霍奇猜想?', '代数几何的千禧年难题之一,问的是复射影流形上哪些上同调类可以由代数闭链表示。'], + ['🛢️', '布伦特与WTI的差价', '两者分别对应北海与美国内陆原油,价差主要反映运输成本与区域供需,常被用作地缘风险指标。'], +]; + +const img = (src, alt, emoji) => (src + ? `<div class="media"><img src="${src}" alt="${alt}" loading="lazy" referrerpolicy="no-referrer" onerror="this.parentNode.classList.add('ph');this.remove()"></div>` + : `<div class="media ph">${emoji}</div>`); + +const source = (name, time) => `<div class="source"><a href="#">${name}</a><span class="dot"></span>${time}</div>`; + +const section = (id, icon, label, inner, count = '') => ` + <div class="section" id="${id}"> + <div class="section-title"><span class="icon">${icon}</span><h2>${label}</h2>${count ? `<span class="count">${count}</span>` : ''}</div> + ${inner} + </div>`; + +const renderCards = (items, emoji) => items.map((c) => ` + <div class="card" data-event-key="${c.key}"> + ${img(c.img, c.title, emoji)} + <div class="body"> + <span class="tag">${c.tag}</span> + <h3>${c.title}</h3> + <p>${c.body}</p> + <div class="why"><b>为什么重要</b>:${c.why}</div> + ${source(c.source, c.time)} + </div> + </div>`).join(''); + +const html = `<!DOCTYPE html> +<html lang="zh-CN"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>每日新闻早报 · 2026年9月18日 · TKMind + +${NEWS002_STYLE_BLOCK} + + +
+ +
+
TKMIND DAILY
+

每日新闻早报

+
2026年9月18日星期五第 218 期今日 11 条
+
+
中美外长再次通话,是今天最值得看的一件事 —— 它决定了后面一周市场对地缘风险的定价。
+ + + +
+ +${section('brief', '⚡', '一分钟速读', `
    ${BRIEF.map((t, i) => `
  1. ${i + 1}${t}
  2. `).join('')}
`, `${BRIEF.length} 条`)} + +${section('headlines', '📰', '今日头条', LEADS.map((c) => ` + + ${img(c.img, c.title, '📰')} +
+ ${c.tag} +

${c.title}

+

${c.body}

+ ${source(c.source, c.time)} +
+
`).join(''))} + +${section('deepdive', '🔍', '深读一条', ` +
+ 解读 +

一通电话为什么值得看:中美沟通渠道正在被重新定义

+

过去一年,中美高层互动从「危机后补救」转向「常态化管道」。这次通话距离上一次仅隔 27 天,节奏本身比内容更值得注意 —— 它意味着双方把沟通频率固定了下来。

+
节奏的稳定,往往比单次会谈的措辞更能说明关系走向。
+

对市场而言,稳定的沟通渠道降低了「意外升级」的尾部风险,这也是今天亚太股市在地缘消息面上没有出现明显波动的原因之一。后续观察点是月底的经贸工作组会议是否如期召开。

+ ${source('综合新华社、路透社', '12:00')} +
`)} + +${section('domestic', '🇨🇳', '国内', renderCards(CARDS.domestic, '🇨🇳'))} +${section('business', '💰', '财经·市场', renderCards(CARDS.business, '💰'))} +${section('tech', '🤖', '科技·AI', renderCards(CARDS.tech, '🤖'))} + +${section('weather', '🌤️', '今日天气', ` +
+
北京
晴 · 东北风3级 · 空气优
+
27°
+
+
${WEATHER.map(([c, e, t]) => `
${c}
${e}
${t}
`).join('')}
`)} + +${section('knowledge', '📚', '知识卡片', `
${KNOWLEDGE.map(([e, t, d]) => `
${e}

${t}

${d}

`).join('')}
`)} + +
+

每天早上 6 点,11 条不重复的新闻

+

我们只写当天真正发生的事,去掉重复选题,每条都告诉你为什么重要。

+ 关注 TKMind 智趣 +
+ +${section('history', '📅', '往期回顾', `
+ ${['09-18', '09-17', '09-16', '09-15', '09-14', '09-13', '09-12'].map((d, i) => `
${d}
${['今天', '周四', '周三', '周二', '周一', '周日', '周六'][i]}
`).join('')} +
`)} + +
+ + + + + +`; + +fs.writeFileSync(OUT, html); +console.log(`news002 preview written to ${OUT}`); diff --git a/search-capability.mjs b/search-capability.mjs index 1f5988c..9f1b694 100644 --- a/search-capability.mjs +++ b/search-capability.mjs @@ -7,8 +7,27 @@ export function validateSearchRequest(input = {}) { return { ok: true, value: { query, type: String(input.type ?? 'web'), limit } }; } +/** provider 返回的配图字段名不统一,统一取第一个安全的 https 地址。 */ +function pickResultImage(result = {}) { + for (const candidate of [result.image, result.img_src, result.thumbnail_src, result.thumbnail]) { + const value = String(candidate ?? '').trim(); + if (value.startsWith('https://') && isSafeHttpUrl(value)) return value.slice(0, 1000); + } + return ''; +} + export function normalizeSearchResult(result = {}, index = 0) { - return { title: String(result.title ?? '').trim(), url: String(result.url ?? '').trim(), snippet: String(result.snippet ?? result.content ?? '').trim().slice(0, 2000), source: String(result.source ?? 'unknown'), rank: index + 1 }; + const image = pickResultImage(result); + const publishedAt = String(result.publishedAt ?? result.publishedDate ?? '').trim().slice(0, 40); + return { + title: String(result.title ?? '').trim(), + url: String(result.url ?? '').trim(), + snippet: String(result.snippet ?? result.content ?? '').trim().slice(0, 2000), + source: String(result.source ?? 'unknown'), + rank: index + 1, + ...(image ? { image } : {}), + ...(publishedAt ? { publishedAt } : {}), + }; } export function buildCitations(results = []) { diff --git a/wechat-daily-news-inline.mjs b/wechat-daily-news-inline.mjs index cba4184..7dd4807 100644 --- a/wechat-daily-news-inline.mjs +++ b/wechat-daily-news-inline.mjs @@ -182,6 +182,8 @@ function classifyCard(cardHtml) { } function detectFeatureTheme(cardHtml) { + // news002 用 .lead-card 取代 news001 的 .highlight-box 系列做头条卡。 + if (/\blead-card\b/u.test(cardHtml)) return FEATURE_CARD_THEMES['highlight-box']; if (/highlight-box-orange/u.test(cardHtml)) return FEATURE_CARD_THEMES['highlight-box-orange']; if (/highlight-box-gold/u.test(cardHtml)) return FEATURE_CARD_THEMES['highlight-box-gold']; if (/highlight-box-blue/u.test(cardHtml)) return FEATURE_CARD_THEMES['highlight-box-blue']; @@ -295,7 +297,23 @@ function resolveMappedImageUrl(src, imageUrlMap = new Map()) { } function extractHeroSubtitle(html) { - return stripInlineText(String(html).match(/
]*>([\s\S]*?)<\/div>/i)?.[1] ?? ''); + // news001 用 .subtitle,news002 用 .lede。 + const source = String(html); + return stripInlineText( + source.match(/
]*>([\s\S]*?)<\/div>/i)?.[1] + ?? source.match(/
]*>([\s\S]*?)<\/div>/i)?.[1] + ?? '', + ); +} + +function extractHeroDateLine(html) { + // news001 用 .date-badge,news002 用 .masthead .meta。 + const source = String(html); + return stripInlineText( + source.match(/
]*>([\s\S]*?)<\/div>/i)?.[1] + ?? source.match(/
]*>([\s\S]*?)<\/div>/i)?.[1] + ?? '', + ); } /** @@ -324,7 +342,7 @@ function renderTodayDigest(html, { maxItems = 6, maxItemChars = 30 } = {}) { function renderHero(html, _imageUrlMap = new Map(), { includeSubtitle = true } = {}) { const title = stripInlineText(String(html).match(/]*>([\s\S]*?)<\/h1>/i)?.[1] ?? '') || '每日新闻早报'; - const dateBadge = stripInlineText(String(html).match(/
]*>([\s\S]*?)<\/div>/i)?.[1] ?? ''); + const dateBadge = extractHeroDateLine(html); const subtitle = includeSubtitle ? truncatePlainText(extractHeroSubtitle(html), 150) : ''; return [ `
`, @@ -557,7 +575,10 @@ function renderGenericSection( /^[\s\S]*?
]*>[\s\S]*?<\/div>/i, '', ); - const tokens = body.split(/(?=
TKMIND DAILY

每日新闻早报

+
2026年9月21日星期一第 221 期
+
中美外长再次通话 · 东博会南宁开幕 · Kimi 发布金融方案
+
+ +

国内

+
🇨🇳
+
时政

沈阳举行九一八纪念活动

国内正文摘要。

+
为什么重要:影响当天交通管制。
+
+
+
`; + +test('news002 lead-card renders as a feature card in the wechat draft', () => { + const rendered = wechatDailyNewsInlineInternals.renderGenericSection( + NEWS002_HTML.match(/
[\s\S]*?<\/a>\n<\/div>/)[0], + { sectionId: 'headlines' }, + ); + assert.match(rendered, /王毅同鲁比奥通电话/u); + assert.match(rendered, /中美关系/u, 'lead-card 的 tag 必须保留'); + assert.doesNotMatch(rendered, /01<\/span>/u, '头条卡不占列表序号'); +}); + +test('news002 card with media block still yields title and body', () => { + const rendered = wechatDailyNewsInlineInternals.renderGenericSection( + NEWS002_HTML.match(/
[\s\S]*$/)[0], + { sectionId: 'domestic' }, + ); + assert.match(rendered, /沈阳举行九一八纪念活动/u); + assert.match(rendered, /国内正文摘要/u, '.media 在前不应挤掉正文

'); +}); + +test('renderHero reads news002 masthead meta and lede', () => { + const rendered = wechatDailyNewsInlineInternals.renderHero(NEWS002_HTML); + assert.match(rendered, /每日新闻早报/u); + assert.match(rendered, /2026年9月21日/u, 'news002 用 .meta 取代 .date-badge'); + assert.match(rendered, /东博会南宁开幕/u, 'news002 用 .lede 取代 .subtitle'); +}); + +test('renderTodayDigest works off the news002 lede', () => { + const digest = wechatDailyNewsInlineInternals.renderTodayDigest(NEWS002_HTML); + assert.match(digest, /今日导读/u); + assert.match(digest, /中美外长再次通话
/u); +}); diff --git a/wechat-news-morning-dedup.mjs b/wechat-news-morning-dedup.mjs new file mode 100644 index 0000000..d66a9c2 --- /dev/null +++ b/wechat-news-morning-dedup.mjs @@ -0,0 +1,275 @@ +/** + * 新闻早报落盘前的事件去重闸门。 + * + * news001 把「23 个栏目 × 固定条数」当硬指标,当天独立事件不够时 Agent 会把同一件事 + * 换个标题重写进多个栏目(实测 0918 生产页 49 条卡片里有 14 条是重复事件)。prompt 层 + * 的「禁止重复」约束无法验证,所以在写页之后再做一次程序化去重,并把结果回灌给调用方。 + * + * REGRESSION GUARD: 这里只做「删重复卡片 / 删空栏目」,不得改写卡片正文, + * 否则会破坏 mindspace 公开页与微信 inline 转换对 DOM 的依赖。 + */ + +import { + normalizeNewsMorningTitle, + normalizeNewsMorningUrl, +} from './wechat-news-morning-search.mjs'; + +// 必须按 class token 精确匹配:`\bcard\b` 会误命中 weather-card / knowledge-card 等。 +const CARD_OPEN_RE = /<(div|a)\b[^>]*\bclass="([^"]*)"[^>]*>/gi; +const SECTION_OPEN_RE = /]*class="[^"]*\bsection\b[^"]*"[^>]*id="([^"]+)"[^>]*>/gi; +const CARD_CLASS_TOKENS = new Set(['card', 'lead-card']); + +function hasCardClass(classAttr) { + return String(classAttr).split(/\s+/).some((token) => CARD_CLASS_TOKENS.has(token)); +} + +/** 从起始标签向后扫描配对的结束标签,返回整块 [start, end)。 */ +function sliceBalancedBlock(html, tagName, openStart, openEnd) { + const open = new RegExp(`<${tagName}\\b`, 'gi'); + const close = new RegExp(``, 'gi'); + let depth = 1; + let cursor = openEnd; + while (depth > 0 && cursor < html.length) { + open.lastIndex = cursor; + close.lastIndex = cursor; + const nextOpen = open.exec(html); + const nextClose = close.exec(html); + if (!nextClose) return { start: openStart, end: html.length }; + if (nextOpen && nextOpen.index < nextClose.index) { + depth += 1; + cursor = nextOpen.index + nextOpen[0].length; + continue; + } + depth -= 1; + cursor = nextClose.index + nextClose[0].length; + } + return { start: openStart, end: cursor }; +} + +/** + * 只有文章永久链接才能当作事件身份。 + * 实测 0918 生产页 49 张卡片只引用了 7 个 URL,全是 `finance.sina.com.cn/stock/cpbd/` + * 这类栏目首页;把栏目首页当身份会把整页误删。 + */ +export function isArticlePermalink(url) { + let pathname = ''; + try { + pathname = new URL(String(url ?? '').trim()).pathname; + } catch { + return false; + } + const segments = pathname.split('/').filter(Boolean); + if (!segments.length) return false; + const last = segments[segments.length - 1]; + if (/^index(\.\w+)?$/i.test(last)) return false; + if (/\d{4,}/.test(pathname)) return true; + if (last.replace(/\.\w+$/, '').length >= 16) return true; + return segments.length >= 3; +} + +function cjkBigrams(value) { + const chars = String(value).replace(/[^\p{Script=Han}\p{L}\p{N}]/gu, ''); + const grams = new Set(); + for (let i = 0; i + 1 < chars.length; i += 1) grams.add(chars.slice(i, i + 2)); + return grams; +} + +/** + * 中文标题归一化后是一整个「词」,仓库既有的按空格分词判重对中文基本失效, + * 这里补一层字符二元组重合度。 + */ +export function isSameNewsEvent(titleA, titleB, { threshold = 0.55 } = {}) { + const a = normalizeNewsMorningTitle(titleA); + const b = normalizeNewsMorningTitle(titleB); + if (!a || !b) return false; + if (a === b) return true; + const shorter = a.length <= b.length ? a : b; + const longer = a.length > b.length ? a : b; + if (shorter.length >= 8 && longer.includes(shorter)) return true; + const gramsA = cjkBigrams(a); + const gramsB = cjkBigrams(b); + if (gramsA.size < 4 || gramsB.size < 4) return false; + let overlap = 0; + for (const gram of gramsA) if (gramsB.has(gram)) overlap += 1; + return overlap / Math.min(gramsA.size, gramsB.size) >= threshold; +} + +function textOf(value) { + return String(value ?? '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function attrOf(tag, name) { + const match = String(tag).match(new RegExp(`${name}="([^"]*)"`, 'i')); + return match?.[1]?.trim() ?? ''; +} + +/** 提取页面里所有新闻卡片(含 .card 与 .lead-card),按出现顺序返回。 */ +export function extractNewsMorningCards(html) { + const source = String(html ?? ''); + const cards = []; + CARD_OPEN_RE.lastIndex = 0; + let match; + while ((match = CARD_OPEN_RE.exec(source)) != null) { + if (!hasCardClass(match[2])) continue; + const tagName = match[1].toLowerCase(); + const { start, end } = sliceBalancedBlock(source, tagName, match.index, match.index + match[0].length); + const raw = source.slice(start, end); + const title = textOf(raw.match(/]*>([\s\S]*?)<\/h3>/i)?.[1] ?? ''); + const sourceBlock = raw.match(/]*\bclass="[^"]*\bsource\b[^"]*"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? ''; + const sourceUrl = attrOf(match[0], 'href') + || sourceBlock.match(/href="(https?:\/\/[^"]+)"/i)?.[1] + || ''; + cards.push({ + start, + end, + raw, + tagName, + title, + url: sourceUrl, + eventKey: attrOf(match[0], 'data-event-key').toLowerCase(), + isLead: /\blead-card\b/i.test(match[0]), + }); + CARD_OPEN_RE.lastIndex = end; + } + return cards; +} + +/** 提取 section 区块边界,用于判断某个栏目是否被清空。 */ +export function extractNewsMorningSections(html) { + const source = String(html ?? ''); + const sections = []; + SECTION_OPEN_RE.lastIndex = 0; + let match; + while ((match = SECTION_OPEN_RE.exec(source)) != null) { + const { start, end } = sliceBalancedBlock(source, 'div', match.index, match.index + match[0].length); + sections.push({ id: match[1], start, end }); + SECTION_OPEN_RE.lastIndex = end; + } + return sections; +} + +function sectionIdAt(sections, offset) { + const hit = sections.find((item) => offset >= item.start && offset < item.end); + return hit?.id ?? ''; +} + +/** + * 判定重复:data-event-key 完全相同 > URL 归一化相同 > 标题高度相似。 + * 不修改 HTML,仅产出报告,便于先观察再启用删除。 + */ +export function auditNewsMorningDuplicates(html, { skipSectionIds = ['history', 'knowledge'] } = {}) { + const sections = extractNewsMorningSections(html); + const skip = new Set(skipSectionIds); + const cards = extractNewsMorningCards(html) + .map((card) => ({ ...card, sectionId: sectionIdAt(sections, card.start) })) + .filter((card) => !skip.has(card.sectionId)); + + const seenKeys = new Map(); + const seenTitles = []; + const kept = []; + const duplicates = []; + + for (const card of cards) { + if (!card.title) continue; + const eventKey = card.eventKey; + if (eventKey && seenKeys.has(eventKey)) { + duplicates.push({ ...card, reason: 'event-key', keptTitle: seenKeys.get(eventKey).title }); + continue; + } + const urlKey = isArticlePermalink(card.url) ? normalizeNewsMorningUrl(card.url) : ''; + const prior = urlKey ? seenTitles.find((item) => item.urlKey === urlKey) : null; + if (prior) { + duplicates.push({ ...card, reason: 'url', keptTitle: prior.rawTitle }); + continue; + } + const sameEvent = seenTitles.find((item) => isSameNewsEvent(item.rawTitle, card.title)); + if (sameEvent) { + duplicates.push({ ...card, reason: 'title', keptTitle: sameEvent.rawTitle }); + continue; + } + if (eventKey) seenKeys.set(eventKey, card); + seenTitles.push({ urlKey, rawTitle: card.title, title: normalizeNewsMorningTitle(card.title) }); + kept.push(card); + } + + return { + total: cards.length, + uniqueCount: kept.length, + duplicateCount: duplicates.length, + duplicates, + kept, + }; +} + +function stripRanges(source, ranges) { + const ordered = [...ranges].sort((a, b) => b.start - a.start); + let output = source; + for (const range of ordered) { + const before = output.slice(0, range.start).replace(/[ \t]*\n?[ \t]*$/, '\n'); + output = before + output.slice(range.end).replace(/^[ \t]*\n/, '\n'); + } + return output; +} + +/** + * 删除重复卡片;某个栏目被删空后连同该 section 与其导航锚点一起移除。 + * 返回 { html, report },调用方负责决定是否落盘。 + */ +export function dedupeNewsMorningHtml(html, options = {}) { + const source = String(html ?? ''); + const report = auditNewsMorningDuplicates(source, options); + if (!report.duplicateCount) { + return { html: source, changed: false, report, removedSectionIds: [] }; + } + + let output = stripRanges(source, report.duplicates); + + const keptBySection = new Set(report.kept.map((card) => card.sectionId).filter(Boolean)); + const protectedIds = new Set([ + ...(options.skipSectionIds ?? ['history', 'knowledge']), + ...(options.keepSectionIds ?? ['weather', 'brief', 'deepdive']), + ]); + const emptySections = extractNewsMorningSections(output) + .filter((section) => !protectedIds.has(section.id)) + .filter((section) => !keptBySection.has(section.id)) + .filter((section) => !/]*href="#${section.id}"[^>]*>[\\s\\S]*?`, 'gi'), + '', + ); + } + } + + return { + html: output, + changed: true, + report, + removedSectionIds: emptySections.map((section) => section.id), + }; +} + +/** 一行摘要,写进 worker 日志与 run 记录。 */ +export function formatNewsMorningDedupeSummary(result) { + const { report, removedSectionIds = [] } = result ?? {}; + if (!report) return ''; + const parts = [ + `卡片 ${report.total} 条,去重后 ${report.uniqueCount} 条`, + ]; + if (report.duplicateCount) { + const reasons = report.duplicates.reduce((acc, item) => { + acc[item.reason] = (acc[item.reason] ?? 0) + 1; + return acc; + }, {}); + parts.push(`剔除 ${report.duplicateCount} 条重复(${Object.entries(reasons).map(([k, v]) => `${k}:${v}`).join(',')})`); + } + if (removedSectionIds.length) parts.push(`移除空栏目 ${removedSectionIds.join('/')}`); + return parts.join(';'); +} diff --git a/wechat-news-morning-dedup.test.mjs b/wechat-news-morning-dedup.test.mjs new file mode 100644 index 0000000..74c02a9 --- /dev/null +++ b/wechat-news-morning-dedup.test.mjs @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + auditNewsMorningDuplicates, + dedupeNewsMorningHtml, + extractNewsMorningCards, + extractNewsMorningSections, + formatNewsMorningDedupeSummary, + isArticlePermalink, + isSameNewsEvent, +} from './wechat-news-morning-dedup.mjs'; + +function card({ id = '', title, url = '', cls = 'card' }) { + return [ + `

`, + '
', + `

${title}

`, + '

正文摘要。

', + ``, + '
', + '
', + ].join('\n'); +} + +function page(sections) { + const body = sections + .map(([id, cards]) => `
\n

${id}

\n${cards.join('\n')}\n
`) + .join('\n'); + const nav = sections.map(([id]) => `${id}`).join(''); + return `\n\n
\n${body}\n
\n`; +} + +test('extractNewsMorningCards matches class tokens, not substrings', () => { + const html = page([ + ['headlines', [card({ title: '甲事件发生了', cls: 'lead-card' })]], + ['weather', ['
北京
']], + ['domestic', [card({ title: '乙事件发生了' })]], + ]); + const cards = extractNewsMorningCards(html); + assert.equal(cards.length, 2, 'weather-card 不应被当作新闻卡片'); + assert.equal(cards[0].isLead, true); + assert.deepEqual(cards.map((item) => item.title), ['甲事件发生了', '乙事件发生了']); +}); + +test('extractNewsMorningSections returns balanced section ranges', () => { + const html = page([['headlines', [card({ title: '甲' })]], ['domestic', [card({ title: '乙' })]]]); + const sections = extractNewsMorningSections(html); + assert.deepEqual(sections.map((item) => item.id), ['headlines', 'domestic']); + assert.ok(sections[0].end <= sections[1].start); +}); + +test('isArticlePermalink rejects section landing pages', () => { + // 0918 生产页的 7 个「来源」全是这类栏目首页,不能当事件身份。 + assert.equal(isArticlePermalink('https://www.zzd.pub/'), false); + assert.equal(isArticlePermalink('https://finance.sina.com.cn/stock/cpbd/'), false); + assert.equal(isArticlePermalink('https://www.news.cn/politics'), false); + assert.equal(isArticlePermalink('https://worldcup.cctv.com/index.shtml'), false); + assert.equal(isArticlePermalink('not-a-url'), false); + + assert.equal(isArticlePermalink('https://www.news.cn/politics/20260918/abc/c.html'), true); + assert.equal(isArticlePermalink('https://www.reuters.com/world/china/call-2026-09-18/'), true); + assert.equal(isArticlePermalink('https://example.com/a/b/c-some-slug'), true); +}); + +test('isSameNewsEvent catches Chinese paraphrases that word overlap misses', () => { + assert.equal( + isSameNewsEvent('王毅同美国国务卿鲁比奥通电话,双方就中美关系交换意见', '王毅同鲁比奥通电话,就中美关系交换意见'), + true, + ); + assert.equal( + isSameNewsEvent('第23届中国—东盟博览会南宁开幕,东帝汶首次以共办方身份参展', '东博会南宁开幕,东帝汶首次以共办方身份参展'), + true, + ); + assert.equal( + isSameNewsEvent('国际油价高位震荡,地缘因素扰动原油市场', 'A股市场关注政策与业绩信号,AI、半导体受关注'), + false, + ); +}); + +test('auditNewsMorningDuplicates prefers data-event-key over text similarity', () => { + const html = page([ + ['headlines', [card({ id: 'wangyi-rubio-call', title: '王毅同鲁比奥通电话' })]], + ['world', [card({ id: 'wangyi-rubio-call', title: '中美高层通话为紧张局势提供沟通渠道' })]], + ]); + const report = auditNewsMorningDuplicates(html); + assert.equal(report.total, 2); + assert.equal(report.uniqueCount, 1); + assert.equal(report.duplicates[0].reason, 'event-key'); +}); + +test('auditNewsMorningDuplicates skips history and knowledge sections', () => { + const html = page([ + ['headlines', [card({ title: '甲事件今天发生了重要变化' })]], + ['history', [card({ title: '甲事件今天发生了重要变化' })]], + ]); + const report = auditNewsMorningDuplicates(html); + assert.equal(report.total, 1); + assert.equal(report.duplicateCount, 0); +}); + +test('dedupeNewsMorningHtml removes duplicate cards and keeps div balance', () => { + const html = page([ + ['headlines', [card({ id: 'a', title: '甲事件今天发生了重要变化' })]], + ['world', [ + card({ id: 'a', title: '甲事件的另一个说法' }), + card({ id: 'b', title: '乙事件在海外引发广泛关注' }), + ]], + ]); + const result = dedupeNewsMorningHtml(html); + + assert.equal(result.changed, true); + assert.equal(result.report.duplicateCount, 1); + assert.ok(!result.html.includes('甲事件的另一个说法')); + assert.ok(result.html.includes('乙事件在海外引发广泛关注')); + assert.deepEqual(result.removedSectionIds, [], 'world 还剩一条,不应整节删除'); + + const opens = (result.html.match(//gi) ?? []).length; + assert.equal(opens, closes, 'div 必须配平'); +}); + +test('dedupeNewsMorningHtml drops emptied flex sections and their nav anchor', () => { + const html = page([ + ['headlines', [card({ id: 'a', title: '甲事件今天发生了重要变化' })]], + ['living', [card({ id: 'a', title: '甲事件换个角度再写一遍' })]], + ]); + const result = dedupeNewsMorningHtml(html); + + assert.deepEqual(result.removedSectionIds, ['living']); + assert.ok(!result.html.includes('id="living"')); + assert.ok(!result.html.includes('href="#living"'), '导航锚点必须一起移除'); + assert.ok(result.html.includes('id="headlines"')); +}); + +test('dedupeNewsMorningHtml never empties protected sections', () => { + const html = page([ + ['headlines', [card({ id: 'a', title: '甲事件今天发生了重要变化' })]], + ['weather', [card({ id: 'a', title: '甲事件今天发生了重要变化' })]], + ]); + const result = dedupeNewsMorningHtml(html); + assert.deepEqual(result.removedSectionIds, []); + assert.ok(result.html.includes('id="weather"')); +}); + +test('dedupeNewsMorningHtml is a no-op on a clean page', () => { + const html = page([ + ['headlines', [card({ id: 'a', title: '甲事件今天发生了重要变化' })]], + ['world', [card({ id: 'b', title: '乙事件在海外引发广泛关注' })]], + ]); + const result = dedupeNewsMorningHtml(html); + assert.equal(result.changed, false); + assert.equal(result.html, html); + assert.match(formatNewsMorningDedupeSummary(result), /卡片 2 条,去重后 2 条/); +}); + +test('formatNewsMorningDedupeSummary reports reasons and removed sections', () => { + const html = page([ + ['headlines', [card({ id: 'a', title: '甲事件今天发生了重要变化' })]], + ['living', [card({ id: 'a', title: '甲事件换个角度再写一遍' })]], + ]); + const summary = formatNewsMorningDedupeSummary(dedupeNewsMorningHtml(html)); + assert.match(summary, /剔除 1 条重复(event-key:1)/); + assert.match(summary, /移除空栏目 living/); +}); diff --git a/wechat-news-morning-draft-worker.mjs b/wechat-news-morning-draft-worker.mjs index 5fd9258..6d89011 100644 --- a/wechat-news-morning-draft-worker.mjs +++ b/wechat-news-morning-draft-worker.mjs @@ -11,6 +11,10 @@ import { isWithinNewsMorningGenerateLeadWindow, } from './wechat-news-morning-draft.mjs'; import { prefetchNewsMorningSearxngBrief } from './wechat-news-morning-search.mjs'; +import { + dedupeNewsMorningHtml, + formatNewsMorningDedupeSummary, +} from './wechat-news-morning-dedup.mjs'; import { isWechatNewsMorningDraftWorkerEnabled, wechatNewsMorningDraftExecutionTimeoutMs, @@ -32,6 +36,31 @@ function resolveTodayPage(config, h5Root, now = Date.now()) { return page; } +/** + * 落盘后的事件去重闸门:prompt 层的「禁止重复」无法验证,这里做一次程序化清理。 + * 去重失败不阻断生成流程,只告警。 + */ +function applyNewsMorningDedupe(page, logger) { + if (!page?.localPath || !fs.existsSync(page.localPath)) return null; + try { + const result = dedupeNewsMorningHtml(fs.readFileSync(page.localPath, 'utf8')); + const summary = formatNewsMorningDedupeSummary(result); + if (result.changed) { + fs.writeFileSync(page.localPath, result.html); + logger.log?.('[NewsMorningDraft] deduped page', { slug: page.slug, summary }); + } else { + logger.log?.('[NewsMorningDraft] dedupe clean', { slug: page.slug, summary }); + } + return result; + } catch (error) { + logger.warn?.( + '[NewsMorningDraft] dedupe failed:', + error instanceof Error ? error.message : error, + ); + return null; + } +} + function archiveTodayPageForForce(config, h5Root, now, logger) { const page = resolveTodayPage(config, h5Root, now); if (!page?.localPath || !fs.existsSync(page.localPath)) return null; @@ -120,6 +149,7 @@ export function startWechatNewsMorningDraftWorker({ if (!page) { throw new Error('新闻早报页面生成未完成'); } + applyNewsMorningDedupe(page, logger); await wechatNewsMorningDraftService.recordAutoGenerationRun({ status: 'success', pageSlug: page.slug, diff --git a/wechat-news-morning-draft.mjs b/wechat-news-morning-draft.mjs index 74c7f12..3a35d4a 100644 --- a/wechat-news-morning-draft.mjs +++ b/wechat-news-morning-draft.mjs @@ -21,6 +21,17 @@ import { uploadWechatDraftBrandAssets, } from './wechat-draft-article-layout.mjs'; import { getLocalParts, localDateKey, startOfLocalDay } from './schedule-time.mjs'; +import { + NEWS001_FRESH_CONTENT_SPEC, + NEWS001_SEO_SPEC, + NEWS001_TEMPLATE_SPEC, + NEWS001_VERSION, + NEWS_MORNING_TEMPLATE_IDS, + NEWS_MORNING_TEMPLATE_REFERENCE_URL as TEMPLATE_REFERENCE_URL, + buildNewsMorningTemplateSpec, + getNewsMorningTemplate, + resolveNewsMorningTemplateId, +} from './news-morning-templates.mjs'; const CONFIG_TABLE = 'h5_wechat_admin_config'; const CONFIG_KEY = 'news_morning_draft'; @@ -30,75 +41,15 @@ const DEFAULT_DRAFT_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/draft/add'; const DEFAULT_MATERIAL_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/material/add_material'; const DEFAULT_UPLOAD_IMG_URL = 'https://api.weixin.qq.com/cgi-bin/media/uploadimg'; const MAX_THUMB_BYTES = 64 * 1024; -export const NEWS_MORNING_TEMPLATE_REFERENCE_URL = - 'https://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/daily-news-0918.html'; +// 版式规范已迁移到 news-morning-templates.mjs(news001 冻结 / news002 订阅版)。 +// 以下导出仅为兼容既有引用,新代码请用 getNewsMorningTemplate() / buildNewsMorningTemplateSpec()。 +export const NEWS_MORNING_TEMPLATE_REFERENCE_URL = TEMPLATE_REFERENCE_URL; +export const NEWS_MORNING_TEMPLATE_VERSION = NEWS001_VERSION; +export const NEWS_MORNING_SEO_SPEC = NEWS001_SEO_SPEC; +export const NEWS_MORNING_TEMPLATE_0915_SPEC = NEWS001_TEMPLATE_SPEC; +export const NEWS_MORNING_TEMPLATE_0910_SPEC = NEWS001_TEMPLATE_SPEC; +export const NEWS_MORNING_FRESH_CONTENT_SPEC = NEWS001_FRESH_CONTENT_SPEC; -export const NEWS_MORNING_TEMPLATE_VERSION = '2026-09-18-refined'; - -export const NEWS_MORNING_SEO_SPEC = [ - '【SEO / GEO 必做】', - '1. ``:`每日新闻早报 · {YYYY年M月D日} · TKMind`。', - '2. `<meta name="description">`:80~160 字,含日期 + 当日 3~5 条核心标题关键词(国内/国际/财经/科技至少各 1 个)。', - '3. `<meta name="mindspace-geo">` JSON:summary(120~180 字)、keywords 数组(8~12 个:新闻/日期/国内/国际/财经/科技/体育/城市名/英文主题词)、faq 至少 3 组 q/a(分别覆盖国内、国际财经、科技体育)。', - '4. 每个 `.section-title` 内标题文字必须用 `<h2>` 包裹(保留 icon span),便于搜索引擎理解栏目层级。', - '5. 每条 card 的 `<h3>` 标题须含具体事件关键词,禁止「暂无」「未能获取」等空词。', - '6. footer 注明整理时间与主要来源域名,便于 GEO 引用。', -].join('\n'); - -/** 生产 daily-news-0918 定稿版式:div.section + 全渠道栏目 + SEO。 */ -export const NEWS_MORNING_TEMPLATE_0915_SPEC = [ - '生成「每日新闻早报」静态 HTML 页面(daily-news-MMDD),版式必须与生产参照页完全一致(仅 HTML/CSS 结构,禁止复制其正文):', - `参照页:${NEWS_MORNING_TEMPLATE_REFERENCE_URL}`, - '写页前可先 read_file 打开同目录最近 daily-news-*.html 或上述 URL 对应文件,对照 CSS 与 DOM 骨架;正文条目必须全部来自当日检索。', - '1. 外层:`.wrap` > `.hero`(`.hero-content` 内 h1「📰 每日新闻早报」、`.date-badge`、`.subtitle` 一句主编导语)> `.container`;禁止 `.stats` 或「X条资讯 · Y个栏目」统计行。', - '2. quick-links 固定顺序:📰 要闻|🌤️ 天气|🌍 国际|🇨🇳 国内|💰 财经|🤖 科技|🏆 体育|🔎 Google|𝕏 X|🌏 亚太|⛽ 能源|🧠 AI芯片|⚖️ 政策|🎓 高校|📈 全球股市|💬 社区|🔬 科学|🎬 文娱|🚀 创投|₿ 加密|🛡️ 地缘|🏙️ 城市|📡 权威快讯|📚 小知识|📅 历史(href 对应下方 id);禁止 id=hot / 🔥 热搜。', - '3. 区块容器必须用 `<div class="section" id="...">`(禁止 `<section id>`);`.section-title` 含 `<span class="icon">` emoji,标题文字用 `<h2>` 包裹。', - '3b. 视觉精细化:`.card` 左侧 3px 色条(tag 色系);`.card.highlight-box` 渐变底+白字;`.quick-links` 横向滚动+浅灰底圆角;hero 保留渐变与 date-badge;整体留白充足、字号层级清晰(h1 28px / h2 18px / h3 16px / p 14px)。', - '4. 区块 id 与内容:', - ' - id=headlines:「今日要闻 · {完整日期}」;至少 3 条 `.card.highlight-box`(tag + h3 + p + source),标题 15~28 字、前 10 字有钩子。', - ' - id=weather:「今日天气 · {月日}」;`.weather-today` + `.weather-grid` 8~12 张 `.weather-card`;热门城市(北京、上海、广州、深圳、成都、杭州、西安、重庆、武汉、南京、厦门、三亚等),禁止用区域预报顶替城市卡。', - ' - id=world / domestic / finance / tech / sports:各 3~4 条 card。', - ' - id=google:「🔎 Google 热点 · Global Headlines」;2 条 card,标题可英文,p 用中文摘要。', - ' - id=x:「𝕏 X 平台热点 · Trending」;2 条 card,X/Twitter 热议或媒体转述。', - ' - id=asia_pacific:「🌏 亚太观察 · Asia-Pacific」;2 条 card,日韩台港/东盟视角。', - ' - id=commodities:「⛽ 能源大宗 · Commodities」;2 条 card,原油/黄金/铜/OPEC 等。', - ' - id=ai_chips:「🧠 AI & 芯片 · Tech Deep」;2 条 card,大模型/半导体/NVIDIA/出口管制等。', - ' - id=policy:「⚖️ 政策监管 · Policy Watch」;2 条 card,中外监管与重大政策。', - ' - id=universities:「🎓 全球高校 · Research & Campus」;2 条 card,高校科研/校园新闻,标题可英文。', - ' - id=global_markets:「📈 全球股市 · Markets」;2 条 card,美股/欧股/日股/港股等。', - ' - id=community:「💬 社区热议 · Reddit/HN」;2 条 card,Reddit/Hacker News 热议经媒体转述。', - ' - id=science:「🔬 科学前沿 · Science」;2 条 card,科研/太空/医学突破。', - ' - id=culture:「🎬 文娱动态 · Culture」;2 条 card,影视/音乐/游戏行业新闻(禁八卦绯闻)。', - ' - id=startup:「🚀 创投速递 · Startup」;2 条 card,融资/IPO/并购。', - ' - id=crypto:「₿ 加密货币 · Crypto」;0~2 条 card,仅当 BTC/ETH/监管有重大波动时写,否则可省略本 section。', - ' - id=geopolitics:「🛡️ 地缘军事 · Geopolitics」;2 条 card,冲突/制裁/外交/防务(与 id=world 去重)。', - ' - id=local_life:「🏙️ 城市动态 · Cities」;2 条 card,北上广深成渝杭西等主要城市民生/交通/政策。', - ' - id=official_feeds:「📡 权威快讯 · Wire Services」;2 条 card,Reuters/BBC/AP/NPR 等通讯社快讯,标题可英文。', - ' - id=knowledge:「📚 知识小卡片」;1~2 条短知识 card。', - ' - id=history:「📅 近7天新闻早报」;列出近 7 天 daily-news-MMDD.html 链接。', - '5. 每条 card:span.tag + h3 + p(2~4 句)+ div.source。', - '6. footer:新闻来源列表 + 整理时间 + TKMind 品牌;必须含 title、description、mindspace-cover、mindspace-geo;生成 daily-news-MMDD.thumbnail.png。', - '7. 推送微信草稿时转为内联 style 版式(微信不支持 <style> 标签),禁止整页长图;底部追加统一「阅读原文 + 二维码」layout;公众号侧 history/knowledge 可略短。', - '8. 英文栏目允许英文标题或中英并列,但每条必须有中文可读摘要,禁止整段纯英文正文。', - '9. 全页去重(必须):同一 URL、同一核心事件只允许出现一次;若已在 headlines/world/domestic/finance/tech 出现,扩展栏目不得重复同角度,可换角度或跳过;写页前合并预取+tkmind_search+web_search 后再去重。', -].join('\n'); - -/** 兼容旧引用名 */ -export const NEWS_MORNING_TEMPLATE_0910_SPEC = NEWS_MORNING_TEMPLATE_0915_SPEC; - -/** 每日正文必须新搜,禁止把历史早报当新闻来源。 */ -export const NEWS_MORNING_FRESH_CONTENT_SPEC = [ - '内容硬约束(必须遵守,优先于版式):', - '1. 检索必须双路:先 load_skill → web,再同一轮调用 tkmind_search(type=news) 与 web_search,合并去重后写页;禁止只靠 web_search。', - '2. 覆盖面至少包括:国内、国际、财经、科技、体育、天气,以及 Google/X/亚太/能源/AI芯片/政策/高校/全球股市/社区/科学/文娱/创投/加密(有则写)/地缘/城市/权威快讯等扩展栏目;不得凭记忆编造;禁止单独「热搜/热门事件」栏目(原占位分给其它栏目)。', - '3. 若附有「专用联网搜索预取」,必须优先从中选题并写入对应栏目;预取有结果时禁止输出「未能获取」类空页。', - '4. 若 tkmind_search / web_search 暂时不可用,但预取 brief 已有条目:仍必须用预取结果写满各栏目,并在 source 标注预取来源 URL。', - '5. 仅当预取 brief 各路均为空且双路搜索均失败时,才允许写「检索暂不可用」说明页;禁止用历史 daily-news 正文充数。', - '6. 标题规则:中文标题 15~28 字;英文栏目标题可英文或中英并列;每条必须可核验并写来源 URL。', - '7. 扩展英文栏目:优先用预取 brief 对应分组,可保留英文标题,正文用 2~4 句中文摘要。', - '8. 全页去重:同一事件(如「美联储加息」「Fed rate hike」)只保留一条;主栏目优先,扩展栏目必须不同角度或跳过;禁止复制粘贴同段摘要。', - '9. 禁止复制、改写历史 daily-news-*.html 的正文;旧稿仅作 HTML 结构参考。', -].join('\n'); function normalizeBoolean(value, fallback = false) { if (value == null || value === '') return fallback; @@ -457,10 +408,7 @@ export function buildNewsMorningAutoGenerationTask(config, { now = Date.now(), s userId: config.sourceUserId, title: '每日新闻早报页面(自动)', taskSpec: [ - NEWS_MORNING_FRESH_CONTENT_SPEC, - NEWS_MORNING_SEO_SPEC, - NEWS_MORNING_TEMPLATE_0910_SPEC, - `版式参照(只读结构):${NEWS_MORNING_TEMPLATE_REFERENCE_URL}`, + buildNewsMorningTemplateSpec(config?.templateId), `今日日期:${dateLabel.iso}(${dateLabel.year}年${dateLabel.month}月${dateLabel.day}日)。正文必须是这一天的新闻,不能是其它日期的旧稿。`, `输出文件名必须是 daily-news-${dateLabel.mmdd}.html(可覆盖同名旧文件)。`, String(config?.taskSpecAppend ?? '').trim(), @@ -1137,7 +1085,8 @@ function defaultsFromEnv(env = process.env) { forceGenerateOnce: false, forcePushOnce: false, forcePushRequireToday: true, - templateVersion: NEWS_MORNING_TEMPLATE_VERSION, + templateId: resolveNewsMorningTemplateId(null, env), + templateVersion: getNewsMorningTemplate(null, env).version, }; } @@ -1200,7 +1149,8 @@ export function createWechatNewsMorningDraftService( taskSpecAppend: String(stored.taskSpecAppend ?? '').trim(), forcePushOnce: normalizeBoolean(stored.forcePushOnce, false), forcePushRequireToday: normalizeBoolean(stored.forcePushRequireToday, true), - templateVersion: NEWS_MORNING_TEMPLATE_VERSION, + templateId: resolveNewsMorningTemplateId(stored.templateId, env), + templateVersion: getNewsMorningTemplate(stored.templateId, env).version, }; } @@ -1283,8 +1233,9 @@ export function createWechatNewsMorningDraftService( page: pageSummary, article, template: { - version: NEWS_MORNING_TEMPLATE_VERSION, - spec: [NEWS_MORNING_FRESH_CONTENT_SPEC, NEWS_MORNING_SEO_SPEC, NEWS_MORNING_TEMPLATE_0910_SPEC].join('\n'), + id: resolveNewsMorningTemplateId(config.templateId, env), + version: getNewsMorningTemplate(config.templateId, env).version, + spec: buildNewsMorningTemplateSpec(config.templateId, env), }, }; } @@ -1338,10 +1289,16 @@ export function createWechatNewsMorningDraftService( }; }, - getTemplate() { + async getTemplate() { + const config = await this.getConfig(); + const template = getNewsMorningTemplate(config.templateId, env); return { - version: NEWS_MORNING_TEMPLATE_VERSION, - spec: [NEWS_MORNING_FRESH_CONTENT_SPEC, NEWS_MORNING_SEO_SPEC, NEWS_MORNING_TEMPLATE_0910_SPEC].join('\n'), + id: template.id, + label: template.label, + version: template.version, + supportsCardImages: template.supportsCardImages, + available: NEWS_MORNING_TEMPLATE_IDS, + spec: buildNewsMorningTemplateSpec(config.templateId, env), }; }, @@ -1398,8 +1355,12 @@ export function createWechatNewsMorningDraftService( payload.taskSpecAppend === undefined ? current.taskSpecAppend : String(payload.taskSpecAppend ?? '').trim(), - templateVersion: NEWS_MORNING_TEMPLATE_VERSION, + templateId: resolveNewsMorningTemplateId( + payload.templateId === undefined ? current.templateId : payload.templateId, + env, + ), }; + next.templateVersion = getNewsMorningTemplate(next.templateId, env).version; const now = Date.now(); await pool.query( `INSERT INTO ${CONFIG_TABLE} (config_key, config_json, updated_by, updated_at) diff --git a/wechat-news-morning-search.mjs b/wechat-news-morning-search.mjs index 401baf9..b4b0c1c 100644 --- a/wechat-news-morning-search.mjs +++ b/wechat-news-morning-search.mjs @@ -164,6 +164,7 @@ export function formatNewsMorningSearchBrief(groups = [], { dateLabel = null, de const lines = [ '【专用联网搜索预取】以下结果来自专用新闻检索(与 web_search 并列,不是替代)。', '写页面时必须:1)优先从下列结果覆盖各栏目;2)同一轮再调用 tkmind_search(type=news) 与 web_search 补漏并核对来源;3)禁止只使用 web_search;4)全页去重:同一 URL/同一事件只允许出现一次,优先保留要闻/国内/国际/财经/科技,扩展栏目用不同角度或跳过;5)英文栏目标题可英文,正文用中文摘要并附原文链接。', + '条目下的「图:」是该条新闻的原始配图地址,模板若支持卡片配图必须原样使用,禁止张冠李戴或自行编造图片地址。', ]; if (dateLabel?.iso) lines.push(`预取日期:${dateLabel.iso}`); if (dedupeRemovedCount > 0) { @@ -187,6 +188,8 @@ export function formatNewsMorningSearchBrief(groups = [], { dateLabel = null, de const snippet = truncateSnippet(item.snippet); lines.push(`- ${title}`); if (item.url) lines.push(` ${item.url}`); + if (item.image) lines.push(` 图:${item.image}`); + if (item.publishedAt) lines.push(` 时间:${item.publishedAt}`); if (snippet) lines.push(` ${snippet}`); } }