From 5708b333ccb1bd07b6b89f7aa256a4f8f1f18d18 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 21 Sep 2026 09:19:41 +0800 Subject: [PATCH] feat(news): add news002 morning-report template with dedup gate and card images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Freeze the 2026-09-18 layout as news001 and add news002 alongside it. Analysis of the 0918 production page found 49 news cards covering only ~20 distinct events (4x "王毅同鲁比奥通电话", 5x "东博会南宁开幕"), citing just 7 URLs that are all section landing pages rather than article permalinks, with zero images. The root cause is structural: 23 fixed sections with fixed card quotas force padding when the day yields fewer events. news002 changes the contract so events decide sections, not the reverse: core sections always render, flex sections are dropped entirely when they have no unused event, total story cards are capped at 28, and every card carries a data-event-key fingerprint. Add wechat-news-morning-dedup.mjs as a post-generation gate, since the prompt level "no duplicates" rule is unverifiable. It dedupes by event key, then by article permalink (deliberately ignoring section landing pages, which would otherwise delete most of the page), then by CJK bigram title similarity, since the existing whitespace-tokenised matcher does not work on Chinese titles. Carry per-item images through normalizeSearchResult into the prefetch brief so cards can render real article thumbnails with a placeholder fallback. Teach the WeChat inline converter both DOM variants (lead-card, masthead meta, lede). Per-card images in the WeChat draft body remain a known gap. Template selection is config/env driven and still defaults to news001. Co-authored-by: Cursor --- docs/news-morning-templates.md | 118 ++++++++ news-morning-templates.mjs | 390 +++++++++++++++++++++++++++ news-morning-templates.test.mjs | 106 ++++++++ scripts/preview-news002-template.mjs | 246 +++++++++++++++++ search-capability.mjs | 21 +- wechat-daily-news-inline.mjs | 35 ++- wechat-daily-news-inline.test.mjs | 50 ++++ wechat-news-morning-dedup.mjs | 275 +++++++++++++++++++ wechat-news-morning-dedup.test.mjs | 164 +++++++++++ wechat-news-morning-draft-worker.mjs | 30 +++ wechat-news-morning-draft.mjs | 121 +++------ wechat-news-morning-search.mjs | 3 + 12 files changed, 1472 insertions(+), 87 deletions(-) create mode 100644 docs/news-morning-templates.md create mode 100644 news-morning-templates.mjs create mode 100644 news-morning-templates.test.mjs create mode 100644 scripts/preview-news002-template.mjs create mode 100644 wechat-news-morning-dedup.mjs create mode 100644 wechat-news-morning-dedup.test.mjs 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('')}
`)} + + + +${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}`); } }