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:
@@ -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
|
||||
|
||||
@@ -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`,以及文章来源的
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
@@ -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})`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user