diff --git a/news-morning-templates.mjs b/news-morning-templates.mjs index a5ced6a..ea53768 100644 --- a/news-morning-templates.mjs +++ b/news-morning-templates.mjs @@ -530,7 +530,7 @@ export const NEWS002_FRESH_CONTENT_SPEC = [ '2. 合并后先产出「今日事件表」并去重,再按事件数决定写几个栏目 —— 是事件决定栏目,不是栏目决定事件。', `3. 全页独立事件不超过 ${NEWS002_MAX_STORY_CARDS} 条;为写满各 core/flex 栏目,必须继续 tkmind_search / web_search 补独立事件,禁止把已写过的事件换个标题再写一遍。`, '4. 判重标准:同一 URL、同一主体+同一动作、中英文互译标题、同一事件的不同媒体转述,都算同一事件。', - '5. 若附有「专用联网搜索预取」,必须优先从中选题;预取有结果时禁止输出「未能获取」类空页。', + '5. 若附有「专用联网搜索预取」,必须优先从中选题;预取含重大新闻锚点补搜(元首外交、央行定调、AI/芯片发布、市场异动等),当日硬头条必须在 headlines/world/business/tech 体现;预取有结果时禁止输出「未能获取」类空页。', '6. 若 tkmind_search / web_search 暂时不可用但预取 brief 有条目:用预取结果写页,并在 source 标注预取来源 URL。', '7. 仅当预取各路均为空且双路搜索均失败时,才允许写「检索暂不可用」说明页;禁止用历史 daily-news 正文充数。', '8. 标题规则:中文 15~26 字;英文来源须转写为中文标题,可在括号内保留英文原题;每条必须可核验并写来源 URL。', diff --git a/wechat-daily-news-inline.mjs b/wechat-daily-news-inline.mjs index 7209f56..9642b0d 100644 --- a/wechat-daily-news-inline.mjs +++ b/wechat-daily-news-inline.mjs @@ -495,9 +495,21 @@ function isPopularTouristCityName(raw) { } function extractWeatherCityName(cardHtml) { - return normalizeWeatherCityLabel( + const fromLegacy = normalizeWeatherCityLabel( extractInnerHtml(cardHtml, /([\s\S]*?)<\/span>/i), ); + if (fromLegacy) return fromLegacy; + return normalizeWeatherCityLabel( + extractInnerHtml(cardHtml, /
([\s\S]*?)<\/div>/i), + ); +} + +function extractWeatherCondAndTemp(cardHtml) { + const cond = stripInlineText(extractInnerHtml(cardHtml, /([\s\S]*?)<\/span>/i)) + || stripInlineText(extractInnerHtml(cardHtml, /
([\s\S]*?)<\/div>/i)); + const temp = stripInlineText(extractInnerHtml(cardHtml, /([\s\S]*?)<\/span>/i)) + || stripInlineText(extractInnerHtml(cardHtml, /
([\s\S]*?)<\/div>/i)); + return { cond, temp }; } function selectTouristWeatherCards(cards = []) { @@ -518,11 +530,11 @@ function formatTouristWeatherSectionTitle(sourceTitleHtml) { } function renderWeatherCityCard(cardHtml) { - const city = stripInlineText(extractInnerHtml(cardHtml, /([\s\S]*?)<\/span>/i)); - const cond = stripInlineText(extractInnerHtml(cardHtml, /([\s\S]*?)<\/span>/i)); - const temp = stripInlineText(extractInnerHtml(cardHtml, /([\s\S]*?)<\/span>/i)); + const city = extractWeatherCityName(cardHtml) + || stripInlineText(extractInnerHtml(cardHtml, /([\s\S]*?)<\/span>/i)); + const { cond, temp } = extractWeatherCondAndTemp(cardHtml); if (!city) return ''; - const detail = [cond, temp && temp.length <= 8 ? temp : ''].filter(Boolean).join(' · '); + const detail = [cond, temp && temp.length <= 12 ? temp : ''].filter(Boolean).join(' · '); return `

${city}${detail ? `
${detail}` : ''}

`; } @@ -553,11 +565,13 @@ function renderWeatherSection(sectionHtml, { sectionId = 'weather', maxDetailCha const todayBlock = extractDivBlock(sectionHtml, 'weather-today'); if (todayBlock) { - const cityRaw = extractInnerHtml(todayBlock, /
([\s\S]*?)<\/div>/i); + const cityRaw = extractInnerHtml(todayBlock, /
([\s\S]*?)<\/div>/i) + || extractInnerHtml(todayBlock, /
([\s\S]*?)<\/div>/i); const cityName = normalizeWeatherCityLabel(cityRaw); const cityLabel = stripInlineText(cityRaw).replace(/^📍\s*/u, '📍 '); const detail = truncatePlainText( - stripInlineText(extractInnerHtml(todayBlock, /
([\s\S]*?)<\/div>/i)), + stripInlineText(extractInnerHtml(todayBlock, /
([\s\S]*?)<\/div>/i)) + || stripInlineText(extractInnerHtml(todayBlock, /
([\s\S]*?)<\/div>/i)), maxDetailChars, ); if (isPopularTouristCityName(cityName) && (cityLabel || detail)) { @@ -581,6 +595,15 @@ function renderWeatherSection(sectionHtml, { sectionId = 'weather', maxDetailCha background: BRAND.tintBlue, padding: '10px 11px', })); + } else { + const summary = stripInlineText(extractInnerHtml(todayBlock ?? '', /
([\s\S]*?)<\/div>/i)); + if (summary) { + parts.push( + `
`, + `

${truncatePlainText(summary, maxDetailChars)}

`, + '
', + ); + } } return parts.filter(Boolean).join(''); } diff --git a/wechat-daily-news-inline.test.mjs b/wechat-daily-news-inline.test.mjs index aa42164..b6a98e7 100644 --- a/wechat-daily-news-inline.test.mjs +++ b/wechat-daily-news-inline.test.mjs @@ -65,6 +65,22 @@ test('renderWeatherSection skips regional fallback cards and national summary', assert.doesNotMatch(rendered, /全国概况/u); }); +test('renderWeatherSection supports news002 wc/we/wt cards', () => { + const news002Weather = `
+
🌤️ 今日天气 · 9月24日
+
全国重点城市 · 9月24日
中央气象台预报,广东广西局地大到暴雨
+
+
北京
🌤
14/26℃
+
上海
🌧
22/28℃
+
+
`; + const rendered = wechatDailyNewsInlineInternals.renderWeatherSection(news002Weather); + assert.match(rendered, /北京/u); + assert.match(rendered, /14\/26℃/u); + assert.match(rendered, /上海/u); + assert.match(rendered, /22\/28℃/u); +}); + test('buildWechatInlineContent renders weather and optional quick links', () => { const built = wechatDailyNewsInlineInternals.buildWechatInlineContent(WEATHER_HTML, { includeFixedLayout: false, diff --git a/wechat-news-morning-search.mjs b/wechat-news-morning-search.mjs index 49b5a97..dbb84ca 100644 --- a/wechat-news-morning-search.mjs +++ b/wechat-news-morning-search.mjs @@ -48,6 +48,10 @@ export function resolveNewsMorningSearxngConcurrency(env = process.env) { return Math.min(8, Math.floor(raw)); } +export function shouldUseNewsMorningAnchorQueries(env = process.env) { + return envFlag(env.MEMIND_NEWS_MORNING_SEARXNG_ANCHOR_QUERIES, true); +} + async function mapWithConcurrency(items, concurrency, mapper) { const list = Array.isArray(items) ? items : []; const results = new Array(list.length); @@ -98,6 +102,31 @@ export function buildNewsMorningSearchQueries(dateLabel) { ].filter((item) => item.query.trim()); } +/** + * 重大新闻锚点检索:泛化 query 容易漏掉元首外交、央行定调、AI 发布等硬头条, + * 额外按垂直领域补搜并归并到既有栏目 id(lead/world/finance/tech…)。 + */ +export function buildNewsMorningAnchorQueries(dateLabel) { + const day = String(dateLabel?.iso ?? '').trim(); + const year = String(dateLabel?.year ?? day.slice(0, 4) ?? new Date().getFullYear()).trim(); + const fresh = `今天 最新 ${year}年`; + const englishFresh = `today latest ${year}`; + return [ + { targetId: 'lead', title: '头条锚点', query: `${fresh} 重大 要闻 突发 头条` }, + { targetId: 'world', title: '国际外交锚点', query: `${fresh} 国际 外交 元首 会晤 访美 中美 联合国` }, + { targetId: 'world', title: '国际英文锚点', query: `${englishFresh} summit diplomacy state visit UN assembly` }, + { targetId: 'domestic', title: '国内政策锚点', query: `${fresh} 国务院 发布 重要 通知 新规` }, + { targetId: 'finance', title: '财经锚点', query: `${fresh} 央行 降息 降准 美联储 A股 重大 财经` }, + { targetId: 'finance', title: '全球财经锚点', query: `${englishFresh} Federal Reserve interest rate earnings IPO merger acquisition` }, + { targetId: 'global_markets', title: '全球股市锚点', query: `${englishFresh} stock market rally crash earnings major wall street` }, + { targetId: 'tech', title: '科技锚点', query: `${fresh} 科技 发布 大模型 AI 芯片 华为 苹果 重大` }, + { targetId: 'ai_chips', title: 'AI芯片锚点', query: `${englishFresh} OpenAI Anthropic Google DeepMind NVIDIA chip export LLM launch` }, + { targetId: 'geopolitics', title: '地缘外交锚点', query: `${fresh} 中美元首 外交 会晤 制裁 防务 冲突` }, + { targetId: 'startup', title: '创投锚点', query: `${englishFresh} startup unicorn funding IPO acquisition tech ${year}` }, + { targetId: 'commodities', title: '能源大宗锚点', query: `${englishFresh} oil price OPEC gold crude major move ${year}` }, + ].filter((item) => item.query.trim()); +} + const STALE_NEWS_PATTERNS = [ /wikinews\.org\/wiki\/\d{4}年/u, /中文報[紙纸]頭條/u, @@ -265,8 +294,8 @@ export function formatNewsMorningSearchBrief(groups = [], { ranking = null, } = {}) { const lines = [ - '【专用联网搜索预取】以下结果来自专用新闻检索(与 web_search 并列,不是替代)。', - '写页面时必须:1)优先从下列结果覆盖各栏目;2)同一轮再调用 tkmind_search(type=news) 与 web_search 补漏并核对来源;3)禁止只使用 web_search;4)全页去重:同一 URL/同一事件只允许出现一次,优先保留要闻/国内/国际/财经/科技,扩展栏目用不同角度或跳过;5)英文栏目标题可英文,正文用中文摘要并附原文链接。', + '【专用联网搜索预取】以下结果来自专用新闻检索(含重大新闻锚点补搜,与 web_search 并列,不是替代)。', + '写页面时必须:1)优先从下列结果覆盖各栏目;当日若有元首外交/央行定调/AI 重大发布/市场异动等硬头条,必须在 headlines/world/business/tech 中体现;2)同一轮再调用 tkmind_search(type=news) 与 web_search 补漏并核对来源;3)禁止只使用 web_search;4)全页去重:同一 URL/同一事件只允许出现一次,优先保留要闻/国内/国际/财经/科技,扩展栏目用不同角度或跳过;5)英文栏目标题可英文,正文用中文摘要并附原文链接。', '条目下的「图:」是该条新闻的原始配图地址,模板若支持卡片配图必须原样使用,禁止张冠李戴或自行编造图片地址。', ]; if (dateLabel?.iso) lines.push(`预取日期:${dateLabel.iso}`); @@ -394,6 +423,30 @@ export async function prefetchNewsMorningSearxngBundle({ ).slice(0, limit); return { ...item, results }; }); + if (shouldUseNewsMorningAnchorQueries(env)) { + const anchorQueries = buildNewsMorningAnchorQueries(dateLabel); + const anchorGroups = await mapWithConcurrency(anchorQueries, concurrency, async (item) => { + const raw = await searchNewsThenGeneral(item.query, { + endpoint, + searchImpl, + limit: limit + 3, + timeoutMs, + useNewsCategory, + engines, + }); + const results = filterFreshNewsMorningSearchResults( + Array.isArray(raw) ? raw : [], + { year: dateLabel.year }, + ).slice(0, limit); + return { + id: item.targetId, + title: item.title, + query: item.query, + results, + }; + }); + searxngGroups = mergeNewsMorningSearchGroups(queries, searxngGroups, anchorGroups); + } } else if (!engineCollection) { return { brief: emptyBrief, diff --git a/wechat-news-morning-search.test.mjs b/wechat-news-morning-search.test.mjs index 31a87e6..c7001b1 100644 --- a/wechat-news-morning-search.test.mjs +++ b/wechat-news-morning-search.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildNewsMorningSearchQueries, + buildNewsMorningAnchorQueries, dedupeNewsMorningSearchGroups, mergeNewsMorningSearchGroups, filterFreshNewsMorningSearchResults, @@ -10,6 +11,7 @@ import { prefetchNewsMorningSearxngBrief, prefetchNewsMorningSearxngBundle, resolveNewsMorningSearxngConcurrency, + shouldUseNewsMorningAnchorQueries, shouldUseNewsMorningNewsCategory, } from './wechat-news-morning-search.mjs'; @@ -29,6 +31,39 @@ test('buildNewsMorningSearchQueries covers core and extended channels', () => { assert.ok(queries.some((item) => item.id === 'official_feeds' && /reuters\.com/u.test(item.query))); }); +test('buildNewsMorningAnchorQueries covers major vertical anchors', () => { + const anchors = buildNewsMorningAnchorQueries({ + iso: '2026-09-24', + year: '2026', + label: '2026年09月24日', + }); + assert.ok(anchors.length >= 10); + assert.ok(anchors.some((item) => item.targetId === 'world' && /访美|会晤/u.test(item.query))); + assert.ok(anchors.some((item) => item.targetId === 'finance' && /央行|美联储/u.test(item.query))); + assert.ok(anchors.some((item) => item.targetId === 'ai_chips' && /OpenAI|NVIDIA/u.test(item.query))); + assert.ok(anchors.every((item) => item.targetId && item.query.trim())); +}); + +test('mergeNewsMorningSearchGroups merges anchor hits into core groups', () => { + const queries = buildNewsMorningSearchQueries({ iso: '2026-09-24', year: '2026', label: '2026年09月24日' }); + const merged = mergeNewsMorningSearchGroups(queries, [{ + id: 'world', + results: [{ title: '泛化国际', url: 'https://news.example/generic-world' }], + }], [{ + id: 'world', + title: '国际外交锚点', + results: [{ title: '中美元首会晤', url: 'https://news.example/xi-trump' }], + }]); + const world = merged.find((group) => group.id === 'world'); + assert.equal(world.results.length, 2); + assert.ok(world.results.some((item) => /会晤/u.test(item.title))); +}); + +test('shouldUseNewsMorningAnchorQueries defaults on', () => { + assert.equal(shouldUseNewsMorningAnchorQueries({}), true); + assert.equal(shouldUseNewsMorningAnchorQueries({ MEMIND_NEWS_MORNING_SEARXNG_ANCHOR_QUERIES: '0' }), false); +}); + test('filterFreshNewsMorningSearchResults drops wikinews archive hits', () => { const filtered = filterFreshNewsMorningSearchResults([ { title: '2021年9月18日中文報紙頭條', url: 'https://zh.wikinews.org/wiki/2021%E5%B9%B49%E6%9C%8818%E6%97%A5' }, @@ -95,6 +130,7 @@ test('formatNewsMorningSearchBrief requires dual search even when empty', () => { id: 'domestic', title: '国内热点', results: [] }, ], { dateLabel: { iso: '2026-09-15' }, dedupeRemovedCount: 2 }); assert.match(text, /专用联网搜索预取/); + assert.match(text, /重大新闻锚点/); assert.match(text, /tkmind_search/); assert.match(text, /web_search/); assert.match(text, /禁止只使用 web_search/); @@ -292,6 +328,37 @@ test('prefetchNewsMorningSearxngBundle returns structured groups for fallback', assert.match(bundle.brief, /专用联网搜索预取/); }); +test('prefetchNewsMorningSearxngBundle runs anchor queries and merges into groups', async () => { + const calls = []; + const bundle = await prefetchNewsMorningSearxngBundle({ + now: Date.parse('2026-09-24T05:00:00+08:00'), + env: { + TKMIND_SEARCH_SEARXNG_URL: 'http://127.0.0.1:20080/search', + MEMIND_NEWS_ENGINE_URL: '', + }, + searchImpl: async (query) => { + calls.push(query); + if (/访美|会晤/u.test(query)) { + return [{ title: '中美元首会晤', url: 'https://news.example/xi-trump', snippet: '外交' }]; + } + if (/OpenAI|NVIDIA/u.test(query)) { + return [{ title: 'OpenAI 发布新模型', url: 'https://news.example/openai', snippet: 'AI' }]; + } + return [{ title: `通用 ${query.slice(0, 12)}`, url: `https://news.example/${calls.length}`, snippet: '摘要' }]; + }, + fetchImpl: async () => ({ + ok: true, + headers: { get: () => 'text/html' }, + text: async () => '', + }), + }); + assert.ok(calls.some((query) => /访美|会晤/u.test(query))); + assert.ok(calls.some((query) => /OpenAI|NVIDIA/u.test(query))); + const world = bundle.groups.find((group) => group.id === 'world'); + assert.ok(world?.results?.some((item) => /会晤/u.test(item.title))); + assert.match(bundle.brief, /重大新闻锚点/); +}); + test('prefetchNewsMorningSearxngBrief skips news category by default', async () => { const calls = []; const brief = await prefetchNewsMorningSearxngBrief({ diff --git a/wechat/push-content/weather-delivery.mjs b/wechat/push-content/weather-delivery.mjs index 0b2baef..302c533 100644 --- a/wechat/push-content/weather-delivery.mjs +++ b/wechat/push-content/weather-delivery.mjs @@ -71,13 +71,19 @@ function normalizeWeatherCity(raw) { function parseWeatherCard(cardHtml) { const city = normalizeWeatherCity( - cardHtml.match(/([\s\S]*?)<\/span>/i)?.[1] ?? '', + cardHtml.match(/([\s\S]*?)<\/span>/i)?.[1] + ?? cardHtml.match(/
([\s\S]*?)<\/div>/i)?.[1] + ?? '', ); const cond = stripInlineText( - cardHtml.match(/([\s\S]*?)<\/span>/i)?.[1] ?? '', + cardHtml.match(/([\s\S]*?)<\/span>/i)?.[1] + ?? cardHtml.match(/
([\s\S]*?)<\/div>/i)?.[1] + ?? '', ); const temp = stripInlineText( - cardHtml.match(/([\s\S]*?)<\/span>/i)?.[1] ?? '', + cardHtml.match(/([\s\S]*?)<\/span>/i)?.[1] + ?? cardHtml.match(/
([\s\S]*?)<\/div>/i)?.[1] + ?? '', ); if (!city) return null; return { city, cond, temp }; diff --git a/wechat/push-content/weather-delivery.test.mjs b/wechat/push-content/weather-delivery.test.mjs index 70bc25c..94a1b28 100644 --- a/wechat/push-content/weather-delivery.test.mjs +++ b/wechat/push-content/weather-delivery.test.mjs @@ -28,6 +28,14 @@ test('buildWeatherPushTextFromHtml renders city card and dressing advice', () => assert.match(text, /上海/u); }); +test('buildWeatherPushTextFromHtml supports news002 wc/we/wt cards', () => { + const html = `
+
北京
🌤
14/26℃
+
`; + const text = buildWeatherPushTextFromHtml(html, { city: '北京' }); + assert.match(text, /14\/26℃/u); +}); + test('normalizePushWeatherCity strips suffix 市', () => { assert.equal(normalizePushWeatherCity('北京市'), '北京'); });