feat(wechat): 早报生成预取 SearXNG 新闻,覆盖国内国际热点
生成任务不再只靠 web_search:先并行检索国内/国际/热门事件等,再要求 tkmind_search 与 web_search 补漏写页。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,10 +7,16 @@ export async function searchSearxng(query, {
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = Number(process.env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000),
|
||||
signal,
|
||||
categories = '',
|
||||
language = '',
|
||||
} = {}) {
|
||||
if (!endpoint) throw new Error('SearXNG endpoint is not configured');
|
||||
try { if (!['http:', 'https:'].includes(new URL(endpoint).protocol)) throw new Error('unsupported protocol'); } catch { throw new Error('SearXNG endpoint is not configured safely'); }
|
||||
const url = new URL(endpoint); url.searchParams.set('q', query); url.searchParams.set('format', 'json');
|
||||
const url = new URL(endpoint);
|
||||
url.searchParams.set('q', query);
|
||||
url.searchParams.set('format', 'json');
|
||||
if (categories) url.searchParams.set('categories', String(categories));
|
||||
if (language) url.searchParams.set('language', String(language));
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const response = await fetchImpl(url, {
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
|
||||
@@ -200,6 +200,21 @@ test('SearXNG adapter normalizes provider results without requiring a live netwo
|
||||
assert.deepEqual(result[0], { title: 'Goose', url: 'https://example.com', snippet: 'snippet', source: 'searxng', rank: 1 });
|
||||
});
|
||||
|
||||
test('SearXNG adapter can request news category and language', async () => {
|
||||
let requested;
|
||||
await searchSearxng('今日热点', {
|
||||
endpoint: 'http://search.local/search',
|
||||
categories: 'news',
|
||||
language: 'zh-CN',
|
||||
fetchImpl: async (url) => {
|
||||
requested = String(url);
|
||||
return { ok: true, json: async () => ({ results: [] }) };
|
||||
},
|
||||
});
|
||||
assert.match(requested, /categories=news/);
|
||||
assert.match(requested, /language=zh-CN/);
|
||||
});
|
||||
|
||||
test('GitHub code adapter sends bounded queries and normalizes results', async () => {
|
||||
let requested;
|
||||
const result = await searchGithubCode('repo:openai goose', { limit: 50, fetchImpl: async (url, options) => { requested = { url: String(url), options }; return { ok: true, json: async () => ({ items: [{ repository: { full_name: 'openai/goose', description: 'agent' }, path: 'src/goose.rs', html_url: 'https://github.com/openai/goose/blob/main/src/goose.rs' }] }) }; } });
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isNewsMorningPageForToday,
|
||||
isWithinNewsMorningGenerateLeadWindow,
|
||||
} from './wechat-news-morning-draft.mjs';
|
||||
import { prefetchNewsMorningSearxngBrief } from './wechat-news-morning-search.mjs';
|
||||
import {
|
||||
isWechatNewsMorningDraftWorkerEnabled,
|
||||
wechatNewsMorningDraftExecutionTimeoutMs,
|
||||
@@ -40,6 +41,7 @@ export function startWechatNewsMorningDraftWorker({
|
||||
h5Root = null,
|
||||
env = process.env,
|
||||
executeTask = executeScheduledTask,
|
||||
prefetchNewsSearch = prefetchNewsMorningSearxngBrief,
|
||||
logger = console,
|
||||
intervalMs = wechatNewsMorningDraftWorkerIntervalMs(env),
|
||||
executionTimeoutMs = wechatNewsMorningDraftExecutionTimeoutMs(env),
|
||||
@@ -66,7 +68,20 @@ export function startWechatNewsMorningDraftWorker({
|
||||
if (generationInFlightDateKey === dateKey) return null;
|
||||
generationInFlightDateKey = dateKey;
|
||||
try {
|
||||
const task = buildNewsMorningAutoGenerationTask(config, { now });
|
||||
let searchBrief = '';
|
||||
try {
|
||||
searchBrief = await prefetchNewsSearch({
|
||||
now,
|
||||
timezone: config.timezone,
|
||||
env,
|
||||
});
|
||||
} catch (prefetchError) {
|
||||
logger.warn?.(
|
||||
'[NewsMorningDraft] searxng prefetch failed:',
|
||||
prefetchError instanceof Error ? prefetchError.message : prefetchError,
|
||||
);
|
||||
}
|
||||
const task = buildNewsMorningAutoGenerationTask(config, { now, searchBrief });
|
||||
await executeTask(task, {
|
||||
userAuth,
|
||||
tkmindProxy,
|
||||
|
||||
@@ -204,6 +204,7 @@ test('generateToday skips existing page unless force is set', async () => {
|
||||
executeTask: async (task) => {
|
||||
executeCalls.push(task.taskSpec);
|
||||
},
|
||||
prefetchNewsSearch: async () => '【专用联网搜索预取】国内国际热门事件',
|
||||
intervalMs: 30_000,
|
||||
runOnStart: false,
|
||||
setIntervalFn: () => ({ unref() {} }),
|
||||
@@ -219,7 +220,9 @@ test('generateToday skips existing page unless force is set', async () => {
|
||||
assert.equal(forced.page.slug, 'daily-news-0911');
|
||||
assert.equal(executeCalls.length, 1);
|
||||
assert.match(executeCalls[0], /禁止复制/u);
|
||||
assert.match(executeCalls[0], /联网搜索/u);
|
||||
assert.match(executeCalls[0], /tkmind_search/u);
|
||||
assert.match(executeCalls[0], /专用联网搜索预取/u);
|
||||
assert.match(executeCalls[0], /国内国际热门事件/u);
|
||||
} finally {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -39,11 +39,13 @@ export const NEWS_MORNING_TEMPLATE_0910_SPEC = [
|
||||
/** 每日正文必须新搜,禁止把历史早报当新闻来源。 */
|
||||
export const NEWS_MORNING_FRESH_CONTENT_SPEC = [
|
||||
'内容硬约束(必须遵守,优先于版式参考):',
|
||||
'1. 必须先联网搜索「执行当日」国内外要闻、财经、科技、体育等,再写页面;不得凭记忆编造。',
|
||||
'2. 禁止复制、改写、微调昨日或任何历史 daily-news-*.html 的新闻标题与正文。',
|
||||
'3. 最近一版 daily-news 只允许当作版式参考(hero / section / card 结构);读完后必须丢弃其中的条目内容。',
|
||||
'4. 每条卡片必须是当日可核验事件,并写明来源;title、date-badge、导语必须写明当日日期。',
|
||||
'5. 若搜索失败,明确写出未能获取当日新闻,禁止用旧稿充数。',
|
||||
'1. 新闻广度必须同时走两条检索,禁止只靠 web_search:先 load_skill → web,再同一轮调用 tkmind_search(type=news,专用联网搜索)和 web_search,合并去重后写页面。',
|
||||
'2. 覆盖面至少包括:国内热点、国际热点、热门事件/热搜;并补财经、科技、体育与当日天气。不得凭记忆编造。',
|
||||
'3. 若任务中附有「专用联网搜索预取」结果,必须优先从中选题,再用 tkmind_search / web_search 补漏;禁止只转述 web_search。',
|
||||
'4. 禁止复制、改写、微调昨日或任何历史 daily-news-*.html 的新闻标题与正文。',
|
||||
'5. 最近一版 daily-news 只允许当作版式参考(hero / section / card 结构);读完后必须丢弃其中的条目内容。',
|
||||
'6. 每条卡片必须是当日可核验事件,并写明来源;title、date-badge、导语必须写明当日日期。要闻栏至少 6 条,其中国内、国际、热门事件都要有代表条目,并用 highlight-box 突出头条。',
|
||||
'7. 必须包含天气区块(section id=weather,weather-today + weather-card)。若搜索失败,明确写出未能获取当日新闻,禁止用旧稿充数。',
|
||||
].join('\n');
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
@@ -140,9 +142,10 @@ export function isWithinNewsMorningGenerateLeadWindow(
|
||||
return current >= start && current < push;
|
||||
}
|
||||
|
||||
export function buildNewsMorningAutoGenerationTask(config, { now = Date.now() } = {}) {
|
||||
export function buildNewsMorningAutoGenerationTask(config, { now = Date.now(), searchBrief = '' } = {}) {
|
||||
const timezone = String(config?.timezone ?? 'Asia/Shanghai').trim() || 'Asia/Shanghai';
|
||||
const dateLabel = formatLocalDateParts(now, timezone);
|
||||
const brief = String(searchBrief ?? '').trim();
|
||||
return {
|
||||
id: 'news-morning-auto-generate',
|
||||
userId: config.sourceUserId,
|
||||
@@ -152,7 +155,8 @@ export function buildNewsMorningAutoGenerationTask(config, { now = Date.now() }
|
||||
NEWS_MORNING_TEMPLATE_0910_SPEC,
|
||||
`今日日期:${dateLabel.iso}(${dateLabel.year}年${dateLabel.month}月${dateLabel.day}日)。正文必须是这一天的新闻,不能是其它日期的旧稿。`,
|
||||
`输出文件名必须是 daily-news-${dateLabel.mmdd}.html(可覆盖同名旧文件)。`,
|
||||
].join('\n'),
|
||||
brief,
|
||||
].filter(Boolean).join('\n'),
|
||||
recurrence: 'once',
|
||||
timezone,
|
||||
notifyChannel: 'web',
|
||||
|
||||
@@ -209,10 +209,24 @@ test('buildNewsMorningAutoGenerationTask requires fresh daily search', () => {
|
||||
{ now: Date.parse('2026-09-15T05:00:00+08:00') },
|
||||
);
|
||||
assert.match(task.taskSpec, /禁止复制/u);
|
||||
assert.match(task.taskSpec, /联网搜索/u);
|
||||
assert.match(task.taskSpec, /tkmind_search/u);
|
||||
assert.match(task.taskSpec, /web_search/u);
|
||||
assert.match(task.taskSpec, /国内热点/u);
|
||||
assert.match(task.taskSpec, /国际热点/u);
|
||||
assert.match(task.taskSpec, /热门事件/u);
|
||||
assert.match(task.taskSpec, /2026-09-15/u);
|
||||
assert.match(task.taskSpec, /daily-news-0915\.html/u);
|
||||
assert.doesNotMatch(task.taskSpec, /可以用昨日正文/u);
|
||||
|
||||
const withBrief = wechatNewsMorningDraftInternals.buildNewsMorningAutoGenerationTask(
|
||||
{ sourceUserId: 'user-1', timezone: 'Asia/Shanghai' },
|
||||
{
|
||||
now: Date.parse('2026-09-15T05:00:00+08:00'),
|
||||
searchBrief: '【专用联网搜索预取】国内热点示例',
|
||||
},
|
||||
);
|
||||
assert.match(withBrief.taskSpec, /专用联网搜索预取/u);
|
||||
assert.match(withBrief.taskSpec, /国内热点示例/u);
|
||||
});
|
||||
|
||||
test('pushDraft refuses yesterday page when requireToday is on', async () => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { searchSearxng } from './mindsearch-providers.mjs';
|
||||
import { resolvePortalSearxngEndpoint } from './mindsearch-prefetch.mjs';
|
||||
import { getLocalParts } from './schedule-time.mjs';
|
||||
|
||||
function envFlag(value, fallback = false) {
|
||||
const raw = String(value ?? '').trim().toLowerCase();
|
||||
if (!raw) return fallback;
|
||||
return ['1', 'true', 'yes', 'on'].includes(raw);
|
||||
}
|
||||
|
||||
function formatLocalDateParts(date = new Date(), timezone = 'Asia/Shanghai') {
|
||||
const parts = getLocalParts(date, timezone);
|
||||
const year = String(parts.year);
|
||||
const month = String(parts.month).padStart(2, '0');
|
||||
const day = String(parts.day).padStart(2, '0');
|
||||
return {
|
||||
iso: `${year}-${month}-${day}`,
|
||||
label: `${year}年${month}月${day}日`,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPrefetchNewsMorningSearxng(env = process.env) {
|
||||
return envFlag(env.MEMIND_NEWS_MORNING_SEARXNG_PREFETCH, true);
|
||||
}
|
||||
|
||||
export function buildNewsMorningSearchQueries(dateLabel) {
|
||||
const day = String(dateLabel?.iso ?? '').trim();
|
||||
const label = String(dateLabel?.label ?? day).trim() || day;
|
||||
return [
|
||||
{ id: 'domestic', title: '国内热点', query: `${label} 国内热点新闻` },
|
||||
{ id: 'world', title: '国际热点', query: `${label} 国际热点新闻` },
|
||||
{ id: 'hot', title: '热门事件', query: `${label} 热门事件 热点` },
|
||||
{ id: 'finance', title: '财经科技', query: `${label} 财经 科技 新闻` },
|
||||
{ id: 'weather', title: '天气', query: `${label} 全国天气预报` },
|
||||
].filter((item) => item.query.trim());
|
||||
}
|
||||
|
||||
function truncateSnippet(value, max = 90) {
|
||||
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) return '';
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||
}
|
||||
|
||||
export function formatNewsMorningSearchBrief(groups = [], { dateLabel = null } = {}) {
|
||||
const lines = [
|
||||
'【专用联网搜索预取】以下结果来自专用新闻检索(与 web_search 并列,不是替代)。',
|
||||
'写页面时必须:1)优先从下列结果覆盖国内、国际、热门事件;2)同一轮再调用 tkmind_search(type=news) 与 web_search 补漏并核对来源;3)禁止只使用 web_search。',
|
||||
];
|
||||
if (dateLabel?.iso) lines.push(`预取日期:${dateLabel.iso}`);
|
||||
lines.push('');
|
||||
|
||||
let hitCount = 0;
|
||||
for (const group of Array.isArray(groups) ? groups : []) {
|
||||
const rows = (Array.isArray(group?.results) ? group.results : [])
|
||||
.filter((item) => item?.title || item?.url)
|
||||
.slice(0, 6);
|
||||
lines.push(`## ${group.title || group.id || '检索'}`);
|
||||
if (!rows.length) {
|
||||
lines.push('- (本路预取为空,必须用 tkmind_search / web_search 补上)');
|
||||
continue;
|
||||
}
|
||||
hitCount += rows.length;
|
||||
for (const item of rows) {
|
||||
const title = String(item.title || item.url).trim();
|
||||
const snippet = truncateSnippet(item.snippet);
|
||||
lines.push(`- ${title}`);
|
||||
if (item.url) lines.push(` ${item.url}`);
|
||||
if (snippet) lines.push(` ${snippet}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hitCount) {
|
||||
lines.push('预取没有返回条目。仍必须调用 tkmind_search(type=news) 与 web_search,覆盖国内、国际、热门事件后再写页面。');
|
||||
}
|
||||
return lines.join('\n').trim();
|
||||
}
|
||||
|
||||
async function searchNewsThenGeneral(query, {
|
||||
endpoint,
|
||||
searchImpl,
|
||||
limit,
|
||||
timeoutMs,
|
||||
}) {
|
||||
const newsHits = await searchImpl(query, {
|
||||
endpoint,
|
||||
limit,
|
||||
timeoutMs,
|
||||
categories: 'news',
|
||||
language: 'zh-CN',
|
||||
}).catch(() => []);
|
||||
if (Array.isArray(newsHits) && newsHits.length > 0) return newsHits;
|
||||
return searchImpl(query, {
|
||||
endpoint,
|
||||
limit,
|
||||
timeoutMs,
|
||||
language: 'zh-CN',
|
||||
}).catch(() => []);
|
||||
}
|
||||
|
||||
export async function prefetchNewsMorningSearxngBrief({
|
||||
now = Date.now(),
|
||||
timezone = 'Asia/Shanghai',
|
||||
env = process.env,
|
||||
searchImpl = searchSearxng,
|
||||
limit = 6,
|
||||
} = {}) {
|
||||
const dateLabel = formatLocalDateParts(now, timezone);
|
||||
const queries = buildNewsMorningSearchQueries(dateLabel);
|
||||
const emptyBrief = formatNewsMorningSearchBrief(
|
||||
queries.map((item) => ({ ...item, results: [] })),
|
||||
{ dateLabel },
|
||||
);
|
||||
if (!shouldPrefetchNewsMorningSearxng(env)) {
|
||||
return emptyBrief;
|
||||
}
|
||||
const endpoint = resolvePortalSearxngEndpoint(env);
|
||||
if (!endpoint) {
|
||||
return emptyBrief;
|
||||
}
|
||||
const timeoutMs = Number(env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000) || 8000;
|
||||
const groups = await Promise.all(queries.map(async (item) => {
|
||||
const results = await searchNewsThenGeneral(item.query, {
|
||||
endpoint,
|
||||
searchImpl,
|
||||
limit,
|
||||
timeoutMs,
|
||||
});
|
||||
return { ...item, results: Array.isArray(results) ? results : [] };
|
||||
}));
|
||||
return formatNewsMorningSearchBrief(groups, { dateLabel });
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildNewsMorningSearchQueries,
|
||||
formatNewsMorningSearchBrief,
|
||||
prefetchNewsMorningSearxngBrief,
|
||||
} from './wechat-news-morning-search.mjs';
|
||||
|
||||
test('buildNewsMorningSearchQueries covers domestic, world and hot events', () => {
|
||||
const queries = buildNewsMorningSearchQueries({
|
||||
iso: '2026-09-15',
|
||||
label: '2026年09月15日',
|
||||
});
|
||||
const ids = queries.map((item) => item.id);
|
||||
assert.deepEqual(ids, ['domestic', 'world', 'hot', 'finance', 'weather']);
|
||||
assert.ok(queries.every((item) => item.query.includes('2026年09月15日')));
|
||||
});
|
||||
|
||||
test('formatNewsMorningSearchBrief requires dual search even when empty', () => {
|
||||
const text = formatNewsMorningSearchBrief([
|
||||
{ id: 'domestic', title: '国内热点', results: [] },
|
||||
], { dateLabel: { iso: '2026-09-15' } });
|
||||
assert.match(text, /专用联网搜索预取/);
|
||||
assert.match(text, /tkmind_search/);
|
||||
assert.match(text, /web_search/);
|
||||
assert.match(text, /禁止只使用 web_search/);
|
||||
assert.match(text, /国内、国际、热门事件/);
|
||||
});
|
||||
|
||||
test('prefetchNewsMorningSearxngBrief queries news category then falls back', async () => {
|
||||
const calls = [];
|
||||
const brief = await prefetchNewsMorningSearxngBrief({
|
||||
now: Date.parse('2026-09-15T05:00:00+08:00'),
|
||||
timezone: 'Asia/Shanghai',
|
||||
env: { TKMIND_SEARCH_SEARXNG_URL: 'http://127.0.0.1:20080/search' },
|
||||
searchImpl: async (query, options) => {
|
||||
calls.push({ query, categories: options.categories || '' });
|
||||
if (options.categories === 'news' && /国内热点/.test(query)) {
|
||||
return [];
|
||||
}
|
||||
if (options.categories === 'news') {
|
||||
return [{ title: `${query} 新闻源`, url: 'https://news.example/a', snippet: '摘要' }];
|
||||
}
|
||||
return [{ title: `${query} 通用源`, url: 'https://web.example/b', snippet: '补搜' }];
|
||||
},
|
||||
});
|
||||
assert.ok(calls.some((item) => item.categories === 'news'));
|
||||
assert.ok(calls.some((item) => item.categories === '' && /国内热点/.test(item.query)));
|
||||
assert.match(brief, /国际热点新闻 新闻源/);
|
||||
assert.match(brief, /国内热点新闻 通用源/);
|
||||
assert.match(brief, /tkmind_search/);
|
||||
});
|
||||
|
||||
test('prefetchNewsMorningSearxngBrief skips live calls without endpoint', async () => {
|
||||
const brief = await prefetchNewsMorningSearxngBrief({
|
||||
now: Date.parse('2026-09-15T05:00:00+08:00'),
|
||||
env: {},
|
||||
searchImpl: async () => {
|
||||
throw new Error('should not run');
|
||||
},
|
||||
});
|
||||
assert.match(brief, /预取没有返回条目|本路预取为空/);
|
||||
assert.match(brief, /tkmind_search/);
|
||||
});
|
||||
Reference in New Issue
Block a user