4a26db5e84
Lock scheduled morning news to news002 with section-fill gates, headline lead-card normalization, SearXNG top-up to max, and push that keeps imageless cards as text while requiring uploaded images where present. Co-authored-by: Cursor <cursoragent@cursor.com>
210 lines
6.8 KiB
JavaScript
210 lines
6.8 KiB
JavaScript
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');
|
|
}
|