const EMPTY_RANKING = Object.freeze({ candidateCount: 0, eventCount: 0, events: [], selected: [], }); export function emptyNewsEngineRanking() { return { candidateCount: 0, eventCount: 0, events: [], selected: [], }; } export function resolveNewsEngineEndpoint(env = process.env) { return String(env.MEMIND_NEWS_ENGINE_URL ?? '').trim().replace(/\/+$/, ''); } export function isNewsEngineMediaUrl(value, env = process.env) { const raw = String(value ?? '').trim(); if (!raw) return false; if (raw.startsWith('/media/')) return true; const base = resolveNewsEngineEndpoint(env); if (!base) return false; try { const parsed = new URL(raw); const origin = new URL(`${base}/`).origin; return parsed.origin === origin && parsed.pathname.startsWith('/media/'); } catch { return false; } } export function applyRankingImagesToGroups(groups = [], ranking = null, { normalizeUrl = (value) => String(value ?? '').trim(), } = {}) { const imageByUrl = new Map(); for (const event of [...(ranking?.selected ?? []), ...(ranking?.events ?? [])]) { const url = normalizeUrl(event?.primaryArticle?.url); const image = String(event?.primaryArticle?.image ?? '').trim(); if (url && image) imageByUrl.set(url, image); } return (Array.isArray(groups) ? groups : []).map((group) => ({ ...group, results: (Array.isArray(group?.results) ? group.results : []).map((item) => { const url = normalizeUrl(item?.url); const image = imageByUrl.get(url); if (!image) return item; return { ...item, image, imageSource: 'news-engine' }; }), })); } function envFlag(value, fallback = false) { const raw = String(value ?? '').trim().toLowerCase(); if (!raw) return fallback; return ['1', 'true', 'yes', 'on'].includes(raw); } export function shouldUseNewsEngineLatestCollection(env = process.env) { if (!resolveNewsEngineEndpoint(env)) return false; return envFlag(env.MEMIND_NEWS_ENGINE_USE_LATEST_COLLECTION, true); } export async function fetchLatestCollectionFromEngine({ env = process.env, fetchImpl = fetch, logger = console, } = {}) { const endpoint = resolveNewsEngineEndpoint(env); if (!endpoint) return null; const timeoutMs = Math.max( 500, Number(env.MEMIND_NEWS_ENGINE_TIMEOUT_MS ?? 5000) || 5000, ); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); timer.unref?.(); try { const token = String(env.MEMIND_NEWS_ENGINE_API_TOKEN ?? '').trim(); const response = await fetchImpl(`${endpoint}/v1/collections/latest`, { headers: { accept: 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), }, signal: controller.signal, }); if (response.status === 404) return null; if (!response.ok) throw new Error(`HTTP ${response.status}`); const collection = await response.json(); if (!collection || typeof collection !== 'object') return null; return collection; } catch (error) { logger.warn?.( '[NewsEngine] latest collection unavailable:', error instanceof Error ? error.message : error, ); return null; } finally { clearTimeout(timer); } } export function resolveNewsEngineMediaFetchUrl(imageUrl, env = process.env) { const raw = String(imageUrl ?? '').trim(); if (!raw) return ''; if (raw.startsWith('/media/')) { const endpoint = resolveNewsEngineEndpoint(env); return endpoint ? `${endpoint}${raw}` : ''; } return isNewsEngineMediaUrl(raw, env) ? raw : ''; } export async function downloadNewsEngineMediaBytes(imageUrl, { env = process.env, fetchImpl = fetch, timeoutMs = 8000, } = {}) { const fetchUrl = resolveNewsEngineMediaFetchUrl(imageUrl, env); if (!fetchUrl) return null; try { const token = String(env.MEMIND_NEWS_ENGINE_API_TOKEN ?? '').trim(); const response = await fetchImpl(fetchUrl, { redirect: 'follow', signal: AbortSignal.timeout(timeoutMs), headers: { Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', ...(token ? { authorization: `Bearer ${token}` } : {}), }, }); if (!response.ok) return null; const buffer = Buffer.from(await response.arrayBuffer()); if (buffer.length > 2_500_000 || buffer.length < 12) return null; return buffer; } catch { return null; } } export async function rankNewsWithEngine(groups, { env = process.env, now = Date.now(), fetchImpl = fetch, logger = console, } = {}) { const endpoint = resolveNewsEngineEndpoint(env); if (!endpoint) return emptyNewsEngineRanking(); const timeoutMs = Math.max( 500, Number(env.MEMIND_NEWS_ENGINE_TIMEOUT_MS ?? 5000) || 5000, ); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); timer.unref?.(); try { const token = String(env.MEMIND_NEWS_ENGINE_API_TOKEN ?? '').trim(); const response = await fetchImpl(`${endpoint}/v1/rank`, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ groups: Array.isArray(groups) ? groups : [], options: { now, limit: Number(env.MEMIND_NEWS_ENGINE_DAILY_LIMIT ?? 15) || 15, minimumScore: Number(env.MEMIND_NEWS_ENGINE_MINIMUM_SCORE ?? 0) || 0, }, }), signal: controller.signal, }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const ranking = await response.json(); if (!ranking || !Array.isArray(ranking.events) || !Array.isArray(ranking.selected)) { throw new Error('invalid ranking response'); } return ranking; } catch (error) { logger.warn?.( '[NewsEngine] ranking unavailable:', error instanceof Error ? error.message : error, ); return { ...EMPTY_RANKING, events: [], selected: [] }; } finally { clearTimeout(timer); } } export function formatRankedNewsCandidates(ranking) { const selected = Array.isArray(ranking?.selected) ? ranking.selected : []; if (!selected.length) return ''; const lines = [ '【News Engine 编辑候选】以下事件已完成聚类、规则评分和栏目配额选择。生成早报时优先采用;must_include=true 的事件必须进入正文。', ]; for (const event of selected) { const source = event.primaryArticle?.sourceDomain || 'unknown'; lines.push( `${event.rank}. [${event.category}] ${event.canonicalTitle}`, ` score=${event.finalScore} sources=${event.sourceCount} must_include=${event.mustInclude} primary=${source}`, ); if (event.primaryArticle?.url) lines.push(` ${event.primaryArticle.url}`); if (event.primaryArticle?.image) lines.push(` 图:${event.primaryArticle.image}`); } return lines.join('\n'); }