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>
This commit is contained in:
john
2026-09-22 14:05:40 +08:00
parent a96a9d0e14
commit 4a26db5e84
18 changed files with 1384 additions and 123 deletions
+12
View File
@@ -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
+2 -1
View File
@@ -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`,以及文章来源的
+122
View File
@@ -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');
}
+99
View File
@@ -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([], {
+195 -8
View File
@@ -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(
/<div\b[^>]*\bsection\b[^>]*\bid=["']brief["'][^>]*>/i,
);
if (!match) continue;
const start = match.index;
const end = sliceNews002BalancedBlock(source, 'div', start, start + match[0].length).end;
return source.slice(start, end);
}
const match = source.match(
new RegExp(
`<div\\b[^>]*class=["'][^"']*\\bsection\\b[^"']*["'][^>]*id=["']${alias}["'][^>]*>`,
'i',
),
);
if (!match) continue;
const start = match.index;
const end = sliceNews002BalancedBlock(source, 'div', start, start + match[0].length).end;
return source.slice(start, end);
}
return '';
}
function countNews002StoryCardsInBlock(block) {
const source = String(block ?? '');
const re = /<(div|a)\b[^>]*\bclass="([^"]*)"[^>]*>/gi;
let count = 0;
let match;
while ((match = re.exec(source)) != null) {
const tokens = String(match[2]).split(/\s+/);
if (tokens.includes('card') || tokens.includes('lead-card')) count += 1;
re.lastIndex = match.index + match[0].length;
}
return count;
}
/** 统计 news002 各栏目当前条数。 */
export function findNews002SectionIdForOffset(html, offset) {
const before = String(html ?? '').slice(0, offset);
const matches = [...before.matchAll(
/<div\b[^>]*class=["'][^"']*\bsection\b[^"']*["'][^>]*id=["']([^"']+)["']/gi,
)];
return matches.length ? matches[matches.length - 1][1] : '';
}
export function countNews002HeadlineLeadCards(html) {
const block = extractNews002SectionBlock(html, 'headlines');
if (!block) return 0;
return (block.match(/\blead-card\b/gi) ?? []).length;
}
export function countNews002SectionItems(html, sectionId) {
const block = extractNews002SectionBlock(html, sectionId);
if (!block) return 0;
if (sectionId === 'brief') return (block.match(/<li\b/gi) ?? []).length;
if (sectionId === 'deepdive') {
return /<div\b[^>]*\bclass="[^"]*\bdeep\b/i.test(block) ? 1 : 0;
}
if (sectionId === 'weather') {
return (block.match(/class="[^"]*\bweather-card\b/gi) ?? []).length;
}
if (sectionId === 'knowledge') {
return (block.match(/class="[^"]*\bk-item\b/gi) ?? []).length;
}
if (sectionId === 'history') {
return (block.match(/class="[^"]*\bh-item\b/gi) ?? []).length;
}
return countNews002StoryCardsInBlock(block);
}
/**
* news002 栏目写满审计:core 栏目必须达到 max;flex 至少 minFlexSections 节且每节达到 max。
*/
export function auditNews002SectionFill(
html,
{
minFlexSections = NEWS002_MIN_FLEX_SECTIONS,
requireCoreMax = true,
requireFlexMax = true,
} = {},
) {
const counts = Object.fromEntries(
NEWS002_SECTIONS.map((section) => [section.id, countNews002SectionItems(html, section.id)]),
);
const violations = [];
for (const section of NEWS002_SECTIONS.filter((item) => item.tier === 'core')) {
const count = counts[section.id] ?? 0;
if (section.id === 'deepdive') {
if (count !== section.max) {
violations.push(`underfilled:${section.id}:${count}/${section.max}`);
}
continue;
}
if (requireCoreMax && count < section.max) {
violations.push(`underfilled:${section.id}:${count}/${section.max}`);
}
}
const flexPresent = NEWS002_FLEX_SECTION_IDS.filter((id) => extractNews002SectionBlock(html, id));
if (flexPresent.length < minFlexSections) {
violations.push(`insufficient-flex-sections:${flexPresent.length}/${minFlexSections}`);
}
if (requireFlexMax) {
for (const id of flexPresent) {
const section = NEWS002_SECTIONS.find((item) => item.id === id);
const count = counts[id] ?? 0;
if (section && count < section.max) {
violations.push(`underfilled:${id}:${count}/${section.max}`);
}
}
}
return {
ok: violations.length === 0,
violations,
counts,
flexPresent,
};
}
/** 交给 Agent 的「必须写满」附加约束(可写入 taskSpecAppend)。 */
export const NEWS002_FILL_REQUIREMENT_SPEC = [
'【news002 写满硬约束 · 未达标即作废重写】',
'1. core 栏目(brief/headlines/deepdive/domestic/world/business/tech/weather/knowledge/history)每一条都必须写到该栏目的上限条数,不能只写 min。',
`2. 各栏上限:${NEWS002_SECTIONS.map((s) => `${s.id}=${s.max}`).join(',')}。`,
`3. flex 栏目(${NEWS002_FLEX_SECTION_IDS.join('/')})至少出现 ${NEWS002_MIN_FLEX_SECTIONS} 个,且每个出现的 flex 栏目也必须写到上限。`,
'4. 写页前必须先合并预取 + tkmind_search(type=news) + web_search,事件表不足时继续搜索补事件,禁止用同一事件拆条凑数。',
`5. 全页独立事件仍遵守去重与 ${NEWS002_MAX_STORY_CARDS} 条上限;为写满各栏,必须覆盖更多不同事件,而不是重复。`,
'6. `.meta` 里的「今日 M 条」必须等于速读条数 + 各栏故事卡 + 知识卡 + 天气城数 + 历史条目的合计,且与实际上屏一致。',
].join('\n');
/** 定时任务默认附加约束:禁止回退 news001 + 必须写满各栏。 */
export const NEWS002_DEFAULT_TASK_SPEC_APPEND = [
'【强制 news002 版式 · 覆盖一切旧稿参考】',
'1. 禁止输出 news001 结构:不得出现 class="hero"、class="date-badge"、class="quick-links"、class="highlight-box"、背景 #f7fafc / 主色 #b0191e。',
'2. 必须使用 news002 结构:div.masthead + div.lede + a.lead-card + ol.brief + div.card[data-event-key],并内嵌 taskSpec 里的 NEWS002_STYLE_BLOCK(暖纸 #FAF7F2、主色 #C8362F)。',
'3. 写页前不要 read_file 任何旧 daily-news-*.html 学版式;旧稿正文禁止复用。只允许按本 taskSpec 生成。',
`4. 全页每张故事卡必须带 data-event-key;独立事件 ≤${NEWS002_MAX_STORY_CARDS};flex 栏目无独立事件则整节删除。`,
'5. 导航仅保留 news002 8 个主锚点(速读/头条/深读/国内/国际/财经/科技/天气/知识),禁止 25 项 quick-links。',
NEWS002_FILL_REQUIREMENT_SPEC,
].join('\n');
/** news002 标准 <style>,直接照抄,禁止自创色值。 */
export const NEWS002_STYLE_BLOCK = `<style>
@@ -286,16 +464,16 @@ export const NEWS002_TEMPLATE_SPEC = [
'news002 与旧版 news001 的区别:事件唯一化、每条配图、报头版式、支持深色模式。禁止套用 news001 的 `.hero` 渐变大色块、25 个 quick-link、`.highlight-box` 系列。',
'',
'一、事件唯一化(最高优先级,违反即作废重写)',
`1. 全页独立事件卡上限 ${NEWS002_MAX_STORY_CARDS} 条。宁可少写,不许灌水。`,
`1. 全页独立事件卡上限 ${NEWS002_MAX_STORY_CARDS} 条;各栏目必须写到上限条数(见写满约束),用更多独立事件填满,禁止重复灌水。`,
'2. 写页前先把预取 + tkmind_search + web_search 的结果归并成一张「今日事件表」:每个事件一行,字段为 事件指纹 / 标题 / 摘要 / 来源URL / 配图URL / 栏目归属。',
'3. 事件指纹 = 核心主体 + 核心动作 的小写拼音或英文短横线串(例:`wangyi-rubio-call`、`asean-expo-nanning`、`openai-hodge-conjecture`)。中英文报道同一事件必须共用同一指纹。',
'4. 每张卡片必须写 `data-event-key="{事件指纹}"`;全页指纹不得重复,一个事件只能出现在一个栏目里。',
'5. 同一事件的「不同角度」不算新事件,不得为了填满栏目而拆成两条。',
'',
'二、栏目结构(core 必出,flex 没料就整节删掉)',
`1. core 栏目(必须全部出现):${NEWS002_CORE_SECTION_IDS.join(' / ')}。`,
`2. flex 栏目(只有拿到未使用过的独立事件才写,否则连同导航锚点一起删除):${NEWS002_FLEX_SECTION_IDS.join(' / ')}。`,
'3. 各栏目条数区间:' + NEWS002_SECTIONS.map((s) => `${s.id} ${s.min}~${s.max}`).join('|') + '。',
'二、栏目结构(core 必出且写满,flex 至少开两节且写满)',
`1. core 栏目(必须全部出现并写到上限):${NEWS002_CORE_SECTION_IDS.join(' / ')}。`,
`2. flex 栏目(至少 ${NEWS002_MIN_FLEX_SECTIONS} 个必须出现,且每个出现的 flex 栏目写到上限):${NEWS002_FLEX_SECTION_IDS.join(' / ')}。`,
'3. 各栏目条数(必须达到 max,不是 min):' + NEWS002_SECTIONS.map((s) => `${s.id}=${s.max}`).join('|') + '。',
'4. 区块容器用 `<div class="section" id="...">`,`.section-title` 内含 `<span class="icon">` + `<h2>` + 可选 `<span class="count">`。',
'',
'三、DOM 骨架(顺序固定)',
@@ -342,7 +520,7 @@ export const NEWS002_FRESH_CONTENT_SPEC = [
'内容硬约束(必须遵守,优先于版式):',
'1. 检索必须双路:先 load_skill → web,再同一轮调用 tkmind_search(type=news) 与 web_search,合并去重后写页;禁止只靠 web_search。',
'2. 合并后先产出「今日事件表」并去重,再按事件数决定写几个栏目 —— 是事件决定栏目,不是栏目决定事件。',
`3. 全页独立事件不超过 ${NEWS002_MAX_STORY_CARDS} 条;当天独立事件不足时,flex 栏目整节删除,禁止把已写过的事件换个标题再写一遍。`,
`3. 全页独立事件不超过 ${NEWS002_MAX_STORY_CARDS} 条;为写满各 core/flex 栏目,必须继续 tkmind_search / web_search 补独立事件,禁止把已写过的事件换个标题再写一遍。`,
'4. 判重标准:同一 URL、同一主体+同一动作、中英文互译标题、同一事件的不同媒体转述,都算同一事件。',
'5. 若附有「专用联网搜索预取」,必须优先从中选题;预取有结果时禁止输出「未能获取」类空页。',
'6. 若 tkmind_search / web_search 暂时不可用但预取 brief 有条目:用预取结果写页,并在 source 标注预取来源 URL。',
@@ -385,7 +563,7 @@ export const NEWS_MORNING_TEMPLATES = {
export const NEWS_MORNING_TEMPLATE_IDS = Object.keys(NEWS_MORNING_TEMPLATES);
export const DEFAULT_NEWS_MORNING_TEMPLATE_ID = 'news001';
export const DEFAULT_NEWS_MORNING_TEMPLATE_ID = 'news002';
export function resolveNewsMorningTemplateId(value, env = process.env) {
const candidates = [value, env?.H5_WECHAT_NEWS_MORNING_TEMPLATE_ID];
@@ -401,13 +579,22 @@ export function getNewsMorningTemplate(value, env = process.env) {
}
/** 组装交给 Agent 的完整 taskSpec 前缀(内容约束 + SEO + 版式)。 */
function envFlag(value, fallback = true) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
export function buildNewsMorningTemplateSpec(templateId, env = process.env) {
const template = getNewsMorningTemplate(templateId, env);
const requireFill = template.id === 'news002'
&& envFlag(env.MEMIND_NEWS_MORNING_NEWS002_REQUIRE_FILL, true);
return [
`版式模板:${template.id}(${template.version})`,
template.freshContentSpec,
template.seoSpec,
template.templateSpec,
requireFill ? NEWS002_FILL_REQUIREMENT_SPEC : '',
template.referenceUrl ? `版式参照(只读结构):${template.referenceUrl}` : '',
].filter(Boolean).join('\n');
}
+19 -3
View File
@@ -13,8 +13,11 @@ import {
NEWS002_VERSION,
NEWS_MORNING_TEMPLATES,
NEWS_MORNING_TEMPLATE_IDS,
auditNews002SectionFill,
buildNewsMorningTemplateSpec,
countNews002SectionItems,
getNewsMorningTemplate,
NEWS002_FILL_REQUIREMENT_SPEC,
resolveNewsMorningTemplateId,
} from './news-morning-templates.mjs';
@@ -37,7 +40,7 @@ test('resolveNewsMorningTemplateId falls back safely', () => {
assert.equal(resolveNewsMorningTemplateId(null, {}), DEFAULT_NEWS_MORNING_TEMPLATE_ID);
assert.equal(resolveNewsMorningTemplateId('news002', {}), 'news002');
assert.equal(resolveNewsMorningTemplateId('NEWS002', {}), 'news002');
assert.equal(resolveNewsMorningTemplateId('news999', {}), 'news001');
assert.equal(resolveNewsMorningTemplateId('news999', {}), DEFAULT_NEWS_MORNING_TEMPLATE_ID);
assert.equal(
resolveNewsMorningTemplateId(null, { H5_WECHAT_NEWS_MORNING_TEMPLATE_ID: 'news002' }),
'news002',
@@ -65,7 +68,7 @@ test('news002 section tiers split core and flex', () => {
test('news002 caps total story cards well below news001 output', () => {
// 0918 生产页产出 49 条卡片、仅约 20 个独立事件。
assert.equal(NEWS002_MAX_STORY_CARDS, 28);
assert.equal(NEWS002_MAX_STORY_CARDS, 30);
assert.match(NEWS002_TEMPLATE_SPEC, new RegExp(`全页独立事件卡上限 ${NEWS002_MAX_STORY_CARDS} 条`));
});
@@ -97,10 +100,23 @@ test('buildNewsMorningTemplateSpec assembles content + seo + layout', () => {
assert.match(spec002, /内容硬约束/);
assert.match(spec002, /SEO \/ GEO 必做/);
assert.ok(spec002.includes(NEWS002_STYLE_BLOCK));
assert.ok(spec002.includes(NEWS002_FILL_REQUIREMENT_SPEC));
assert.ok(!spec002.includes('版式参照(只读结构)'), 'news002 自带样式,不依赖外部参照页');
});
test('auditNews002SectionFill requires core max counts and flex coverage', () => {
const sparse = `<div class="section" id="brief"><ol class="brief"><li><span class="n">1</span><span>一</span></li></ol></div>
<div class="section" id="headlines"><a class="lead-card" data-event-key="a"><div class="body"><h3>头条</h3></div></a></div>`;
const sparseAudit = auditNews002SectionFill(sparse);
assert.equal(sparseAudit.ok, false);
assert.ok(sparseAudit.violations.some((item) => item.startsWith('underfilled:brief:')));
assert.ok(sparseAudit.violations.some((item) => item.startsWith('insufficient-flex-sections:')));
assert.equal(countNews002SectionItems(sparse, 'brief'), 1);
assert.equal(countNews002SectionItems(sparse, 'headlines'), 1);
});
test('getNewsMorningTemplate returns the resolved template object', () => {
assert.equal(getNewsMorningTemplate('news002', {}).id, 'news002');
assert.equal(getNewsMorningTemplate('bogus', {}).id, 'news001');
assert.equal(getNewsMorningTemplate('bogus', {}).id, DEFAULT_NEWS_MORNING_TEMPLATE_ID);
});
+1
View File
@@ -108,6 +108,7 @@
"test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs",
"test:deep-search": "node --test deep-search.test.mjs mindsearch.test.mjs",
"test:news-engine": "node --test news-engine-client.test.mjs wechat-news-morning-search.test.mjs",
"verify:news-engine-integration": "node scripts/verify-news-engine-integration.mjs",
"test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs",
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
@@ -0,0 +1,305 @@
#!/usr/bin/env node
/**
* 一次性生成今日新闻早报页面并推送到微信公众号草稿箱。
*
* 用法:
* node scripts/run-news-morning-generate-push-now.mjs
* node scripts/run-news-morning-generate-push-now.mjs --generate-only
* node scripts/run-news-morning-generate-push-now.mjs --push-only
*/
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { createDbPool, isDatabaseConfigured } from '../db.mjs';
import { loadWechatMpConfig } from '../wechat-mp-config.mjs';
import {
resolveMindSpaceServerRuntimeOptions,
resolvePortalH5Root,
} from '../mindspace-runtime-config.mjs';
import { resolveMindSpaceAnalyticsConfig } from '../mindspace-analytics.mjs';
import { createHealthDataRuntime } from '../health-data-runtime.mjs';
import { createHealthChannelSessionStore } from '../health-channel-session-store.mjs';
import { startWorkspaceAssetSyncWatcher } from '../mindspace-workspace-sync.mjs';
import { startWorkspaceThumbnailWatcher } from '../mindspace-workspace-thumbnails.mjs';
import { bootstrapPortalDomainServices } from '../server/portal-domain-services-bootstrap.mjs';
import { bootstrapPortalAuthServices } from '../server/portal-auth-services-bootstrap.mjs';
import { bootstrapPortalAgentServices } from '../server/portal-agent-services-bootstrap.mjs';
import { bootstrapPortalMemorySessionServices } from '../server/portal-memory-session-services-bootstrap.mjs';
import { bootstrapPortalGatewayServices } from '../server/portal-gateway-services-bootstrap.mjs';
import { bootstrapPortalIntegrationServices } from '../server/portal-integration-services-bootstrap.mjs';
import { startWechatNewsMorningDraftWorker } from '../wechat-news-morning-draft-worker.mjs';
import { resolveGooseApiTargetsFromEnv } from './goose-v149-canary.mjs';
import { loadH5Environment } from './load-env.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const codeRoot = path.join(__dirname, '..');
function parseApiTargets(env) {
const gooseCanary = resolveGooseApiTargetsFromEnv(env);
if (gooseCanary?.targets?.length) return [...gooseCanary.targets];
const csvTargets = String(env.TKMIND_API_TARGETS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const legacyTargets = [
env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
env.TKMIND_API_TARGET_1,
].filter(Boolean);
return [...new Set([...csvTargets, ...legacyTargets])];
}
async function bootstrapNewsMorningWorker(env) {
if (!isDatabaseConfigured()) {
throw new Error('DATABASE_URL 未配置');
}
const h5Root = resolvePortalH5Root(codeRoot, env);
const usersRoot = env.H5_USERS_ROOT?.trim() || path.join(h5Root, 'users');
const runtime = resolveMindSpaceServerRuntimeOptions(h5Root, env);
const apiTargets = parseApiTargets(env);
const apiTarget = apiTargets[0] ?? 'https://127.0.0.1:18006';
const apiSecret = env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
const wechatMpConfig = loadWechatMpConfig(env);
const mindSpaceAnalyticsConfig = resolveMindSpaceAnalyticsConfig();
const healthDataRuntime = createHealthDataRuntime({ env, logger: console });
const healthChannelStore = createHealthChannelSessionStore();
const pool = createDbPool();
let userAuth = null;
let sessionAccess = null;
let sessionSnapshotService = null;
const domainServices = await bootstrapPortalDomainServices({
pool,
h5Root,
env,
runtime,
analyticsConfig: mindSpaceAnalyticsConfig,
getUserAuth: () => userAuth,
getSessionSnapshotService: () => sessionSnapshotService,
workspaceMaintenanceEnabled: env.MEMIND_WORKSPACE_MAINTENANCE !== '0',
logger: console,
});
Object.assign(mindSpaceAnalyticsConfig, domainServices.mindSpaceAnalyticsConfig);
let llmProviderService = null;
let tkmindProxy = null;
let agentRunGateway = null;
const authServices = await bootstrapPortalAuthServices({
pool,
h5Root,
usersRoot,
mindSearchConfigService: domainServices.mindSearchConfigService,
env,
logger: console,
});
userAuth = authServices.userAuth;
sessionAccess = authServices.sessionAccess;
const agentServices = await bootstrapPortalAgentServices({
pool,
env,
runtime,
apiTarget,
apiTargets,
apiSecret,
userAuth,
sessionAccess,
subscriptionService: authServices.subscriptionService,
mindSpaceRuntimeAdapter: domainServices.mindSpaceRuntimeAdapter,
mindSpaceAssets: domainServices.mindSpaceAssets,
resolveUserIdByDirKey: domainServices.resolveUserIdByDirKey,
workspaceMaintenanceEnabled: env.MEMIND_WORKSPACE_MAINTENANCE !== '0',
startWorkspaceThumbnailWatcher,
startWorkspaceAssetSyncWatcher,
logger: console,
});
llmProviderService = agentServices.llmProviderService;
const memorySessionServices = await bootstrapPortalMemorySessionServices({
pool,
h5Root,
env,
llmProviderService,
userAuth,
sessionAccess,
logger: console,
});
sessionSnapshotService = memorySessionServices.sessionSnapshotService;
async function syncUserGeneratedPages(userId, options = {}) {
if (!domainServices.mindSpacePageSync) return;
return domainServices.mindSpacePageSync.syncUserGeneratedPages(userId, options);
}
const gatewayServices = bootstrapPortalGatewayServices({
pool,
h5Root,
env,
apiTarget,
apiTargets,
apiSecret,
userAuth,
sessionAccess,
sessionStreamStore: memorySessionServices.sessionStreamStore,
llmProviderService,
subscriptionService: authServices.subscriptionService,
billingConfigService: authServices.billingConfigService,
sessionSnapshotService,
conversationMemoryService: memorySessionServices.conversationMemoryService,
memoryV2: memorySessionServices.memoryV2,
systemDisclosurePolicyService: memorySessionServices.systemDisclosurePolicyService,
wechatCursorExecutorPolicyService: memorySessionServices.wechatCursorExecutorPolicyService,
mindSpaceAssets: domainServices.mindSpaceAssets,
directChatService: memorySessionServices.directChatService,
chatIntentRouter: memorySessionServices.chatIntentRouter,
syncUserGeneratedPages,
isSessionPageDeliveryActive: () => false,
experienceService: agentServices.experienceService,
});
tkmindProxy = gatewayServices.tkmindProxy;
agentRunGateway = gatewayServices.agentRunGateway;
const integrationServices = await bootstrapPortalIntegrationServices({
pool,
h5Root,
codeRoot,
env,
usersRoot,
wechatMpConfig,
mindSpacePublicFinish: domainServices.mindSpacePublicFinish,
userAuth,
sessionAccess,
tkmindProxy,
scheduleService: domainServices.scheduleService,
scheduledTaskService: domainServices.scheduledTaskService,
intentDraftService: domainServices.intentDraftService,
taskUnifiedService: domainServices.taskUnifiedService,
sessionSnapshotService,
wechatScheduleLlmConfigService: memorySessionServices.wechatScheduleLlmConfigService,
wechatSubscribeMorningLlmConfigService: memorySessionServices.wechatSubscribeMorningLlmConfigService,
wechatScheduledTaskManageLlmConfigService: memorySessionServices.wechatScheduledTaskManageLlmConfigService,
wechatIntentRouter: memorySessionServices.wechatIntentRouter,
wechatCursorExecutorPolicyService: memorySessionServices.wechatCursorExecutorPolicyService,
agentRunGateway,
llmProviderService,
chatIntentRouter: memorySessionServices.chatIntentRouter,
systemDisclosurePolicyService: memorySessionServices.systemDisclosurePolicyService,
mindSpaceAnalyticsConfig,
subscriptionService: authServices.subscriptionService,
apiTarget,
apiSecret,
mindSpacePages: domainServices.mindSpacePages,
mindSpacePageLiveEdit: domainServices.mindSpacePageLiveEdit,
syncUserGeneratedPages,
mindSpaceImageGeneration: agentServices.mindSpaceImageGeneration,
healthChannelStore,
healthObservationStore: healthDataRuntime.observationStore,
healthObservationService: healthDataRuntime.observationService,
healthDocumentStore: healthDataRuntime.documentStore,
healthDataRuntime,
healthEventStore: healthDataRuntime.eventStore,
logger: console,
setIntervalFn: () => ({ unref() {} }),
startWechatNewsMorningDraftWorkerFn: (options) =>
startWechatNewsMorningDraftWorker({ ...options, runOnStart: false }),
});
return {
pool,
worker: integrationServices.wechatNewsMorningDraftWorker,
draftService: integrationServices.wechatNewsMorningDraftService,
wechatMpConfig,
};
}
async function main() {
const generateOnly = process.argv.includes('--generate-only');
const pushOnly = process.argv.includes('--push-only');
loadH5Environment(__dirname);
process.env.H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED = '1';
process.env.MEMIND_WORKSPACE_MAINTENANCE = '0';
const newsEngineUrl = process.env.MEMIND_NEWS_ENGINE_URL?.trim();
if (newsEngineUrl && !pushOnly) {
try {
const response = await fetch(`${newsEngineUrl.replace(/\/$/, '')}/v1/collect`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}',
});
const payload = await response.json().catch(() => ({}));
console.log('[news-engine] collect', response.status, payload.status ?? payload.message ?? 'ok');
} catch (error) {
console.warn('[news-engine] collect skipped:', error instanceof Error ? error.message : error);
}
}
console.log('[bootstrap] starting portal integration services…');
const { pool, worker, draftService, wechatMpConfig } = await bootstrapNewsMorningWorker(process.env);
if (!wechatMpConfig.enabled) {
throw new Error('微信服务号未启用,无法推送草稿');
}
if (!worker?.generateToday) {
throw new Error('新闻早报 worker 未就绪(检查 H5_WECHAT_MP_* 与 tkmind 配置)');
}
const config = await draftService.getConfig();
console.log('[config]', JSON.stringify({
sourceUserId: config.sourceUserId,
templateId: config.templateId,
enabled: config.enabled,
}, null, 2));
let result = null;
if (!pushOnly) {
console.log('[generate] 开始生成今日早报页面(Agent 任务,约 10–20 分钟)…');
result = await worker.generateToday({
force: true,
pushDraft: !generateOnly,
});
console.log('[generate] done', JSON.stringify({
slug: result.page?.slug ?? null,
skipped: result.skipped ?? false,
pushed: Boolean(result.draft),
}, null, 2));
}
if (generateOnly) {
await pool.end();
return;
}
if (pushOnly || !result?.draft) {
console.log('[push] 推送今日草稿…');
const pushResult = await draftService.pushDraft({
triggeredBy: 'manual-generate-push-now',
requireToday: true,
});
console.log('[push] success', JSON.stringify({
draftMediaId: pushResult.draftMediaId,
pageSlug: pushResult.preview?.page?.slug ?? null,
pageUrl: pushResult.preview?.page?.publicUrl ?? null,
coverSource: pushResult.cover?.source ?? null,
runId: pushResult.run?.id ?? null,
}, null, 2));
} else {
console.log('[push] success', JSON.stringify({
draftMediaId: result.draft.draftMediaId,
pageSlug: result.draft.preview?.page?.slug ?? result.page?.slug ?? null,
pageUrl: result.draft.preview?.page?.publicUrl ?? null,
coverSource: result.draft.cover?.source ?? null,
runId: result.draft.run?.id ?? null,
}, null, 2));
}
await pool.end();
}
main().catch((error) => {
console.error('\n[failed]', error instanceof Error ? error.message : error);
if (error?.run) {
console.error(JSON.stringify(error.run, null, 2));
}
process.exit(1);
});
+4 -10
View File
@@ -10,21 +10,15 @@
import process from 'node:process';
import mysql from 'mysql2/promise';
import { loadH5Environment } from './load-env.mjs';
import { NEWS002_DEFAULT_TASK_SPEC_APPEND } from '../news-morning-templates.mjs';
loadH5Environment(import.meta.dirname);
const CONFIG_KEY = 'news_morning_draft';
const CONFIG_TABLE = 'h5_wechat_admin_config';
/** Agent 易回退到旧 daily-news 骨架,必须显式禁止 news001 DOM。 */
export const NEWS002_FORCE_TASK_APPEND = [
'【强制 news002 版式 · 覆盖一切旧稿参考】',
'1. 禁止输出 news001 结构:不得出现 class="hero"、class="date-badge"、class="quick-links"、class="highlight-box"、背景 #f7fafc / 主色 #b0191e。',
'2. 必须使用 news002 结构:div.masthead + div.lede + a.lead-card + ol.brief + div.card[data-event-key],并内嵌 taskSpec 里的 NEWS002_STYLE_BLOCK(暖纸 #FAF7F2、主色 #C8362F)。',
'3. 写页前不要 read_file 任何旧 daily-news-*.html 学版式;旧稿正文禁止复用。只允许按本 taskSpec 生成。',
'4. 全页每张故事卡必须带 data-event-key;独立事件 ≤28;flex 栏目无独立事件则整节删除。',
'5. 导航仅保留 news002 8 个主锚点(速读/头条/深读/国内/国际/财经/科技/天气/知识),禁止 25 项 quick-links。',
].join('\n');
/** @deprecated 使用 NEWS002_DEFAULT_TASK_SPEC_APPEND */
export const NEWS002_FORCE_TASK_APPEND = NEWS002_DEFAULT_TASK_SPEC_APPEND;
async function main() {
const apply = process.argv.includes('--apply');
@@ -51,7 +45,7 @@ async function main() {
enabled: current.enabled !== false,
templateId: restoreTemplate ? 'news001' : 'news002',
forceGenerateOnce: restoreTemplate ? false : true,
taskSpecAppend: restoreTemplate ? (current.taskSpecAppend ?? '') : NEWS002_FORCE_TASK_APPEND,
taskSpecAppend: restoreTemplate ? (current.taskSpecAppend ?? '') : NEWS002_DEFAULT_TASK_SPEC_APPEND,
};
console.log(JSON.stringify({
@@ -0,0 +1,60 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import {
formatRankedNewsCandidates,
resolveNewsEngineEndpoint,
} from '../news-engine-client.mjs';
import { prefetchNewsMorningSearxngBundle } from '../wechat-news-morning-search.mjs';
const env = process.env;
const newsEngineUrl = resolveNewsEngineEndpoint(env);
const searxngUrl = String(env.TKMIND_SEARCH_SEARXNG_URL ?? '').trim();
if (!newsEngineUrl) {
console.error('[verify-news-engine] 缺少 MEMIND_NEWS_ENGINE_URL');
process.exit(1);
}
if (!searxngUrl) {
console.error('[verify-news-engine] 缺少 TKMIND_SEARCH_SEARXNG_URL');
process.exit(1);
}
console.log('[verify-news-engine] news-engine:', newsEngineUrl);
console.log('[verify-news-engine] searxng:', searxngUrl);
const health = await fetch(`${newsEngineUrl}/health`).catch(() => null);
assert.ok(health?.ok, 'news-engine /health 不可用');
const healthBody = await health.json();
assert.equal(healthBody.service, 'memind-news-engine');
const started = Date.now();
const bundle = await prefetchNewsMorningSearxngBundle({
env,
limit: 3,
});
const elapsedMs = Date.now() - started;
assert.ok(Array.isArray(bundle.groups), 'bundle.groups 必须是数组');
assert.ok(bundle.groups.some((group) => group.results?.length > 0), '至少一个栏目应有预取结果');
assert.ok(bundle.ranking, 'ranking 不应为空');
assert.ok(Number(bundle.ranking.eventCount) >= 1, 'eventCount 应 >= 1');
assert.ok(Array.isArray(bundle.ranking.selected), 'selected 必须是数组');
assert.ok(bundle.ranking.selected.length > 0, 'news-engine 应返回至少 1 个选中事件');
const rankedBlock = formatRankedNewsCandidates(bundle.ranking);
assert.match(bundle.brief, /News Engine 编辑候选/u);
assert.match(rankedBlock, /must_include=/u);
console.log('[verify-news-engine] ok', {
elapsedMs,
candidateCount: bundle.ranking.candidateCount,
eventCount: bundle.ranking.eventCount,
selectedCount: bundle.ranking.selected.length,
dedupeRemovedCount: bundle.dedupeRemovedCount,
dateLabel: bundle.dateLabel?.iso,
});
for (const event of bundle.ranking.selected.slice(0, 5)) {
console.log(
` #${event.rank} [${event.category}] score=${event.finalScore} sources=${event.sourceCount} must=${event.mustInclude} ${event.canonicalTitle}`,
);
}
+70 -21
View File
@@ -84,13 +84,14 @@ async function applyNews002ImageGate(
throw new Error('news002 配图闸门无法读取生成页面');
}
let source = fs.readFileSync(page.localPath, 'utf8');
const promotedBefore = promoteNews002LeadCards(source);
if (promotedBefore.promotedCount > 0) {
logger.log?.('[NewsMorningDraft] news002 promoted headline cards', {
const normalizedBefore = promoteNews002LeadCards(source);
if (normalizedBefore.promotedCount > 0 || normalizedBefore.demotedCount > 0) {
logger.log?.('[NewsMorningDraft] news002 normalized headline cards', {
slug: page.slug,
promotedCount: promotedBefore.promotedCount,
promotedCount: normalizedBefore.promotedCount,
demotedCount: normalizedBefore.demotedCount,
});
source = promotedBefore.html;
source = normalizedBefore.html;
}
let enriched = await enrichNews002HtmlWithSourceImages(source, {
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 6000) || 6000,
@@ -117,12 +118,13 @@ async function applyNews002ImageGate(
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 6000) || 6000,
concurrency: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_CONCURRENCY ?? 8) || 8,
});
const promotedAfterSupplement = promoteNews002LeadCards(enriched.html);
if (promotedAfterSupplement.promotedCount > 0) {
enriched = { ...enriched, html: promotedAfterSupplement.html };
logger.log?.('[NewsMorningDraft] news002 promoted headline cards after searxng', {
const normalizedAfterSupplement = promoteNews002LeadCards(enriched.html);
if (normalizedAfterSupplement.promotedCount > 0 || normalizedAfterSupplement.demotedCount > 0) {
enriched = { ...enriched, html: normalizedAfterSupplement.html };
logger.log?.('[NewsMorningDraft] news002 normalized headline cards after searxng', {
slug: page.slug,
promotedCount: promotedAfterSupplement.promotedCount,
promotedCount: normalizedAfterSupplement.promotedCount,
demotedCount: normalizedAfterSupplement.demotedCount,
});
}
audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
@@ -139,9 +141,9 @@ async function applyNews002ImageGate(
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 6000) || 6000,
concurrency: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_CONCURRENCY ?? 8) || 8,
});
const promotedAfterRepair = promoteNews002LeadCards(enriched.html);
if (promotedAfterRepair.promotedCount > 0) {
enriched = { ...enriched, html: promotedAfterRepair.html };
const normalizedAfterRepair = promoteNews002LeadCards(enriched.html);
if (normalizedAfterRepair.promotedCount > 0 || normalizedAfterRepair.demotedCount > 0) {
enriched = { ...enriched, html: normalizedAfterRepair.html };
}
audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
}
@@ -149,24 +151,71 @@ async function applyNews002ImageGate(
}
if (!audit.ok && audit.violations.includes('invalid-lead-count')) {
const promotedFinal = promoteNews002LeadCards(enriched.html);
if (promotedFinal.promotedCount > 0) {
enriched = { ...enriched, html: promotedFinal.html };
const normalizedFinal = promoteNews002LeadCards(enriched.html);
if (normalizedFinal.promotedCount > 0 || normalizedFinal.demotedCount > 0) {
enriched = { ...enriched, html: normalizedFinal.html };
audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
logger.log?.('[NewsMorningDraft] news002 promoted headline cards before final audit', {
logger.log?.('[NewsMorningDraft] news002 normalized headline cards before localize', {
slug: page.slug,
promotedCount: promotedFinal.promotedCount,
promotedCount: normalizedFinal.promotedCount,
demotedCount: normalizedFinal.demotedCount,
});
}
}
const localized = await localizeNews002CardImages(enriched.html, {
let localized = await localizeNews002CardImages(enriched.html, {
env,
publishDir: path.dirname(page.localPath),
slug: page.slug,
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_DOWNLOAD_TIMEOUT_MS ?? 8000) || 8000,
});
fs.writeFileSync(page.localPath, localized.html);
audit = auditNews002ImageCoverage(localized.html, { requireTemplateStructure: true });
audit = auditNews002ImageCoverage(localized.html, {
requireTemplateStructure: true,
requireSectionFill: true,
env,
});
if (
!audit.ok
&& searchGroups.length > 0
&& shouldUseNewsMorningSearxngFallback(env)
&& audit.violations.some((item) => item.startsWith('underfilled:'))
) {
const fillSupplement = supplementNews002HtmlFromSearxngGroups(
localized.html,
searchGroups,
audit,
);
logger.log?.('[NewsMorningDraft] searxng fill supplement after section audit', {
slug: page.slug,
changed: fillSupplement.changed,
...fillSupplement.stats,
});
if (fillSupplement.changed) {
enriched = await enrichNews002HtmlWithSourceImages(fillSupplement.html, {
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 6000) || 6000,
concurrency: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_CONCURRENCY ?? 8) || 8,
});
const normalizedAfterFill = promoteNews002LeadCards(enriched.html);
if (normalizedAfterFill.promotedCount > 0 || normalizedAfterFill.demotedCount > 0) {
enriched = { ...enriched, html: normalizedAfterFill.html };
}
localized = await localizeNews002CardImages(enriched.html, {
env,
publishDir: path.dirname(page.localPath),
slug: page.slug,
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_DOWNLOAD_TIMEOUT_MS ?? 8000) || 8000,
});
fs.writeFileSync(page.localPath, localized.html);
audit = auditNews002ImageCoverage(localized.html, {
requireTemplateStructure: true,
requireSectionFill: true,
env,
});
}
}
logger.log?.('[NewsMorningDraft] news002 image audit', {
slug: page.slug,
attempted: enriched.attemptedCount,
@@ -177,7 +226,7 @@ async function applyNews002ImageGate(
});
if (!audit.ok) {
throw new Error(
`news002 配图/结构闸门未通过:${audit.violations.join(', ')}`
`news002 配图/结构/写满闸门未通过:${audit.violations.join(', ')}`
+ `(卡片 ${audit.cardCount},真实配图 ${audit.imageCount},头条配图 ${audit.leadImageCount}/${audit.leadCount})`,
);
}
+11 -3
View File
@@ -26,7 +26,6 @@ import {
auditNews002ImageCoverage,
extractNews002CardImageUrls,
isWeakNewsMorningImageUrl,
stripNews002ImagelessStoryCards,
} from './wechat-news-morning-images.mjs';
import {
NEWS001_FRESH_CONTENT_SPEC,
@@ -35,8 +34,10 @@ import {
NEWS001_VERSION,
NEWS_MORNING_TEMPLATE_IDS,
NEWS_MORNING_TEMPLATE_REFERENCE_URL as TEMPLATE_REFERENCE_URL,
auditNews002SectionFill,
buildNewsMorningTemplateSpec,
getNewsMorningTemplate,
NEWS002_DEFAULT_TASK_SPEC_APPEND,
resolveNewsMorningTemplateId,
} from './news-morning-templates.mjs';
@@ -117,6 +118,9 @@ export function isNewsMorningContentDegraded(html, { minStoryCards = 12 } = {})
if (/关于今日早报的重要说明|未填充任何当日具体新闻条目|未能获取当日可核验新闻/u.test(source)) {
return true;
}
if (/class="[^"]*\bmasthead\b/i.test(source)) {
return !auditNews002SectionFill(source).ok;
}
// 必须按 class token 统计:news002 的头条卡是 a.lead-card,
// 旧写法 `class="card(?:\s|")` 匹配不到,会把正常页误判成降级页。
return countNewsMorningStoryCards(source) < minStoryCards;
@@ -841,7 +845,6 @@ export async function buildDailyNewsWechatDraftArticleForPush({
memindLibRoot,
});
if (/class="[^"]*\bmasthead\b/i.test(String(html ?? ''))) {
html = stripNews002ImagelessStoryCards(String(html ?? '')).html;
const audit = auditNews002ImageCoverage(html, {
requireTemplateStructure: true,
requireAllCoreSections: false,
@@ -1183,7 +1186,12 @@ export function createWechatNewsMorningDraftService(
author: String(stored.author ?? defaults.author).trim().slice(0, 8) || defaults.author,
openComment: normalizeBoolean(stored.openComment, defaults.openComment),
forceGenerateOnce: normalizeBoolean(stored.forceGenerateOnce, false),
taskSpecAppend: String(stored.taskSpecAppend ?? '').trim(),
taskSpecAppend: (() => {
const storedAppend = String(stored.taskSpecAppend ?? '').trim();
if (storedAppend) return storedAppend;
const templateId = resolveNewsMorningTemplateId(stored.templateId, env);
return templateId === 'news002' ? NEWS002_DEFAULT_TASK_SPEC_APPEND : '';
})(),
forcePushOnce: normalizeBoolean(stored.forcePushOnce, false),
forcePushRequireToday: normalizeBoolean(stored.forcePushRequireToday, true),
templateId: resolveNewsMorningTemplateId(stored.templateId, env),
+27
View File
@@ -4,6 +4,7 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
buildDailyNewsWechatDraftArticle,
buildNewsMorningWechatPushText,
convertNewsPageHtmlToWechatArticle,
createWechatNewsMorningDraftService,
@@ -83,6 +84,32 @@ function createPool(seedRow = null) {
};
}
test('buildDailyNewsWechatDraftArticle keeps news002 imageless cards as text-only blocks', () => {
const html = `<!DOCTYPE html><html><body>
<div class="masthead"><h1>每日新闻早报</h1><div class="meta"><b>2026年9月22日</b></div></div>
<div class="lede">导语</div>
<div class="section" id="headlines"><div class="section-title"><h2>今日头条</h2></div>
<a class="lead-card" href="https://example.com/a" data-event-key="k1"><div class="media"><img src="https://news.example/a.jpg" alt=""></div>
<div class="body"><span class="tag">头条</span><h3>有图头条</h3><p>摘要A</p></div></a>
<a class="lead-card" href="https://example.com/b" data-event-key="k2"><div class="media ph">📰</div>
<div class="body"><span class="tag">头条</span><h3>无图头条</h3><p>摘要B</p></div></a>
</div>
<div class="section" id="domestic"><div class="section-title"><h2>国内</h2></div>
<div class="card" data-event-key="k3"><div class="body"><span class="tag">国内</span><h3>无图国内</h3><p>摘要C</p></div></div>
</div>
</body></html>`;
const article = buildDailyNewsWechatDraftArticle({
html,
publicUrl: 'https://m.tkmind.cn/demo',
imageUrlMap: new Map([['https://news.example/a.jpg', 'https://mmbiz.qpic.cn/a.jpg']]),
});
assert.ok(article.cardCount >= 3, '无图卡应保留为纯文字块');
assert.match(article.content, /有图头条/u);
assert.match(article.content, /无图头条/u);
assert.match(article.content, /无图国内/u);
assert.match(article.content, /mmbiz\.qpic\.cn\/a\.jpg/u);
});
test('convertNewsPageHtmlToWechatArticle converts daily-news to inline html with footer', () => {
const article = convertNewsPageHtmlToWechatArticle(SAMPLE_HTML, {
publicUrl: 'https://m.tkmind.cn/MindSpace/demo/public/daily-news-0910.html',
+139 -39
View File
@@ -3,6 +3,14 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
import { downloadNewsEngineMediaBytes, isNewsEngineMediaUrl } from './news-engine-client.mjs';
import {
NEWS002_MAX_STORY_CARDS,
NEWS002_MIN_FLEX_SECTIONS,
auditNews002SectionFill,
countNews002HeadlineLeadCards,
findNews002SectionIdForOffset,
} from './news-morning-templates.mjs';
const DEFAULT_TIMEOUT_MS = 6000;
const DEFAULT_USER_AGENT =
@@ -138,7 +146,9 @@ function isPlaceholderCardImage(value) {
return /news002-card-fallback|data-image-source=["']default["']/.test(raw);
}
function acceptCardImageSrc(value, { pageUrl = '' } = {}) {
function acceptCardImageSrc(value, { pageUrl = '', env = process.env } = {}) {
const raw = decodeHtmlAttribute(value).trim();
if (isNewsEngineMediaUrl(raw, env)) return raw;
const accepted = normalizeNewsMorningImageUrl(value, { pageUrl })
|| normalizeNewsMorningLocalImageRef(value);
return accepted && !isPlaceholderCardImage(accepted) && !isWeakNewsMorningImageUrl(accepted)
@@ -246,6 +256,7 @@ export async function resolveNewsMorningSourceImage(
export async function enrichNewsMorningSearchGroupsWithImages(
groups = [],
{
env = process.env,
fetchImpl = fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
maxItems = 48,
@@ -259,7 +270,12 @@ export async function enrichNewsMorningSearchGroupsWithImages(
const pending = [];
for (const group of cloned) {
for (const item of group.results) {
if (item.image || !item.url || pending.length >= maxItems) continue;
if (!item.url || pending.length >= maxItems) continue;
if (item.image && isNewsEngineMediaUrl(item.image, env)) continue;
if (item.image && !isNewsEngineMediaUrl(item.image, env)) {
delete item.image;
delete item.imageSource;
}
pending.push(item);
}
}
@@ -284,11 +300,11 @@ export async function enrichNewsMorningSearchGroupsWithImages(
return { groups: cloned, attemptedCount: pending.length, resolvedCount };
}
export function extractCardImage(cardRaw) {
export function extractCardImage(cardRaw, { env = process.env } = {}) {
const media = String(cardRaw ?? '').match(
/<div\b[^>]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<img\b[^>]*src=["']([^"']+)["']/i,
);
return acceptCardImageSrc(media?.[1] ?? '');
return acceptCardImageSrc(media?.[1] ?? '', { env });
}
/** 无真实 https 配图或仅有 ph 占位时视为不可展示。 */
@@ -317,31 +333,89 @@ function promoteCardRawToLead(card) {
return card.raw.replace(/\bcard\b/, 'lead-card');
}
/** Agent 常把头条写成 div.card;晋升 3~4 条有图卡为 a.lead-card 以满足结构闸门。 */
function demoteCardRawFromLead(card) {
let raw = String(card.raw ?? '');
if (/^<a\b/i.test(raw) && /\blead-card\b/i.test(raw)) {
const eventKey = attrOf(raw.match(/^<a\b[^>]*>/i)?.[0] ?? '', 'data-event-key');
raw = raw
.replace(/^<a\b[^>]*>/i, `<div class="card"${eventKey ? ` data-event-key="${escapeHtmlAttribute(eventKey)}"` : ''}>`)
.replace(/<\/a>\s*$/i, '</div>');
return raw;
}
return raw.replace(/\blead-card\b/, 'card');
}
function isHeadlinesSectionId(sectionId) {
return sectionId === 'headlines';
}
/**
* 规范化头条 lead-card:仅 #headlines 内保留 3~4 条,其它栏位的 lead-card 降级为 div.card。
*/
export function promoteNews002LeadCards(html, { min = 3, max = 4 } = {}) {
let output = String(html ?? '');
const leads = extractNews002StoryCards(output).filter(
(card) => card.isLead && cardHasDisplayableImage(card.raw),
);
if (leads.length >= min && leads.length <= max) {
return { html: output, promotedCount: 0 };
let promotedCount = 0;
let demotedCount = 0;
const demoteCards = (cards) => {
for (const card of [...cards].sort((a, b) => b.start - a.start)) {
const demoted = demoteCardRawFromLead(card);
output = output.slice(0, card.start) + demoted + output.slice(card.end);
demotedCount += 1;
}
};
let cards = extractNews002StoryCards(output).filter((card) => card.title);
demoteCards(cards.filter((card) => (
card.isLead && !isHeadlinesSectionId(findNews002SectionIdForOffset(output, card.start))
)));
cards = extractNews002StoryCards(output).filter((card) => card.title);
let headlineLeads = cards.filter((card) => (
card.isLead && isHeadlinesSectionId(findNews002SectionIdForOffset(output, card.start))
));
if (headlineLeads.length > max) {
const extras = headlineLeads
.sort((a, b) => Number(cardHasDisplayableImage(a.raw)) - Number(cardHasDisplayableImage(b.raw)))
.slice(max);
demoteCards(extras);
headlineLeads = headlineLeads.filter((card) => !extras.includes(card));
}
output = output.replace(
/<a\b[^>]*class="[^"]*\blead-card\b[^"]*"[^>]*>[\s\S]*?<\/a>\s*(?=<ol class="brief")/i,
'',
);
const candidates = extractNews002StoryCards(output)
.filter((card) => card.title && cardHasDisplayableImage(card.raw) && !card.isLead)
.slice(0, max);
const needCount = Math.max(0, min - leads.length);
const toPromote = candidates.slice(0, needCount);
for (const card of [...toPromote].sort((a, b) => b.start - a.start)) {
const promoted = promoteCardRawToLead(card);
output = output.slice(0, card.start) + promoted + output.slice(card.end);
if (headlineLeads.length < min) {
const candidates = extractNews002StoryCards(output)
.filter((card) => (
card.title
&& cardHasDisplayableImage(card.raw)
&& !card.isLead
&& isHeadlinesSectionId(findNews002SectionIdForOffset(output, card.start))
));
const needCount = Math.max(0, min - headlineLeads.length);
for (const card of candidates.slice(0, needCount).sort((a, b) => b.start - a.start)) {
const promoted = promoteCardRawToLead(card);
output = output.slice(0, card.start) + promoted + output.slice(card.end);
promotedCount += 1;
}
headlineLeads = extractNews002StoryCards(output).filter((card) => (
card.isLead && isHeadlinesSectionId(findNews002SectionIdForOffset(output, card.start))
));
}
return { html: output, promotedCount: toPromote.length };
if (headlineLeads.length < min) {
const candidates = extractNews002StoryCards(output)
.filter((card) => card.title && cardHasDisplayableImage(card.raw) && !card.isLead)
.sort((a, b) => Number(isHeadlinesSectionId(findNews002SectionIdForOffset(output, b.start)))
- Number(isHeadlinesSectionId(findNews002SectionIdForOffset(output, a.start))));
const needCount = Math.max(0, min - headlineLeads.length);
for (const card of candidates.slice(0, needCount).sort((a, b) => b.start - a.start)) {
const promoted = promoteCardRawToLead(card);
output = output.slice(0, card.start) + promoted + output.slice(card.end);
promotedCount += 1;
}
}
return { html: output, promotedCount, demotedCount };
}
const NEWS002_KEEP_EMPTY_SECTION_IDS = new Set(['weather', 'history', 'brief']);
@@ -501,6 +575,7 @@ async function encodeNews002LocalCardImage(buffer) {
export async function localizeNews002CardImages(
html,
{
env = process.env,
publishDir = '',
slug = 'daily-news',
fetchImpl = fetch,
@@ -516,8 +591,9 @@ export async function localizeNews002CardImages(
const cards = extractNews002StoryCards(source).filter((card) => card.title);
const remoteCards = [];
for (const card of cards) {
const image = extractCardImage(card.raw);
if (!image || !/^https:\/\//i.test(image)) continue;
const image = extractCardImage(card.raw, { env });
if (!image) continue;
if (!isNewsEngineMediaUrl(image, env) && !/^https:\/\//i.test(image)) continue;
remoteCards.push({ card, image });
}
@@ -541,11 +617,13 @@ export async function localizeNews002CardImages(
reusedCount += 1;
continue;
}
const bytes = await downloadNewsMorningImageBytes(item.image, {
fetchImpl,
timeoutMs,
referrer: item.card.url || '',
});
const bytes = isNewsEngineMediaUrl(item.image, env)
? await downloadNewsEngineMediaBytes(item.image, { env, fetchImpl, timeoutMs })
: await downloadNewsMorningImageBytes(item.image, {
fetchImpl,
timeoutMs,
referrer: item.card.url || '',
});
if (!bytes) {
failed.add(item.image);
continue;
@@ -567,9 +645,9 @@ export async function localizeNews002CardImages(
let output = source;
for (const card of [...cards].sort((a, b) => b.start - a.start)) {
const current = extractCardImage(card.raw);
const current = extractCardImage(card.raw, { env });
if (!current) continue;
if (!/^https:\/\//i.test(current)) continue;
if (!isNewsEngineMediaUrl(current, env) && !/^https:\/\//i.test(current)) continue;
const local = localByRemote.get(current) || '';
const replacement = renderCardMedia(card, local);
output = output.slice(0, card.start) + replacement + output.slice(card.end);
@@ -663,24 +741,38 @@ export async function enrichNews002HtmlWithSourceImages(
};
}
function envFlag(value, fallback = true) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
export function auditNews002ImageCoverage(
html,
{
maxStoryCards = 28,
maxStoryCards = NEWS002_MAX_STORY_CARDS,
requireTemplateStructure = false,
requireAllCoreSections = true,
requireLeadCount = true,
requireSectionFill = false,
minFlexSections = NEWS002_MIN_FLEX_SECTIONS,
env = process.env,
} = {},
) {
const source = String(html ?? '');
const cards = extractNews002StoryCards(source).filter((card) => card.title);
const missingImages = cards.filter((card) => !cardHasDisplayableImage(card.raw));
const leads = cards.filter((card) => card.isLead);
const missingLeadImages = leads.filter((card) => !cardHasDisplayableImage(card.raw));
const headlineLeadCount = countNews002HeadlineLeadCards(source);
const headlineLeads = cards.filter((card) => (
card.isLead && findNews002SectionIdForOffset(source, card.start) === 'headlines'
));
const missingLeadImages = headlineLeads.filter((card) => !cardHasDisplayableImage(card.raw));
const violations = [];
if (!/class="[^"]*\bmasthead\b/i.test(source)) violations.push('missing-masthead');
if (cards.length > maxStoryCards) violations.push('too-many-story-cards');
if (requireLeadCount && (leads.length < 3 || leads.length > 4)) violations.push('invalid-lead-count');
if (requireLeadCount && (headlineLeadCount < 3 || headlineLeadCount > 4)) {
violations.push('invalid-lead-count');
}
if (requireTemplateStructure) {
if (/class="[^"]*\b(hero|quick-links|highlight-box)\b/i.test(source)) {
violations.push('legacy-news001-dom');
@@ -735,13 +827,21 @@ export function auditNews002ImageCoverage(
violations.push('missing-card-why');
}
}
let sectionFill = null;
const shouldRequireFill = requireSectionFill
|| (requireTemplateStructure && envFlag(env.MEMIND_NEWS_MORNING_NEWS002_REQUIRE_FILL, true));
if (shouldRequireFill) {
sectionFill = auditNews002SectionFill(source, { minFlexSections });
violations.push(...sectionFill.violations);
}
return {
ok: violations.length === 0,
violations,
cardCount: cards.length,
imageCount: cards.length - missingImages.length,
leadCount: leads.length,
leadImageCount: leads.length - missingLeadImages.length,
leadCount: headlineLeadCount,
leadImageCount: headlineLeads.length - missingLeadImages.length,
missingTitles: missingImages.map((card) => card.title),
sectionFill,
};
}
+28
View File
@@ -91,6 +91,7 @@ test('enrichNews002HtmlWithSourceImages inserts media and body wrappers', async
leadCount: 3,
leadImageCount: 3,
missingTitles: [],
sectionFill: null,
});
});
@@ -161,6 +162,7 @@ test('normalizeNewsMorningLocalImageRef accepts public card assets', () => {
test('auditNews002ImageCoverage ignores default thumbnails and does not require images', () => {
const html = `<div class="masthead"></div>
<div class="section" id="headlines">
<a class="lead-card" data-event-key="k1" href="https://news.example/a">
<div class="media"><img src="news002-card-fallback.svg" alt="头条一"></div>
<div class="body"><h3>头条一真实新闻</h3></div>
@@ -171,6 +173,7 @@ test('auditNews002ImageCoverage ignores default thumbnails and does not require
<a class="lead-card" data-event-key="k3" href="https://news.example/c">
<div class="body"><h3>头条三真实新闻</h3></div>
</a>
</div>
<div class="card" data-event-key="k4">
<div class="body"><h3>普通真实新闻标题</h3></div>
</div>`;
@@ -182,6 +185,7 @@ test('auditNews002ImageCoverage ignores default thumbnails and does not require
leadCount: 3,
leadImageCount: 0,
missingTitles: ['头条一真实新闻', '头条二真实新闻', '头条三真实新闻', '普通真实新闻标题'],
sectionFill: null,
});
});
@@ -238,6 +242,30 @@ test('enrichNews002HtmlWithSourceImages never drops imageless story cards', asyn
assert.equal((result.html.match(/data-event-key="keep-\d"/g) || []).length, 4);
});
test('localizeNews002CardImages copies news-engine media onto the publish host', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'news002-ne-media-'));
const source = `<a class="lead-card" data-event-key="k1" href="https://news.example/a">
<div class="media"><img src="http://127.0.0.1:8092/media/abc.jpg" alt="头条"></div>
<div class="body"><h3>News Engine 配图</h3></div>
</a>`;
try {
const result = await localizeNews002CardImages(source, {
env: { MEMIND_NEWS_ENGINE_URL: 'http://127.0.0.1:8092' },
publishDir: tmp,
slug: 'daily-news-0922',
fetchImpl: async (url) => {
assert.equal(url, 'http://127.0.0.1:8092/media/abc.jpg');
return { ok: true, arrayBuffer: async () => TINY_PNG };
},
});
assert.equal(result.localizedCount, 1);
assert.match(result.html, /daily-news-0922-img-[a-f0-9]{12}\.jpg/);
assert.doesNotMatch(result.html, /127\.0\.0\.1:8092\/media\/abc\.jpg/);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('localizeNews002CardImages downloads remote images onto the publish host', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'news002-localize-'));
const source = `<div class="masthead"></div>
+123 -22
View File
@@ -3,8 +3,12 @@ import { resolvePortalSearxngEndpoint } from './mindsearch-prefetch.mjs';
import { getLocalParts } from './schedule-time.mjs';
import { enrichNewsMorningSearchGroupsWithImages } from './wechat-news-morning-images.mjs';
import {
applyRankingImagesToGroups,
fetchLatestCollectionFromEngine,
formatRankedNewsCandidates,
isNewsEngineMediaUrl,
rankNewsWithEngine,
shouldUseNewsEngineLatestCollection,
} from './news-engine-client.mjs';
function envFlag(value, fallback = false) {
@@ -156,6 +160,65 @@ export function isDuplicateNewsMorningStory(existingTitles = [], candidate = {})
});
}
function pickPreferredNewsMorningArticle(existing, incoming, { env = process.env } = {}) {
if (!existing) return incoming;
if (!incoming) return existing;
const existingLocal = isNewsEngineMediaUrl(existing.image, env);
const incomingLocal = isNewsEngineMediaUrl(incoming.image, env);
if (incomingLocal && !existingLocal) return incoming;
if (existingLocal && !incomingLocal) return existing;
if (incoming.image && !existing.image) return incoming;
if (existing.image && !incoming.image) return existing;
if (String(incoming.snippet ?? '').length > String(existing.snippet ?? '').length) return incoming;
return existing;
}
/** 合并多路 SearXNG / News Engine 采集结果,按栏目 id 归并并 URL 去重。 */
export function mergeNewsMorningSearchGroups(queries = [], ...sources) {
const merged = new Map(
(Array.isArray(queries) ? queries : []).map((query) => [query.id, { ...query, results: [] }]),
);
const urlIndex = new Map([...merged.keys()].map((id) => [id, new Map()]));
for (const source of sources) {
for (const group of Array.isArray(source) ? source : []) {
const groupId = String(group?.id ?? '').trim();
if (!groupId) continue;
if (!merged.has(groupId)) {
merged.set(groupId, {
id: groupId,
title: group.title || groupId,
query: group.query || '',
results: [],
});
urlIndex.set(groupId, new Map());
}
const bucket = merged.get(groupId);
const byUrl = urlIndex.get(groupId);
for (const item of Array.isArray(group?.results) ? group.results : []) {
const urlKey = normalizeNewsMorningUrl(item?.url);
if (!urlKey) {
bucket.results.push(item);
continue;
}
if (byUrl.has(urlKey)) {
byUrl.set(urlKey, pickPreferredNewsMorningArticle(byUrl.get(urlKey), item));
continue;
}
byUrl.set(urlKey, item);
}
}
}
return [...merged.values()].map((group) => {
const byUrl = urlIndex.get(group.id) ?? new Map();
return {
...group,
results: [...byUrl.values(), ...group.results],
};
});
}
/** 按栏目优先级跨组去重:先出现的栏目保留,后续栏目跳过重复 URL/标题。 */
export function dedupeNewsMorningSearchGroups(groups = [], { seen = null } = {}) {
const seenUrls = seen?.urls ?? new Set();
@@ -290,8 +353,46 @@ export async function prefetchNewsMorningSearxngBundle({
dedupeRemovedCount: 0,
};
}
let engineCollection = null;
if (shouldUseNewsEngineLatestCollection(env)) {
const latest = await fetchLatestCollectionFromEngine({
env,
fetchImpl: newsEngineFetchImpl,
logger,
});
if (
latest?.status === 'ok'
&& String(latest.dateLabel ?? '') === dateLabel.iso
&& Array.isArray(latest.groups)
&& latest.groups.some((group) => group.results?.length > 0)
) {
engineCollection = latest;
}
}
const endpoint = resolvePortalSearxngEndpoint(env);
if (!endpoint) {
let searxngGroups = emptyGroups;
if (endpoint) {
const timeoutMs = Number(env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000) || 8000;
const useNewsCategory = shouldUseNewsMorningNewsCategory(env);
const engines = resolveNewsMorningSearxngEngines(env);
const concurrency = resolveNewsMorningSearxngConcurrency(env);
searxngGroups = await mapWithConcurrency(queries, concurrency, async (item) => {
const raw = await searchNewsThenGeneral(item.query, {
endpoint,
searchImpl,
limit: limit + 3,
timeoutMs,
useNewsCategory,
engines,
});
const results = filterFreshNewsMorningSearchResults(
Array.isArray(raw) ? raw : [],
{ year: dateLabel.year },
).slice(0, limit);
return { ...item, results };
});
} else if (!engineCollection) {
return {
brief: emptyBrief,
groups: emptyGroups,
@@ -300,33 +401,31 @@ export async function prefetchNewsMorningSearxngBundle({
dedupeRemovedCount: 0,
};
}
const timeoutMs = Number(env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000) || 8000;
const useNewsCategory = shouldUseNewsMorningNewsCategory(env);
const engines = resolveNewsMorningSearxngEngines(env);
const concurrency = resolveNewsMorningSearxngConcurrency(env);
const groups = await mapWithConcurrency(queries, concurrency, async (item) => {
const raw = await searchNewsThenGeneral(item.query, {
endpoint,
searchImpl,
limit: limit + 3,
timeoutMs,
useNewsCategory,
engines,
});
const results = filterFreshNewsMorningSearchResults(
Array.isArray(raw) ? raw : [],
{ year: dateLabel.year },
).slice(0, limit);
return { ...item, results };
});
const groups = mergeNewsMorningSearchGroups(
queries,
searxngGroups,
engineCollection?.groups ?? [],
).map((group) => ({
...group,
results: (group.results ?? []).slice(0, limit),
}));
const ranking = await rankNewsWithEngine(groups, {
env,
now,
fetchImpl: newsEngineFetchImpl,
logger,
});
const { groups: dedupedGroups, removedCount } = dedupeNewsMorningSearchGroups(groups);
const imageEnrichment = await enrichNewsMorningSearchGroupsWithImages(dedupedGroups, {
const engineGroups = Array.isArray(ranking?.groups) && ranking.groups.length
? ranking.groups
: groups;
const { groups: dedupedGroups, removedCount } = dedupeNewsMorningSearchGroups(engineGroups);
const groupsWithEngineImages = applyRankingImagesToGroups(dedupedGroups, ranking, {
normalizeUrl: normalizeNewsMorningUrl,
});
const imageEnrichment = await enrichNewsMorningSearchGroupsWithImages(groupsWithEngineImages, {
env,
fetchImpl,
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 5000) || 5000,
maxItems: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_MAX_ITEMS ?? 48) || 48,
@@ -342,6 +441,8 @@ export async function prefetchNewsMorningSearxngBundle({
ranking,
dateLabel,
dedupeRemovedCount: removedCount,
source: engineCollection ? 'searxng+news-engine' : 'searxng',
collectionId: engineCollection?.id ?? null,
};
}
+120 -11
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
buildNewsMorningSearchQueries,
dedupeNewsMorningSearchGroups,
mergeNewsMorningSearchGroups,
filterFreshNewsMorningSearchResults,
formatNewsMorningSearchBrief,
isDuplicateNewsMorningStory,
@@ -37,6 +38,29 @@ test('filterFreshNewsMorningSearchResults drops wikinews archive hits', () => {
assert.match(filtered[0].title, /国乒/);
});
test('mergeNewsMorningSearchGroups combines searxng and news-engine results by group id', () => {
const queries = buildNewsMorningSearchQueries({ iso: '2026-09-22', year: '2026', label: '2026年09月22日' });
const merged = mergeNewsMorningSearchGroups(queries, [{
id: 'tech',
results: [{ title: 'SearXNG 科技', url: 'https://news.example/searxng-a' }],
}], [{
id: 'tech',
results: [{
title: 'Engine 科技',
url: 'https://news.example/searxng-a',
image: 'http://127.0.0.1:8092/media/a.jpg',
}],
}], [{
id: 'crypto',
results: [{ title: 'Engine 独有', url: 'https://news.example/engine-only' }],
}]);
const tech = merged.find((group) => group.id === 'tech');
assert.equal(tech.results.length, 1);
assert.match(tech.results[0].image, /\/media\//);
const crypto = merged.find((group) => group.id === 'crypto');
assert.equal(crypto.results[0].title, 'Engine 独有');
});
test('dedupeNewsMorningSearchGroups removes duplicate urls and similar titles across groups', () => {
const { groups, removedCount } = dedupeNewsMorningSearchGroups([
{
@@ -136,6 +160,86 @@ test('prefetchNewsMorningSearxngBrief queries news category then falls back', as
assert.match(brief, /图:https:\/\/cdn\.example\/news\.jpg/u);
});
test('prefetchNewsMorningSearxngBundle merges searxng prefetch with today news-engine collection', async () => {
let searchCalled = false;
const bundle = await prefetchNewsMorningSearxngBundle({
now: Date.parse('2026-09-22T05:00:00+08:00'),
env: {
MEMIND_NEWS_ENGINE_URL: 'http://127.0.0.1:8092',
TKMIND_SEARCH_SEARXNG_URL: 'http://127.0.0.1:20080/search',
},
searchImpl: async (query) => {
searchCalled = true;
if (String(query).includes('科技')) {
return [{ title: 'SearXNG 科技', url: 'https://news.example/searxng-tech', snippet: '来自 SearXNG' }];
}
return [];
},
newsEngineFetchImpl: async (url, init) => {
if (String(url).includes('/v1/collections/latest')) {
return {
ok: true,
status: 200,
async json() {
return {
id: 'c-today',
status: 'ok',
dateLabel: '2026-09-22',
groups: [{
id: 'tech',
title: '科技',
results: [{
title: 'Engine 科技',
url: 'https://news.example/searxng-tech',
image: 'http://127.0.0.1:8092/media/a.jpg',
}],
}],
};
},
};
}
if (String(url).includes('/v1/rank')) {
return {
ok: true,
async json() {
return {
candidateCount: 1,
eventCount: 1,
events: [],
selected: [{
rank: 1,
category: 'tech',
canonicalTitle: 'Engine 科技',
finalScore: 8.2,
sourceCount: 1,
mustInclude: false,
primaryArticle: {
url: 'https://news.example/searxng-tech',
image: 'http://127.0.0.1:8092/media/a.jpg',
sourceDomain: 'news.example',
},
}],
groups: [{
id: 'tech',
results: [{
title: 'Engine 科技',
url: 'https://news.example/searxng-tech',
image: 'http://127.0.0.1:8092/media/a.jpg',
}],
}],
};
},
};
}
throw new Error(`unexpected fetch ${url}`);
},
});
assert.equal(searchCalled, true);
assert.equal(bundle.source, 'searxng+news-engine');
assert.match(bundle.brief, /SearXNG 科技|Engine 科技/);
assert.match(bundle.brief, /News Engine 编辑候选/);
});
test('prefetchNewsMorningSearxngBundle returns structured groups for fallback', async () => {
const bundle = await prefetchNewsMorningSearxngBundle({
now: Date.parse('2026-09-15T05:00:00+08:00'),
@@ -151,14 +255,18 @@ test('prefetchNewsMorningSearxngBundle returns structured groups for fallback',
headers: { get: () => 'text/html' },
text: async () => '<meta property="og:image" content="https://cdn.example/story.jpg">',
}),
newsEngineFetchImpl: async () => ({
ok: true,
async json() {
return {
candidateCount: 1,
eventCount: 1,
events: [],
selected: [{
newsEngineFetchImpl: async (url) => {
if (String(url).includes('/v1/collections/latest')) {
return { ok: false, status: 404, async json() { return null; } };
}
return {
ok: true,
async json() {
return {
candidateCount: 1,
eventCount: 1,
events: [],
selected: [{
rank: 1,
category: 'international',
canonicalTitle: '样例新闻',
@@ -170,9 +278,10 @@ test('prefetchNewsMorningSearxngBundle returns structured groups for fallback',
url: 'https://news.example/story',
},
}],
};
},
}),
};
},
};
},
});
assert.ok(Array.isArray(bundle.groups));
assert.ok(bundle.groups.length > 10);
+47 -5
View File
@@ -1,4 +1,8 @@
import { NEWS002_MAX_STORY_CARDS, NEWS002_SECTIONS } from './news-morning-templates.mjs';
import {
NEWS002_FLEX_SECTION_IDS,
NEWS002_MAX_STORY_CARDS,
NEWS002_SECTIONS,
} from './news-morning-templates.mjs';
import {
extractCardImage,
extractNews002StoryCards,
@@ -137,6 +141,20 @@ function parseMissingSectionIds(violations = []) {
return [...ids];
}
function parseUnderfilledSectionTargets(violations = []) {
const targets = new Map();
for (const violation of violations) {
const match = String(violation).match(/^underfilled:([^:]+):(\d+)\/(\d+)$/);
if (!match) continue;
const sectionId = match[1];
const count = Number(match[2]);
const max = Number(match[3]);
const need = Math.max(0, max - count);
if (need > 0) targets.set(sectionId, { count, max, need });
}
return targets;
}
function hostLabel(url) {
try {
return new URL(String(url)).hostname.replace(/^www\./i, '');
@@ -361,13 +379,23 @@ export function supplementNews002HtmlFromSearxngGroups(
};
let output = source;
const missingSections = parseMissingSectionIds(audit?.violations ?? []);
const underfilledTargets = parseUnderfilledSectionTargets(audit?.violations ?? []);
const needLeads = (audit?.violations ?? []).includes('invalid-lead-count')
|| countLeadCardsInHeadlines(output) < 3;
|| countLeadCardsInHeadlines(output) < 3
|| (underfilledTargets.has('headlines')
&& countLeadCardsInHeadlines(output) < underfilledTargets.get('headlines').max);
let remainingBudget = Math.max(0, maxAdds - existingCards.length);
if (underfilledTargets.size > 0) {
const underfillNeed = [...underfilledTargets.values()].reduce((sum, item) => sum + item.need, 0);
remainingBudget = Math.max(remainingBudget, underfillNeed);
}
if (needLeads && remainingBudget > 0) {
const currentLeads = countLeadCardsInHeadlines(output);
const needCount = Math.max(0, 3 - currentLeads);
const headlineTarget = underfilledTargets.get('headlines');
const needCount = headlineTarget
? Math.max(0, headlineTarget.max - currentLeads)
: Math.max(0, 3 - currentLeads);
const toAdd = pickSearchCandidatesWithFallback(
groupMap,
SECTION_SEARCH_POOLS.headlines,
@@ -389,9 +417,20 @@ export function supplementNews002HtmlFromSearxngGroups(
}
}
const supplementableSectionIds = [
'domestic',
'world',
'business',
'tech',
'deepdive',
'knowledge',
...NEWS002_FLEX_SECTION_IDS,
];
const sectionIds = new Set([
...missingSections,
...['domestic', 'world', 'business', 'tech', 'deepdive', 'knowledge'].filter((id) => {
...underfilledTargets.keys(),
...supplementableSectionIds.filter((id) => {
if (underfilledTargets.has(id)) return true;
const meta = SECTION_BY_ID.get(id);
if (!meta) return false;
if (id === 'deepdive') return !hasNews002Section(output, id);
@@ -413,7 +452,10 @@ export function supplementNews002HtmlFromSearxngGroups(
const existingCount = sectionId === 'headlines'
? countLeadCardsInHeadlines(output)
: countStoryCardsInSection(output, sectionId);
const needCount = Math.max(0, meta.min - existingCount);
const underfill = underfilledTargets.get(sectionId);
const needCount = underfill
? Math.max(0, underfill.max - existingCount)
: Math.max(0, meta.min - existingCount);
if (!needCount && hasNews002Section(output, sectionId)) continue;
const pool = SECTION_SEARCH_POOLS[sectionId] ?? [SEARXNG_GROUP_TO_NEWS002_SECTION[sectionId]].filter(Boolean);