diff --git a/.env.example b/.env.example index 9ac35e4..418c0bb 100644 --- a/.env.example +++ b/.env.example @@ -239,6 +239,11 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # H5_WECHAT_NEWS_MORNING_DRAFT_MINUTE=0 # H5_WECHAT_NEWS_MORNING_DRAFT_AUTO_GENERATE=1 # H5_WECHAT_NEWS_MORNING_DRAFT_GENERATE_LEAD_MINUTES=60 +# 早报模板:news002(订阅版,写满各栏 + 有图必留、无图保留文字卡) +# H5_WECHAT_NEWS_MORNING_TEMPLATE_ID=news002 +# MEMIND_NEWS_MORNING_NEWS002_REQUIRE_FILL=1 +# MEMIND_NEWS_MORNING_SEARXNG_FALLBACK=1 +# MEMIND_NEWS_MORNING_LOCALIZE_IMAGES=1 # Intent Transaction Layer(Phase A:Draft → Confirm → Commit;默认关闭) # H5_INTENT_TRANSACTION_ENABLED=1 @@ -536,6 +541,13 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # MEMIND_NEWS_MORNING_SEARXNG_NEWS_CATEGORY=0 # MEMIND_NEWS_MORNING_SEARXNG_CONCURRENCY=3 # MEMIND_NEWS_MORNING_SEARXNG_ENGINES=yandex,bing,360search +# 独立 news-engine 服务:早报预取完成后 POST groups 到 /v1/rank,brief 注入「News Engine 编辑候选」 +# MEMIND_NEWS_ENGINE_URL=http://127.0.0.1:8092 +# MEMIND_NEWS_ENGINE_API_TOKEN= +# MEMIND_NEWS_ENGINE_DAILY_LIMIT=15 +# MEMIND_NEWS_ENGINE_MINIMUM_SCORE=0 +# MEMIND_NEWS_ENGINE_TIMEOUT_MS=120000 +# MEMIND_NEWS_ENGINE_USE_LATEST_COLLECTION=true # Portal 在 web / search-enhanced 技能执行前预取 SearXNG;设为 0 可关闭。 # MEMIND_LIVE_SEARCH_PREFETCH=1 # TKMIND_SEARCH_MCP_HOST_GATEWAY=host.docker.internal diff --git a/docs/news-morning-templates.md b/docs/news-morning-templates.md index b665bb1..271a2e8 100644 --- a/docs/news-morning-templates.md +++ b/docs/news-morning-templates.md @@ -102,7 +102,8 @@ news002 的卡片图会先上传到微信正文素材接口,再由 inline 渲 静态页与微信草稿均使用 **左上角 72~92px 小图**(`.card` / `.lead-card` 左侧缩略图),不再用全宽大图。 能拿到真实配图的卡片必须显示并上传;没有真实图就只保留文字卡,不放默认小图。 远程配图会先下载到当日 MindSpace `public/`,改写成本站相对 URL,避免海外图源在国内打不开。 -已有配图上传失败、头条不是 3~4 条、事件卡超过 28 条,或页面仍混有 news001 DOM, +推送前不再删除无图故事卡(微信草稿保留纯文字卡);有图卡必须成功上传微信 CDN。 +生成闸门会拦截:配图上传失败、头条不是 3~4 条、事件卡超过 30 条,或页面仍混有 news001 DOM, 才会由生成/推送闸门阻断,不允许进入草稿箱。 真实图片来源按优先级取 SearXNG 结果里的 `img_src`/`thumbnail`,以及文章来源的 diff --git a/news-engine-client.mjs b/news-engine-client.mjs index ce069ea..0c04c2b 100644 --- a/news-engine-client.mjs +++ b/news-engine-client.mjs @@ -18,6 +18,127 @@ 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(), @@ -82,6 +203,7 @@ export function formatRankedNewsCandidates(ranking) { ` 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'); } diff --git a/news-engine-client.test.mjs b/news-engine-client.test.mjs index bd464cd..0934b37 100644 --- a/news-engine-client.test.mjs +++ b/news-engine-client.test.mjs @@ -1,9 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + applyRankingImagesToGroups, + downloadNewsEngineMediaBytes, + fetchLatestCollectionFromEngine, formatRankedNewsCandidates, + isNewsEngineMediaUrl, rankNewsWithEngine, resolveNewsEngineEndpoint, + shouldUseNewsEngineLatestCollection, } from './news-engine-client.mjs'; test('news engine client is disabled without an endpoint', async () => { @@ -54,6 +59,100 @@ test('news engine client sends groups and authentication to the independent serv assert.match(formatRankedNewsCandidates(ranking), /模型发布/); }); +test('isNewsEngineMediaUrl detects local news-engine media paths', () => { + assert.equal(isNewsEngineMediaUrl('/media/abc.jpg'), true); + assert.equal( + isNewsEngineMediaUrl('http://127.0.0.1:8092/media/abc.jpg', { MEMIND_NEWS_ENGINE_URL: 'http://127.0.0.1:8092' }), + true, + ); + assert.equal(isNewsEngineMediaUrl('https://cdn.example.com/a.jpg'), false); +}); + +test('applyRankingImagesToGroups merges primaryArticle images by url', () => { + const groups = applyRankingImagesToGroups([{ + id: 'tech', + results: [{ title: 'A', url: 'https://example.com/a' }], + }], { + selected: [{ + primaryArticle: { + url: 'https://example.com/a', + image: 'http://127.0.0.1:8092/media/abc.jpg', + }, + }], + }); + assert.equal(groups[0].results[0].image, 'http://127.0.0.1:8092/media/abc.jpg'); + assert.equal(groups[0].results[0].imageSource, 'news-engine'); +}); + +test('shouldUseNewsEngineLatestCollection requires endpoint', () => { + assert.equal(shouldUseNewsEngineLatestCollection({}), false); + assert.equal( + shouldUseNewsEngineLatestCollection({ MEMIND_NEWS_ENGINE_URL: 'http://127.0.0.1:8092' }), + true, + ); + assert.equal( + shouldUseNewsEngineLatestCollection({ + MEMIND_NEWS_ENGINE_URL: 'http://127.0.0.1:8092', + MEMIND_NEWS_ENGINE_USE_LATEST_COLLECTION: 'false', + }), + false, + ); +}); + +test('fetchLatestCollectionFromEngine returns latest snapshot', async () => { + const collection = await fetchLatestCollectionFromEngine({ + env: { + MEMIND_NEWS_ENGINE_URL: 'http://127.0.0.1:8092', + MEMIND_NEWS_ENGINE_API_TOKEN: 'token', + }, + fetchImpl: async (url, init) => { + assert.equal(url, 'http://127.0.0.1:8092/v1/collections/latest'); + assert.equal(init.headers.authorization, 'Bearer token'); + return { + ok: true, + status: 200, + async json() { + return { id: 'c1', status: 'ok', dateLabel: '2026-09-22', groups: [{ id: 'tech', results: [{}] }] }; + }, + }; + }, + }); + assert.equal(collection.id, 'c1'); +}); + +test('downloadNewsEngineMediaBytes fetches /media assets from news-engine', async () => { + const bytes = await downloadNewsEngineMediaBytes('http://127.0.0.1:8092/media/abc.jpg', { + env: { MEMIND_NEWS_ENGINE_URL: 'http://127.0.0.1:8092' }, + fetchImpl: async (url) => { + assert.equal(url, 'http://127.0.0.1:8092/media/abc.jpg'); + return { + ok: true, + arrayBuffer: async () => Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0, 0, 0, 0]), + }; + }, + }); + assert.ok(Buffer.isBuffer(bytes)); +}); + +test('formatRankedNewsCandidates includes local image urls', () => { + const block = formatRankedNewsCandidates({ + selected: [{ + rank: 1, + category: 'tech', + canonicalTitle: '测试', + finalScore: 8, + sourceCount: 1, + mustInclude: false, + primaryArticle: { + url: 'https://example.com/a', + image: 'http://127.0.0.1:8092/media/abc.jpg', + sourceDomain: 'example.com', + }, + }], + }); + assert.match(block, /图:http:\/\/127\.0\.0\.1:8092\/media\/abc\.jpg/); +}); + test('news engine client fails open when the service is unavailable', async () => { const warnings = []; const ranking = await rankNewsWithEngine([], { diff --git a/news-morning-templates.mjs b/news-morning-templates.mjs index 01910bd..b30dbcc 100644 --- a/news-morning-templates.mjs +++ b/news-morning-templates.mjs @@ -115,7 +115,185 @@ export const NEWS002_FLEX_SECTION_IDS = NEWS002_SECTIONS .map((item) => item.id); /** 全页独立事件卡上限;超过这个数就是在灌水,不是在做早报。 */ -export const NEWS002_MAX_STORY_CARDS = 28; +/** core(20) + 3×flex(9) 写满时最多 29 条故事卡,留 1 条余量给 SearXNG 补栏。 */ +export const NEWS002_MAX_STORY_CARDS = 30; + +/** news002 flex 栏目至少应出现的节数(写满策略下仍要求多栏目覆盖)。 */ +export const NEWS002_MIN_FLEX_SECTIONS = 2; + +const NEWS002_SECTION_ALIASES = { + business: ['business', 'finance'], + deepdive: ['deepdive', 'deep'], +}; + +function sliceNews002BalancedBlock(html, tagName, openStart, openEnd) { + const open = new RegExp(`<${tagName}\\b`, 'gi'); + const close = new RegExp(`${tagName}\\s*>`, 'gi'); + let depth = 1; + let cursor = openEnd; + while (depth > 0 && cursor < html.length) { + open.lastIndex = cursor; + close.lastIndex = cursor; + const nextOpen = open.exec(html); + const nextClose = close.exec(html); + if (!nextClose) return { start: openStart, end: html.length }; + if (nextOpen && nextOpen.index < nextClose.index) { + depth += 1; + cursor = nextOpen.index + nextOpen[0].length; + } else { + depth -= 1; + cursor = nextClose.index + nextClose[0].length; + } + } + return { start: openStart, end: cursor }; +} + +function extractNews002SectionBlock(html, sectionId) { + const source = String(html ?? ''); + const aliases = NEWS002_SECTION_ALIASES[sectionId] ?? [sectionId]; + for (const alias of aliases) { + if (alias === 'brief') { + const match = source.match( + /