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:
@@ -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。',
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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('北京市'), '北京');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user