Files
memind/news-engine-client.mjs
T
john 4a26db5e84 feat(news): ship news002 fill gate and full WeChat draft push
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>
2026-09-22 14:05:40 +08:00

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');
}