diff --git a/mindsearch-providers.mjs b/mindsearch-providers.mjs index b669d39..a79aa7a 100644 --- a/mindsearch-providers.mjs +++ b/mindsearch-providers.mjs @@ -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, diff --git a/mindsearch.test.mjs b/mindsearch.test.mjs index cf0ec8b..7fb5d4f 100644 --- a/mindsearch.test.mjs +++ b/mindsearch.test.mjs @@ -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' }] }) }; } }); diff --git a/wechat-news-morning-draft-worker.mjs b/wechat-news-morning-draft-worker.mjs index c25e252..31f8a8c 100644 --- a/wechat-news-morning-draft-worker.mjs +++ b/wechat-news-morning-draft-worker.mjs @@ -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, diff --git a/wechat-news-morning-draft-worker.test.mjs b/wechat-news-morning-draft-worker.test.mjs index 10495af..ff8fb49 100644 --- a/wechat-news-morning-draft-worker.test.mjs +++ b/wechat-news-morning-draft-worker.test.mjs @@ -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 }); } diff --git a/wechat-news-morning-draft.mjs b/wechat-news-morning-draft.mjs index d4b7d82..8e87126 100644 --- a/wechat-news-morning-draft.mjs +++ b/wechat-news-morning-draft.mjs @@ -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', diff --git a/wechat-news-morning-draft.test.mjs b/wechat-news-morning-draft.test.mjs index cccc08a..4f391a6 100644 --- a/wechat-news-morning-draft.test.mjs +++ b/wechat-news-morning-draft.test.mjs @@ -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 () => { diff --git a/wechat-news-morning-search.mjs b/wechat-news-morning-search.mjs new file mode 100644 index 0000000..d8b7d2f --- /dev/null +++ b/wechat-news-morning-search.mjs @@ -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 }); +} diff --git a/wechat-news-morning-search.test.mjs b/wechat-news-morning-search.test.mjs new file mode 100644 index 0000000..772b271 --- /dev/null +++ b/wechat-news-morning-search.test.mjs @@ -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/); +});