Add major-news anchor prefetch and fix news002 weather WeChat rendering.

Anchor queries boost retrieval for diplomacy, finance, and AI headlines; inline conversion now reads news002 wc/we/wt weather cards so drafts show city forecasts again.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-24 09:25:19 +08:00
parent af8bbc46a6
commit b1f8fbb6e4
7 changed files with 186 additions and 13 deletions
+1 -1
View File
@@ -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。',
+30 -7
View File
@@ -495,9 +495,21 @@ function isPopularTouristCityName(raw) {
}
function extractWeatherCityName(cardHtml) {
return normalizeWeatherCityLabel(
const fromLegacy = normalizeWeatherCityLabel(
extractInnerHtml(cardHtml, /<span class="w-city">([\s\S]*?)<\/span>/i),
);
if (fromLegacy) return fromLegacy;
return normalizeWeatherCityLabel(
extractInnerHtml(cardHtml, /<div class="wc">([\s\S]*?)<\/div>/i),
);
}
function extractWeatherCondAndTemp(cardHtml) {
const cond = stripInlineText(extractInnerHtml(cardHtml, /<span class="w-cond">([\s\S]*?)<\/span>/i))
|| stripInlineText(extractInnerHtml(cardHtml, /<div class="we">([\s\S]*?)<\/div>/i));
const temp = stripInlineText(extractInnerHtml(cardHtml, /<span class="w-temp">([\s\S]*?)<\/span>/i))
|| stripInlineText(extractInnerHtml(cardHtml, /<div class="wt">([\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, /<span class="w-city">([\s\S]*?)<\/span>/i));
const cond = stripInlineText(extractInnerHtml(cardHtml, /<span class="w-cond">([\s\S]*?)<\/span>/i));
const temp = stripInlineText(extractInnerHtml(cardHtml, /<span class="w-temp">([\s\S]*?)<\/span>/i));
const city = extractWeatherCityName(cardHtml)
|| stripInlineText(extractInnerHtml(cardHtml, /<span class="w-city">([\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 `<p style="margin:0;font-size:13px;color:${BRAND.navyMid};line-height:1.55"><strong style="color:${BRAND.navy}">${city}</strong>${detail ? `<br>${detail}` : ''}</p>`;
}
@@ -553,11 +565,13 @@ function renderWeatherSection(sectionHtml, { sectionId = 'weather', maxDetailCha
const todayBlock = extractDivBlock(sectionHtml, 'weather-today');
if (todayBlock) {
const cityRaw = extractInnerHtml(todayBlock, /<div class="wt-city">([\s\S]*?)<\/div>/i);
const cityRaw = extractInnerHtml(todayBlock, /<div class="wt-city">([\s\S]*?)<\/div>/i)
|| extractInnerHtml(todayBlock, /<div class="c">([\s\S]*?)<\/div>/i);
const cityName = normalizeWeatherCityLabel(cityRaw);
const cityLabel = stripInlineText(cityRaw).replace(/^📍\s*/u, '📍 ');
const detail = truncatePlainText(
stripInlineText(extractInnerHtml(todayBlock, /<div class="wt-detail">([\s\S]*?)<\/div>/i)),
stripInlineText(extractInnerHtml(todayBlock, /<div class="wt-detail">([\s\S]*?)<\/div>/i))
|| stripInlineText(extractInnerHtml(todayBlock, /<div class="d">([\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 ?? '', /<div class="d">([\s\S]*?)<\/div>/i));
if (summary) {
parts.push(
`<section style="margin:0 0 11px;background:${BRAND.tintBlue};border-left:3px solid ${BRAND.navy};border-radius:10px;padding:12px 13px">`,
`<p style="margin:0;font-size:13px;color:${BRAND.navySoft};line-height:1.8">${truncatePlainText(summary, maxDetailChars)}</p>`,
'</section>',
);
}
}
return parts.filter(Boolean).join('');
}
+16
View File
@@ -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 = `<div class="section" id="weather">
<div class="section-title">🌤️ 今日天气 · 9月24日</div>
<div class="weather-today"><div><div class="c">全国重点城市 · 9月24日</div><div class="d">中央气象台预报,广东广西局地大到暴雨</div></div></div>
<div class="weather-grid">
<div class="weather-card"><div class="wc">北京</div><div class="we">🌤</div><div class="wt">14/26℃</div></div>
<div class="weather-card"><div class="wc">上海</div><div class="we">🌧</div><div class="wt">22/28℃</div></div>
</div>
</div>`;
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,
+55 -2
View File
@@ -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,
+67
View File
@@ -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({
+9 -3
View File
@@ -71,13 +71,19 @@ function normalizeWeatherCity(raw) {
function parseWeatherCard(cardHtml) {
const city = normalizeWeatherCity(
cardHtml.match(/<span class="w-city">([\s\S]*?)<\/span>/i)?.[1] ?? '',
cardHtml.match(/<span class="w-city">([\s\S]*?)<\/span>/i)?.[1]
?? cardHtml.match(/<div class="wc">([\s\S]*?)<\/div>/i)?.[1]
?? '',
);
const cond = stripInlineText(
cardHtml.match(/<span class="w-cond">([\s\S]*?)<\/span>/i)?.[1] ?? '',
cardHtml.match(/<span class="w-cond">([\s\S]*?)<\/span>/i)?.[1]
?? cardHtml.match(/<div class="we">([\s\S]*?)<\/div>/i)?.[1]
?? '',
);
const temp = stripInlineText(
cardHtml.match(/<span class="w-temp">([\s\S]*?)<\/span>/i)?.[1] ?? '',
cardHtml.match(/<span class="w-temp">([\s\S]*?)<\/span>/i)?.[1]
?? cardHtml.match(/<div class="wt">([\s\S]*?)<\/div>/i)?.[1]
?? '',
);
if (!city) return null;
return { city, cond, temp };
@@ -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 = `<div class="weather-grid">
<div class="weather-card"><div class="wc">北京</div><div class="we">🌤</div><div class="wt">14/26℃</div></div>
</div>`;
const text = buildWeatherPushTextFromHtml(html, { city: '北京' });
assert.match(text, /14\/26℃/u);
});
test('normalizePushWeatherCity strips suffix 市', () => {
assert.equal(normalizePushWeatherCity('北京市'), '北京');
});