Merge origin/main into feature/news002-searxng-fallback

Integrate news002 image localization and compact wechat layout from main
while keeping SearXNG fallback enrichment in the draft worker.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-22 09:29:05 +08:00
16 changed files with 689 additions and 106 deletions
+6
View File
@@ -230,6 +230,7 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# H5_SCHEDULED_TASK_MAX_ATTEMPTS=3
# H5_SCHEDULED_TASK_EXECUTION_TIMEOUT_MS=900000
# 新闻早报 · 微信草稿箱自动 worker(默认跟随 H5_REMINDER_WORKER_ENABLED
# 每天自动生成并推草稿:ENABLED + AUTO + AUTO_GENERATE 都要开;时刻以 DB 配置为准。
# H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED=1
# H5_WECHAT_NEWS_MORNING_DRAFT_AUTO=1
# H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED=1
@@ -530,6 +531,11 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind
# Template source: /Users/john/Project/searxng-prod/settings.yml.template
# Goosed MCP endpoints are automatically rewritten to host.docker.internal.
# TKMIND_SEARCH_SEARXNG_URL=http://127.0.0.1:20080/search
# 早报预取:默认走通用搜索(Yandex/Bing/360),最多 3 路并行,避免打满连接池和触发验证码。
# MEMIND_NEWS_MORNING_SEARXNG_PREFETCH=1
# MEMIND_NEWS_MORNING_SEARXNG_NEWS_CATEGORY=0
# MEMIND_NEWS_MORNING_SEARXNG_CONCURRENCY=3
# MEMIND_NEWS_MORNING_SEARXNG_ENGINES=yandex,bing,360search
# Portal 在 web / search-enhanced 技能执行前预取 SearXNG;设为 0 可关闭。
# MEMIND_LIVE_SEARCH_PREFETCH=1
# TKMIND_SEARCH_MCP_HOST_GATEWAY=host.docker.internal
+22
View File
@@ -1,5 +1,27 @@
# 历史分支处置登记
## `feature/searxng-news-morning-throttle`
**状态:禁止再次引用。已 fast-forward 并入 `origin/main` 并完成 103 Portal 快速发布。**
审计日期:2026-09-22
分支 HEAD`b74ab4ec`
`origin/main` 对应提交:`b74ab4ec`
### 交付内容
- news002 微信草稿与静态页统一左上角小图(72~92px 缩略图)
- 远程配图本地化到当日 MindSpace `public/`,缺图保留文字卡、禁止默认占位小图
- SearXNG 预取与配图 enrichment 链路收紧
### 103 发布记录
- Portal runtime`20260922-074006-b74ab4ec`(快速发布,`STUDIO_HOST=10.10.0.2`
- git_head`b74ab4ec46924be05c6928438816de5163079dc9`
- 发布后:Portal 200`/auth/login` 401105 隧道端口 19081 健康
---
## `feature/wechat-push-secret-codes`
**状态:禁止再次引用。已 fast-forward 并入 `origin/main` 并完成 103 Portal 快速发布。**
+10 -7
View File
@@ -60,9 +60,9 @@ prompt 层的「禁止重复」无法验证,所以在落盘后再做一次程
链路:SearXNG `img_src`/`thumbnail``normalizeSearchResult()` 透传 `image`/`publishedAt`
→ 预取 brief 里以 `图:{URL}` 单独成行 → Agent 写进 `.card .media > img`
-图:`loading="lazy"` + `referrerpolicy="no-referrer"` + `onerror` 降级为占位
- 无图:`<div class="media ph">{栏目 emoji}</div>`,版式不塌
- 禁止张冠李戴、禁止编造图片地址、禁止整页长图
-真实图:必须显示,`loading="lazy"` + `referrerpolicy="no-referrer"`
- 无图:先抓来源页 `og:image` / `twitter:image`;仍没有就不放配图,保留文字卡
- 禁止默认小图凑数、禁止张冠李戴、禁止编造图片地址、禁止整页长图
### 4. 视觉与排版
@@ -99,11 +99,14 @@ CSS 固化在 `NEWS002_STYLE_BLOCK`,直接内嵌进 taskSpecAgent 照抄即
| 导语 | `.subtitle` | `.lede` |
news002 的卡片图会先上传到微信正文素材接口,再由 inline 渲染器输出微信 CDN 地址。
任一新闻卡缺少真实同源图片、图片上传失败、头条不是 3~4 条、事件卡超过 28 条,
或页面仍混有 news001 DOM,都会由生成/推送闸门阻断,不允许进入草稿箱
静态页与微信草稿均使用 **左上角 7292px 小图**`.card` / `.lead-card` 左侧缩略图),不再用全宽大图。
能拿到真实配图的卡片必须显示并上传;没有真实图就只保留文字卡,不放默认小图
远程配图会先下载到当日 MindSpace `public/`,改写成本站相对 URL,避免海外图源在国内打不开。
已有配图上传失败、头条不是 3~4 条、事件卡超过 28 条,或页面仍混有 news001 DOM
才会由生成/推送闸门阻断,不允许进入草稿箱。
真实图片来源按优先级取文章来源的 `og:image:secure_url``og:image`
`twitter:image`;不使用与事件无法核验关系的泛图片搜索结果。
真实图片来源按优先级取 SearXNG 结果里的 `img_src`/`thumbnail`,以及文章来源的
`og:image:secure_url``og:image``twitter:image`;不使用与事件无法核验关系的泛图片搜索结果。
## 预览与验证
+2
View File
@@ -9,6 +9,7 @@ export async function searchSearxng(query, {
signal,
categories = '',
language = '',
engines = '',
} = {}) {
if (!endpoint) throw new Error('SearXNG endpoint is not configured');
try { if (!['http:', 'https:'].includes(new URL(endpoint).protocol)) throw new Error('unsupported protocol'); } catch { throw new Error('SearXNG endpoint is not configured safely'); }
@@ -17,6 +18,7 @@ export async function searchSearxng(query, {
url.searchParams.set('format', 'json');
if (categories) url.searchParams.set('categories', String(categories));
if (language) url.searchParams.set('language', String(language));
if (engines) url.searchParams.set('engines', String(engines));
const timeout = AbortSignal.timeout(timeoutMs);
const response = await fetchImpl(url, {
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
+13
View File
@@ -215,6 +215,19 @@ test('SearXNG adapter can request news category and language', async () => {
assert.match(requested, /language=zh-CN/);
});
test('SearXNG adapter can pin engines', async () => {
let requested;
await searchSearxng('今日热点', {
endpoint: 'http://search.local/search',
engines: 'yandex,bing',
fetchImpl: async (url) => {
requested = String(url);
return { ok: true, json: async () => ({ results: [] }) };
},
});
assert.match(requested, /engines=yandex%2Cbing|engines=yandex,bing/);
});
test('GitHub code adapter sends bounded queries and normalizes results', async () => {
let requested;
const result = await searchGithubCode('repo:openai goose', { limit: 50, fetchImpl: async (url, options) => { requested = { url: String(url), options }; return { ok: true, json: async () => ({ items: [{ repository: { full_name: 'openai/goose', description: 'agent' }, path: 'src/goose.rs', html_url: 'https://github.com/openai/goose/blob/main/src/goose.rs' }] }) }; } });
+13 -12
View File
@@ -186,15 +186,16 @@ export const NEWS002_STYLE_BLOCK = `<style>
color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;
justify-content:center;margin-top:3px}
.brief a{color:inherit;text-decoration:none;border-bottom:1px solid var(--line)}
/* 头条大图卡 */
.lead-card{display:block;background:var(--surface);border:1px solid var(--line);
border-radius:16px;overflow:hidden;margin-bottom:15px;text-decoration:none;color:inherit;
box-shadow:0 8px 22px rgba(52,42,31,.07)}
.lead-card .media{position:relative;aspect-ratio:16/9;background:var(--accent-soft)}
/* 头条卡:与普通卡一致,左侧小图 + 右侧正文 */
.lead-card{display:flex;gap:13px;background:var(--surface);border:1px solid var(--line);
border-radius:12px;padding:13px;margin-bottom:10px;text-decoration:none;color:inherit;
transition:transform .18s ease,box-shadow .18s ease}
.lead-card .media{flex-shrink:0;align-self:flex-start;width:92px;aspect-ratio:4/3;
border-radius:8px;overflow:hidden;background:var(--accent-soft)}
.lead-card .media img{width:100%;height:100%;object-fit:cover;display:block}
.lead-card .body{padding:14px 16px 16px}
.lead-card h3{font-family:var(--serif);font-size:19px;font-weight:700;line-height:1.45;margin-bottom:7px}
.lead-card p{font-size:14px;color:var(--ink-2)}
.lead-card .body{flex:1;min-width:0}
.lead-card h3{font-family:var(--serif);font-size:15.5px;font-weight:700;line-height:1.5;margin-bottom:5px}
.lead-card p{font-size:13.5px;color:var(--ink-2);line-height:1.65}
/* 图文混排卡 */
.card{display:flex;gap:13px;background:var(--surface);border:1px solid var(--line);
border-radius:12px;padding:13px;margin-bottom:10px;transition:transform .18s ease,box-shadow .18s ease}
@@ -318,11 +319,11 @@ export const NEWS002_TEMPLATE_SPEC = [
'',
'五、配图(news002 新增,必须做)',
'1. 预取 brief 里每条结果若带 `图:{URL}`,就把该 URL 用作这条新闻的配图。',
'1b. 正式 news002 的每张新闻卡都必须有与来源文章绑定的真实 https 配图;没有图的候选事件不得入选、不得落盘,core 栏目须换选另一条有图事件flex 栏目可整节省略。',
'2. 有写 `<div class="media"><img src="{图URL}" alt="{标题}" loading="lazy" referrerpolicy="no-referrer"></div>`;禁止输出 `.media.ph` 占位卡。',
'3. 无真实配图的事件直接不写;落盘后 worker 会剔除无图卡并删除空栏目,禁止为了过闸门编造图片地址或保留无图条目。',
'1b. 正式 news002 能拿到来源文章真实 https 配图的卡片必须显示该图;没有真实图就不要放 `<img>`、不要用默认小图或 `.media.ph` 占位。没有图事件也必须保留,禁止因缺图删卡。',
'2. 有真实图才写 `<div class="media"><img src="{图URL}" alt="{标题}" loading="lazy" referrerpolicy="no-referrer"></div>`。',
'3. 禁止为了过闸门编造图片地址;禁止用默认小图凑数。拿不到图就把 `.media` 整块拿掉,只留文字卡。',
'3b. 知识卡片的 emoji 用 `<div class="k-icon">`,否则字号会掉到正文大小。',
'4. 头条 3~4 条及所有普通新闻卡必须 100% 有真实配图;配图必须来自对应来源文章的 `og:image` / `twitter:image`,禁止拿甲事件的图配乙事件。',
'4. 配图优先用预取 `图:{URL}` 和来源文章的 `og:image` / `twitter:image`;能取到就必须显示,禁止拿甲事件的图配乙事件,禁止因缺图拿掉卡片。',
'5. 图片一律 https,禁止 data URI 长图,禁止整页长图截图。',
'',
'六、视觉(照抄,禁止自创色值)',
@@ -52,9 +52,6 @@ async function main() {
templateId: restoreTemplate ? 'news001' : 'news002',
forceGenerateOnce: restoreTemplate ? false : true,
taskSpecAppend: restoreTemplate ? (current.taskSpecAppend ?? '') : NEWS002_FORCE_TASK_APPEND,
autoPushEnabled: restoreTemplate
? current.autoPushEnabled
: false,
};
console.log(JSON.stringify({
+74 -11
View File
@@ -44,6 +44,12 @@ const WX = Object.freeze({
listWrap: 'margin:0 0 12px',
listTitle: `margin:0;font-size:14px;font-weight:700;color:${BRAND.ink};line-height:1.5`,
listBody: `margin:5px 0 0;font-size:13px;color:${BRAND.body};line-height:1.75`,
compactWrap: 'margin:0 0 12px',
compactThumb: 'display:inline-block;vertical-align:top;width:72px;margin-right:10px',
compactThumbImg: 'display:block;width:72px;height:54px;object-fit:cover;border-radius:8px',
compactBody: 'display:inline-block;vertical-align:top;width:calc(100% - 82px);max-width:calc(100% - 82px)',
compactTag: `margin:0 0 4px;font-size:10px;font-weight:700;color:${BRAND.red}`,
compactLeadTitle: `margin:0;font-size:15px;font-weight:700;color:${BRAND.ink};line-height:1.5`,
rankWrap: `margin:0 0 12px;background:${BRAND.tintRed};border-radius:10px;padding:12px 12px 8px`,
rankTitle: `margin:0 0 8px;font-size:14px;font-weight:700;color:${BRAND.ink};line-height:1.5`,
rankBody: `margin:0;font-size:13px;color:${BRAND.body};line-height:1.85`,
@@ -174,14 +180,23 @@ function extractClassDivBlocks(parentHtml, classPrefix) {
return blocks;
}
/** feature=渐变头条卡,rank=多行榜单卡,list=带序号的列表条目。 */
function classifyCard(cardHtml) {
if (detectFeatureTheme(cardHtml)) return 'feature';
function isNews002Html(html) {
const source = String(html ?? '');
if (/\bclass="[^"]*\bmasthead\b/u.test(source)) return true;
return /\bclass="[^"]*\blead-card\b/u.test(source) && !/\bclass="hero\b/u.test(source);
}
/** feature=渐变头条卡,rank=多行榜单卡,list=带序号的列表条目,lead=news002 头条小图卡。 */
function classifyCard(cardHtml, { news002Mode = false } = {}) {
if (news002Mode && /\blead-card\b/u.test(cardHtml)) return 'lead';
if (detectFeatureTheme(cardHtml, { news002Mode })) return 'feature';
const rawBody = extractInnerHtml(cardHtml, /<p[^>]*>([\s\S]*?)<\/p>/i);
return /<br\s*\/?>/i.test(rawBody) ? 'rank' : 'list';
}
function detectFeatureTheme(cardHtml) {
function detectFeatureTheme(cardHtml, { news002Mode = false } = {}) {
// news002 草稿与静态页统一为左上角小图,不走 news001 渐变头条卡。
if (news002Mode && /\blead-card\b/u.test(cardHtml)) return '';
// news002 用 .lead-card 取代 news001 的 .highlight-box 系列做头条卡。
if (/\blead-card\b/u.test(cardHtml)) return FEATURE_CARD_THEMES['highlight-box'];
if (/highlight-box-orange/u.test(cardHtml)) return FEATURE_CARD_THEMES['highlight-box-orange'];
@@ -257,15 +272,48 @@ function renderListCard({ title, body, index, imageUrl = '' }) {
].filter(Boolean).join('');
}
/** news002:左侧 72×54 缩略图 + 右侧标题正文,与静态页 .card/.lead-card 一致。 */
function renderNews002CompactCard({
title,
body,
tag = '',
index = 0,
imageUrl = '',
isLead = false,
}) {
const thumbBlock = imageUrl
? [
`<span style="${WX.compactThumb}">`,
`<img src="${imageUrl}" alt="${stripInlineText(title)}" style="${WX.compactThumbImg}" />`,
'</span>',
].join('')
: '';
const titleStyle = isLead ? WX.compactLeadTitle : WX.listTitle;
const titleHtml = isLead
? `<p style="${titleStyle}"><strong>${title}</strong></p>`
: `<p style="${titleStyle}"><span style="color:${BRAND.red}">${formatCardIndex(index)}</span> <strong>${title}</strong></p>`;
return [
`<section style="${WX.compactWrap}">`,
thumbBlock,
`<span style="${thumbBlock ? WX.compactBody : 'display:block'}">`,
tag ? `<p style="${WX.compactTag}">${tag}</p>` : '',
titleHtml,
body ? `<p style="${WX.listBody}">${body}</p>` : '',
'</span>',
'</section>',
].filter(Boolean).join('');
}
function renderCard(cardHtml, {
maxBodyChars = null,
index = 0,
imageUrlMap = new Map(),
news002Mode = false,
} = {}) {
const title = extractInnerHtml(cardHtml, /<h3[^>]*>([\s\S]*?)<\/h3>/i);
if (!title) return '';
const rawBody = extractInnerHtml(cardHtml, /<p[^>]*>([\s\S]*?)<\/p>/i);
const theme = detectFeatureTheme(cardHtml);
const theme = detectFeatureTheme(cardHtml, { news002Mode });
const rankBody = theme ? '' : renderRankListBody(rawBody);
if (rankBody) {
@@ -280,20 +328,32 @@ function renderCard(cardHtml, {
const body = maxBodyChars
? truncatePlainText(rawBody.replace(/<[^>]+>/g, ''), maxBodyChars)
: rawBody;
const imageUrl = extractMappedCardImage(cardHtml, imageUrlMap);
if (news002Mode) {
return renderNews002CompactCard({
title,
body,
tag: extractTagLabel(cardHtml),
index,
imageUrl,
isLead: /\blead-card\b/u.test(cardHtml),
});
}
if (theme) {
return renderFeatureCard(cardHtml, {
theme,
title,
body,
imageUrl: extractMappedCardImage(cardHtml, imageUrlMap),
imageUrl,
});
}
return renderListCard({
title,
body,
index,
imageUrl: extractMappedCardImage(cardHtml, imageUrlMap),
imageUrl,
});
}
@@ -596,7 +656,7 @@ function renderGenericSection(
maxCards = null,
maxBodyChars = null,
imageUrlMap = new Map(),
requireCardImage = false,
news002Mode = false,
} = {},
) {
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
@@ -623,17 +683,17 @@ function renderGenericSection(
if (maxCards != null && cardCount >= maxCards) continue;
const cardChunk = token.replace(/^(?:<div class="(?:card|highlight-box)|<a class="lead-card)(?:\s|")/i, '');
const cardHtml = `<div class="card ${isLeadCard ? 'lead-card ' : ''}${cardChunk}`;
if (requireCardImage && !extractMappedCardImage(cardHtml, imageUrlMap)) continue;
// 序号只给列表条目,头条卡与榜单卡靠自身样式区分,不占号。
const rendered = renderCard(cardHtml, {
maxBodyChars,
index: listIndex,
imageUrlMap,
news002Mode,
});
if (rendered) {
parts.push(rendered);
cardCount += 1;
if (classifyCard(cardHtml) === 'list') listIndex += 1;
if (classifyCard(cardHtml, { news002Mode }) === 'list') listIndex += 1;
}
}
if (cardCount === 0) return '';
@@ -887,6 +947,7 @@ function buildWechatInlineContent(html, {
imageUrlMap = new Map(),
} = {}) {
const sections = splitSections(html);
const news002Mode = isNews002Html(html);
const digest = renderTodayDigest(html);
const isNews002 = /class="[^"]*\bmasthead\b/i.test(String(html ?? ''));
const bodyParts = [renderHero(html, imageUrlMap, { includeSubtitle: !digest })];
@@ -926,7 +987,7 @@ function buildWechatInlineContent(html, {
maxCards: resolveSectionCardLimit(section, trimProfile.sectionCardLimits),
maxBodyChars: trimProfile.maxBodyChars,
imageUrlMap,
requireCardImage: isNews002,
news002Mode,
});
if (rendered) {
contentParts.push(rendered);
@@ -1124,7 +1185,9 @@ export function convertDailyNewsHtmlToWechatInlineArticle(html, {
}
export const wechatDailyNewsInlineInternals = {
isNews002Html,
renderCard,
renderNews002CompactCard,
renderHero,
renderTodayDigest,
renderBriefStats,
+14 -8
View File
@@ -162,20 +162,24 @@ const NEWS002_HTML = `<!DOCTYPE html><html><body>
</div>
</div></body></html>`;
test('news002 lead-card without image is omitted from wechat draft', () => {
test('news002 lead-card renders as compact left-thumb card in the wechat draft', () => {
const rendered = wechatDailyNewsInlineInternals.renderGenericSection(
NEWS002_HTML.match(/<div class="section" id="headlines">[\s\S]*?<\/a>\n<\/div>/)[0],
{ sectionId: 'headlines', requireCardImage: true },
{ sectionId: 'headlines', news002Mode: true },
);
assert.equal(rendered, '', '无图头条卡不应进入微信草稿');
assert.match(rendered, /王毅同鲁比奥通电话/u);
assert.match(rendered, /中美关系/u, 'lead-card 的 tag 必须保留');
assert.doesNotMatch(rendered, /01<\/span>/u, '头条卡不占列表序号');
assert.doesNotMatch(rendered, /calc\(100% \+ 28px\)/u, 'news002 头条不走全宽 feature 大图');
});
test('news002 card without image is omitted from wechat draft', () => {
test('news002 card with media block still yields title and body', () => {
const rendered = wechatDailyNewsInlineInternals.renderGenericSection(
NEWS002_HTML.match(/<div class="section" id="domestic">[\s\S]*$/)[0],
{ sectionId: 'domestic', requireCardImage: true },
{ sectionId: 'domestic', news002Mode: true },
);
assert.equal(rendered, '', '无图普通卡不应进入微信草稿');
assert.match(rendered, /沈阳举行九一八纪念活动/u);
assert.match(rendered, /国内正文摘要/u, '.media 在前不应挤掉正文 <p>');
});
test('news002 cards render uploaded source images in the wechat draft', () => {
@@ -187,15 +191,17 @@ test('news002 cards render uploaded source images in the wechat draft', () => {
const imageUrlMap = new Map([[sourceImage, wechatImage]]);
const lead = wechatDailyNewsInlineInternals.renderGenericSection(
html.match(/<div class="section" id="headlines">[\s\S]*?<\/a>\n<\/div>/)[0],
{ sectionId: 'headlines', imageUrlMap },
{ sectionId: 'headlines', imageUrlMap, news002Mode: true },
);
const domestic = wechatDailyNewsInlineInternals.renderGenericSection(
html.match(/<div class="section" id="domestic">[\s\S]*$/)[0],
{ sectionId: 'domestic', imageUrlMap },
{ sectionId: 'domestic', imageUrlMap, news002Mode: true },
);
assert.match(lead, /mmbiz\.qpic\.cn\/wechat-story\.jpg/u);
assert.match(domestic, /mmbiz\.qpic\.cn\/wechat-story\.jpg/u);
assert.doesNotMatch(lead, /news\.example\/story\.jpg/u);
assert.match(lead, /width:72px;height:54px/u, '配图必须是左上角小图');
assert.doesNotMatch(domestic, /width:100%;height:auto;margin:0 0 9px/u, '列表卡不再用顶部全宽图');
});
test('renderHero reads news002 masthead meta and lede', () => {
+13 -29
View File
@@ -23,8 +23,7 @@ import {
import {
auditNews002ImageCoverage,
enrichNews002HtmlWithSourceImages,
pruneNews002ImagelessStoryCards,
stripNews002ImagelessStoryCards,
localizeNews002CardImages,
} from './wechat-news-morning-images.mjs';
import {
isWechatNewsMorningDraftWorkerEnabled,
@@ -72,18 +71,6 @@ function applyNewsMorningDedupe(page, logger) {
}
}
function stripNews002ForGate(html, logger, page, label) {
const stripped = stripNews002ImagelessStoryCards(html);
if (stripped.removedCards > 0) {
logger.log?.('[NewsMorningDraft] news002 stripped imageless cards', {
slug: page.slug,
phase: label,
removedCards: stripped.removedCards,
});
}
return stripped.html;
}
async function applyNews002ImageGate(
page,
config,
@@ -95,12 +82,11 @@ async function applyNews002ImageGate(
if (!page?.localPath || !fs.existsSync(page.localPath)) {
throw new Error('news002 配图闸门无法读取生成页面');
}
let source = stripNews002ForGate(fs.readFileSync(page.localPath, 'utf8'), logger, page, 'pre-enrich');
const source = fs.readFileSync(page.localPath, 'utf8');
let enriched = await enrichNews002HtmlWithSourceImages(source, {
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 6000) || 6000,
concurrency: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_CONCURRENCY ?? 8) || 8,
});
fs.writeFileSync(page.localPath, enriched.html);
let audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
if (
@@ -122,7 +108,6 @@ 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,
});
fs.writeFileSync(page.localPath, enriched.html);
audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
}
@@ -137,16 +122,24 @@ 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,
});
fs.writeFileSync(page.localPath, enriched.html);
audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
}
}
}
const localized = await localizeNews002CardImages(enriched.html, {
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 });
logger.log?.('[NewsMorningDraft] news002 image audit', {
slug: page.slug,
attempted: enriched.attemptedCount,
resolved: enriched.resolvedCount,
localized: localized.localizedCount,
localizeFailed: localized.failedCount,
...audit,
});
if (!audit.ok) {
@@ -155,15 +148,6 @@ async function applyNews002ImageGate(
+ `(卡片 ${audit.cardCount},真实配图 ${audit.imageCount},头条配图 ${audit.leadImageCount}/${audit.leadCount}`,
);
}
const display = pruneNews002ImagelessStoryCards(enriched.html);
if (display.removedCards > 0 || display.removedSections > 0) {
logger.log?.('[NewsMorningDraft] news002 display prune after gate', {
slug: page.slug,
removedCards: display.removedCards,
removedSections: display.removedSections,
});
fs.writeFileSync(page.localPath, display.html);
}
return audit;
}
@@ -394,8 +378,6 @@ export function startWechatNewsMorningDraftWorker({
return;
}
if (!config.autoPushEnabled) return;
const shouldGenerate =
config.autoGenerateEnabled !== false
&& !todayPage
@@ -416,6 +398,8 @@ export function startWechatNewsMorningDraftWorker({
}
}
if (!config.autoPushEnabled) return;
if (
!isLocalScheduleDue(
{ hour: config.pushHour, minute: config.pushMinute, timezone },
+63
View File
@@ -101,6 +101,69 @@ test('news morning draft worker auto-pushes at configured time', async () => {
}
});
test('runOnce auto-generates in the lead window even if auto-push is off', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-autogen-'));
const userId = 'user-news';
const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
fs.mkdirSync(publicDir, { recursive: true });
const pool = createPool();
const service = createWechatNewsMorningDraftService(pool, {
mpConfig: { enabled: true, appId: 'app', appSecret: 'secret' },
h5Root: tmpRoot,
env: {
H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED: '1',
H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: userId,
},
});
service.getConfig = async () => ({
enabled: true,
sourceUserId: userId,
autoPushEnabled: false,
autoGenerateEnabled: true,
generateLeadMinutes: 60,
timezone: 'Asia/Shanghai',
pushHour: 6,
pushMinute: 0,
pageSlugPattern: 'daily-news-*',
});
const pushCalls = [];
service.pushDraft = async (options = {}) => {
pushCalls.push(options);
return { ok: true, draftMediaId: 'draft-should-not-fire' };
};
const executeCalls = [];
const fixedNow = Date.UTC(2026, 8, 10, 21, 15, 0);
const originalNow = Date.now;
Date.now = () => fixedNow;
try {
const worker = startWechatNewsMorningDraftWorker({
wechatNewsMorningDraftService: service,
mpConfig: { enabled: true },
userAuth: { canUseChat: async () => ({ ok: true }) },
tkmindProxy: { id: 'proxy' },
h5Root: tmpRoot,
env: { H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED: '1' },
executeTask: async () => {
executeCalls.push('generate');
fs.writeFileSync(path.join(publicDir, 'daily-news-0911.html'), `${SAMPLE_HTML}<!--lead-->`);
},
intervalMs: 30_000,
runOnStart: false,
setIntervalFn: () => ({ unref() {} }),
});
await worker.runOnce();
assert.equal(executeCalls.length, 1);
assert.equal(pushCalls.length, 0);
assert.match(fs.readFileSync(path.join(publicDir, 'daily-news-0911.html'), 'utf8'), /lead/);
} finally {
Date.now = originalNow;
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test('runOnce honors forceGenerateOnce even when today page exists', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-flag-'));
const userId = 'user-news';
+5 -1
View File
@@ -707,7 +707,10 @@ function collectHtmlBodyImageRefs(html) {
refs.push(coverRef);
}
for (const match of String(html ?? '').matchAll(/<img[^>]+src=["']([^"']+)["']/gi)) {
const ref = String(match[1] ?? '').trim();
const ref = String(match[1] ?? '')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.trim();
if (!ref || /^data:/i.test(ref) || seen.has(ref)) continue;
seen.add(ref);
refs.push(ref);
@@ -1546,6 +1549,7 @@ export function createWechatNewsMorningDraftService(
accessToken,
wechatFetch,
memindLibRoot,
publishDir: path.dirname(page.localPath),
})
: preview.article;
if (articleTitleOverride) {
+200 -19
View File
@@ -1,7 +1,16 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const DEFAULT_TIMEOUT_MS = 6000;
const DEFAULT_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
+ '(KHTML, like Gecko) Chrome/126.0 Safari/537.36 TKMind-NewsBot/1.0';
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
export const NEWS002_CARD_FALLBACK_FILENAME = 'news002-card-fallback.svg';
const NEWS002_LOCAL_IMAGE_MAX_BYTES = 2.5 * 1024 * 1024;
function decodeHtmlAttribute(value) {
return String(value ?? '')
@@ -107,6 +116,27 @@ export function normalizeNewsMorningImageUrl(value, { pageUrl = '' } = {}) {
}
}
export function normalizeNewsMorningLocalImageRef(value) {
const raw = decodeHtmlAttribute(value).trim();
if (!raw || /^data:/i.test(raw) || /^https?:/i.test(raw)) return '';
if (raw.includes('..') || raw.includes('\\')) return '';
const pathname = raw.split('?')[0].split('#')[0].replace(/^\.\//, '').replace(/^\/+/, '');
if (!pathname || !/\.(webp|png|jpe?g|gif|svg)$/i.test(pathname)) return '';
if (!/^[A-Za-z0-9._/-]+$/.test(pathname)) return '';
return pathname.slice(0, 400);
}
function isPlaceholderCardImage(value) {
const raw = String(value ?? '').toLowerCase();
return /news002-card-fallback|data-image-source=["']default["']/.test(raw);
}
function acceptCardImageSrc(value, { pageUrl = '' } = {}) {
const accepted = normalizeNewsMorningImageUrl(value, { pageUrl })
|| normalizeNewsMorningLocalImageRef(value);
return accepted && !isPlaceholderCardImage(accepted) ? accepted : '';
}
function readMetaContent(html, key, value) {
const source = String(html ?? '');
const tags = source.match(/<meta\b[^>]*>/gi) ?? [];
@@ -215,7 +245,7 @@ export function extractCardImage(cardRaw) {
const media = String(cardRaw ?? '').match(
/<div\b[^>]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<img\b[^>]*src=["']([^"']+)["']/i,
);
return normalizeNewsMorningImageUrl(media?.[1] ?? '');
return acceptCardImageSrc(media?.[1] ?? '');
}
/** 无真实 https 配图或仅有 ph 占位时视为不可展示。 */
@@ -313,12 +343,176 @@ function ensureBodyWrapper(inner) {
return `<div class="body">${source}</div>`;
}
function renderCardMedia(card, image) {
const parts = splitCardRaw(card);
if (!parts) return card.raw;
const cleanedInner = ensureBodyWrapper(removeExistingMedia(parts.inner));
if (!image) {
return `${parts.open}${cleanedInner}${parts.close}`;
}
return `${parts.open}<div class="media"><img src="${escapeHtmlAttribute(image)}" alt="${escapeHtmlAttribute(card.title)}" loading="lazy" referrerpolicy="no-referrer"></div>${cleanedInner}${parts.close}`;
}
export function resolveNews002CardFallbackAssetPath({ memindLibRoot = process.cwd() } = {}) {
const candidates = [
path.join(THIS_DIR, 'wechat/assets', NEWS002_CARD_FALLBACK_FILENAME),
path.join(memindLibRoot, 'wechat/assets', NEWS002_CARD_FALLBACK_FILENAME),
path.join(process.cwd(), 'wechat/assets', NEWS002_CARD_FALLBACK_FILENAME),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
}
function looksLikeImageBuffer(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return false;
if (buffer[0] === 0xff && buffer[1] === 0xd8) return true;
if (buffer[0] === 0x89 && buffer[1] === 0x50) return true;
if (buffer[0] === 0x47 && buffer[1] === 0x49) return true;
return buffer.slice(0, 4).toString('ascii') === 'RIFF'
&& buffer.slice(8, 12).toString('ascii') === 'WEBP';
}
function news002LocalImageFilename(slug, imageUrl) {
const safeSlug = String(slug || 'daily-news').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40) || 'daily-news';
const hash = createHash('sha1').update(String(imageUrl)).digest('hex').slice(0, 12);
return `${safeSlug}-img-${hash}.jpg`;
}
async function downloadNewsMorningImageBytes(imageUrl, {
fetchImpl = fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
referrer = '',
} = {}) {
const normalized = normalizeNewsMorningImageUrl(imageUrl);
if (!normalized) return null;
try {
const response = await fetchImpl(normalized, {
redirect: 'follow',
signal: AbortSignal.timeout(timeoutMs),
headers: {
'User-Agent': DEFAULT_USER_AGENT,
Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
...(referrer ? { Referer: referrer } : {}),
},
});
if (!response.ok) return null;
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length > NEWS002_LOCAL_IMAGE_MAX_BYTES || !looksLikeImageBuffer(buffer)) return null;
return buffer;
} catch {
return null;
}
}
async function encodeNews002LocalCardImage(buffer) {
return sharp(buffer)
.rotate()
.resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 78, mozjpeg: true })
.toBuffer();
}
export async function localizeNews002CardImages(
html,
{
publishDir = '',
slug = 'daily-news',
fetchImpl = fetch,
timeoutMs = 8000,
concurrency = 4,
} = {},
) {
const source = String(html ?? '');
if (!publishDir) {
return { html: source, localizedCount: 0, failedCount: 0, reusedCount: 0 };
}
fs.mkdirSync(publishDir, { recursive: true });
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;
remoteCards.push({ card, image });
}
const localByRemote = new Map();
const failed = new Set();
const claimed = new Set();
let cursor = 0;
let localizedCount = 0;
let reusedCount = 0;
async function worker() {
while (cursor < remoteCards.length) {
const item = remoteCards[cursor];
cursor += 1;
if (claimed.has(item.image)) continue;
claimed.add(item.image);
const filename = news002LocalImageFilename(slug, item.image);
const dest = path.join(publishDir, filename);
if (fs.existsSync(dest) && fs.statSync(dest).size > 32) {
localByRemote.set(item.image, filename);
reusedCount += 1;
continue;
}
const bytes = await downloadNewsMorningImageBytes(item.image, {
fetchImpl,
timeoutMs,
referrer: item.card.url || '',
});
if (!bytes) {
failed.add(item.image);
continue;
}
try {
const encoded = await encodeNews002LocalCardImage(bytes);
fs.writeFileSync(dest, encoded);
localByRemote.set(item.image, filename);
localizedCount += 1;
} catch {
failed.add(item.image);
}
}
}
await Promise.all(
Array.from({ length: Math.min(Math.max(1, concurrency), remoteCards.length || 1) }, () => worker()),
);
let output = source;
for (const card of [...cards].sort((a, b) => b.start - a.start)) {
const current = extractCardImage(card.raw);
if (!current) continue;
if (!/^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);
}
return {
html: output,
localizedCount,
failedCount: failed.size,
reusedCount,
};
}
export function materializeNews002CardFallbackImage(publishDir, options = {}) {
const sourcePath = resolveNews002CardFallbackAssetPath(options);
if (!sourcePath) throw new Error('缺少 news002 默认卡片图');
if (!publishDir) throw new Error('缺少 news002 默认卡片图输出目录');
fs.mkdirSync(publishDir, { recursive: true });
const dest = path.join(publishDir, NEWS002_CARD_FALLBACK_FILENAME);
fs.copyFileSync(sourcePath, dest);
return NEWS002_CARD_FALLBACK_FILENAME;
}
export async function enrichNews002HtmlWithSourceImages(
html,
{
fetchImpl = fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
concurrency = 8,
fallbackImage: _unusedFallbackImage = '',
} = {},
) {
const source = String(html ?? '');
@@ -345,30 +539,19 @@ export async function enrichNews002HtmlWithSourceImages(
let output = source;
for (const card of [...cards].sort((a, b) => b.start - a.start)) {
const parts = splitCardRaw(card);
if (!parts) continue;
const existingImage = extractCardImage(card.raw);
const image = existingImage || imageByStart.get(card.start) || '';
if (!image) {
output = output.slice(0, card.start) + output.slice(card.end);
continue;
}
const cleanedInner = ensureBodyWrapper(removeExistingMedia(parts.inner));
const media = `<div class="media"><img src="${escapeHtmlAttribute(image)}" alt="${escapeHtmlAttribute(card.title)}" loading="lazy" referrerpolicy="no-referrer"></div>`;
const replacement = `${parts.open}${media}${cleanedInner}${parts.close}`;
// Keep imageless cards, but do not render a placeholder/default thumbnail.
const replacement = renderCardMedia(card, image);
output = output.slice(0, card.start) + replacement + output.slice(card.end);
}
const stripped = stripNews002ImagelessStoryCards(output);
const remainingCards = extractNews002StoryCards(stripped.html).filter((card) => card.title);
return {
html: stripped.html,
cardCount: remainingCards.length,
html: output,
cardCount: cards.filter((card) => card.title).length,
attemptedCount: unresolved.length,
resolvedCount,
removedCards: stripped.removedCards,
removedSections: 0,
fallbackCount: 0,
};
}
@@ -388,9 +571,7 @@ export function auditNews002ImageCoverage(
const violations = [];
if (!/class="[^"]*\bmasthead\b/i.test(source)) violations.push('missing-masthead');
if (cards.length > maxStoryCards) violations.push('too-many-story-cards');
if (cards.length > 0 && missingImages.length > 0) violations.push('story-image-missing');
if (leads.length < 3 || leads.length > 4) violations.push('invalid-lead-count');
if (missingLeadImages.length > 0) violations.push('lead-image-missing');
if (requireTemplateStructure) {
if (/class="[^"]*\b(hero|quick-links|highlight-box)\b/i.test(source)) {
violations.push('legacy-news001-dom');
+155 -2
View File
@@ -1,4 +1,7 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
auditNews002ImageCoverage,
@@ -6,10 +9,19 @@ import {
enrichNews002HtmlWithSourceImages,
enrichNewsMorningSearchGroupsWithImages,
extractNewsMorningSourceImage,
localizeNews002CardImages,
materializeNews002CardFallbackImage,
NEWS002_CARD_FALLBACK_FILENAME,
normalizeNewsMorningImageUrl,
normalizeNewsMorningLocalImageRef,
pruneNews002ImagelessStoryCards,
} from './wechat-news-morning-images.mjs';
const TINY_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
function htmlResponse(html, url = 'https://news.example/story') {
return {
ok: true,
@@ -81,7 +93,7 @@ test('enrichNews002HtmlWithSourceImages inserts media and body wrappers', async
});
});
test('auditNews002ImageCoverage blocks missing images and excess cards', () => {
test('auditNews002ImageCoverage blocks excess cards but keeps imageless stories', () => {
const cards = Array.from({ length: 29 }, (_, index) => (
`<div class="card" data-event-key="k${index}"><h3>事件标题${index}</h3></div>`
)).join('');
@@ -90,7 +102,8 @@ test('auditNews002ImageCoverage blocks missing images and excess cards', () => {
);
assert.equal(audit.ok, false);
assert.ok(audit.violations.includes('too-many-story-cards'));
assert.ok(audit.violations.includes('story-image-missing'));
assert.equal(audit.violations.includes('story-image-missing'), false);
assert.equal(audit.imageCount, 0);
});
test('pruneNews002ImagelessStoryCards removes ph placeholders and empty sections', () => {
@@ -151,3 +164,143 @@ test('strict news002 audit rejects legacy or incomplete template structure', ()
assert.ok(audit.violations.some((item) => item.startsWith('missing-sections:')));
assert.ok(audit.violations.includes('noncanonical-palette'));
});
test('normalizeNewsMorningLocalImageRef accepts public card assets', () => {
assert.equal(normalizeNewsMorningLocalImageRef('./daily-news-0922-card.webp'), 'daily-news-0922-card.webp');
assert.equal(normalizeNewsMorningLocalImageRef('news002-card-fallback.svg'), 'news002-card-fallback.svg');
assert.equal(normalizeNewsMorningLocalImageRef('../secret.png'), '');
assert.equal(normalizeNewsMorningLocalImageRef('https://cdn.example/a.jpg'), '');
});
test('auditNews002ImageCoverage ignores default thumbnails and does not require images', () => {
const html = `<div class="masthead"></div>
<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>
</a>
<a class="lead-card" data-event-key="k2" href="https://news.example/b">
<div class="body"><h3>头条二真实新闻</h3></div>
</a>
<a class="lead-card" data-event-key="k3" href="https://news.example/c">
<div class="body"><h3>头条三真实新闻</h3></div>
</a>
<div class="card" data-event-key="k4">
<div class="body"><h3>普通真实新闻标题</h3></div>
</div>`;
assert.deepEqual(auditNews002ImageCoverage(html), {
ok: true,
violations: [],
cardCount: 4,
imageCount: 0,
leadCount: 3,
leadImageCount: 0,
missingTitles: ['头条一真实新闻', '头条二真实新闻', '头条三真实新闻', '普通真实新闻标题'],
});
});
test('enrichNews002HtmlWithSourceImages keeps imageless cards without a placeholder image', async () => {
const source = `<!doctype html><html><body>
<div class="masthead"><h1>每日新闻早报</h1></div>
<div class="section" id="headlines">
<a class="lead-card" data-event-key="k1" href="https://news.example/a">
<h3>真实头条新闻标题</h3><p>摘要</p>
</a>
<a class="lead-card" data-event-key="k2" href="https://news.example/b">
<h3>第二条真实头条新闻</h3><p>摘要</p>
</a>
<a class="lead-card" data-event-key="k3" href="https://news.example/c">
<h3>第三条真实头条新闻</h3><p>摘要</p>
</a>
</div>
<div class="card" data-event-key="k4"><h3>普通真实新闻标题</h3><p>摘要</p>
<div class="source"><a href="https://news.example/d">来源</a></div>
</div>
</body></html>`;
const result = await enrichNews002HtmlWithSourceImages(source, {
fallbackImage: NEWS002_CARD_FALLBACK_FILENAME,
fetchImpl: async () => htmlResponse('<html><body>no image</body></html>'),
});
assert.equal(result.resolvedCount, 0);
assert.equal(result.fallbackCount, 0);
assert.equal(result.cardCount, 4);
assert.match(result.html, /data-event-key="k1"/);
assert.match(result.html, /data-event-key="k2"/);
assert.match(result.html, /data-event-key="k3"/);
assert.match(result.html, /data-event-key="k4"/);
assert.doesNotMatch(result.html, /news002-card-fallback/);
assert.doesNotMatch(result.html, /class="media"/);
assert.equal(auditNews002ImageCoverage(result.html).imageCount, 0);
});
test('enrichNews002HtmlWithSourceImages never drops imageless story cards', async () => {
const source = `<div class="masthead"></div>
<a class="lead-card" data-event-key="keep-1" href="https://news.example/a"><h3>保留头条一新闻标题</h3></a>
<a class="lead-card" data-event-key="keep-2" href="https://news.example/b"><h3>保留头条二新闻标题</h3></a>
<a class="lead-card" data-event-key="keep-3" href="https://news.example/c"><h3>保留头条三新闻标题</h3></a>
<div class="card" data-event-key="keep-4"><h3>保留普通新闻标题七字</h3>
<div class="source"><a href="https://news.example/d">来源</a></div>
</div>`;
const result = await enrichNews002HtmlWithSourceImages(source, {
fallbackImage: NEWS002_CARD_FALLBACK_FILENAME,
fetchImpl: async () => htmlResponse('<html></html>'),
});
assert.equal(result.cardCount, 4);
assert.equal(result.resolvedCount, 0);
assert.equal(result.fallbackCount, 0);
assert.doesNotMatch(result.html, /class="media"/);
assert.equal((result.html.match(/data-event-key="keep-\d"/g) || []).length, 4);
});
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>
<a class="lead-card" data-event-key="k1" href="https://news.example/a">
<div class="media"><img src="https://cdn.example/story.jpg" alt="头条一"></div>
<div class="body"><h3>头条一真实新闻</h3></div>
</a>
<a class="lead-card" data-event-key="k2" href="https://news.example/b">
<div class="media"><img src="https://blocked.example/need-proxy.jpg" alt="头条二"></div>
<div class="body"><h3>头条二真实新闻</h3></div>
</a>
<a class="lead-card" data-event-key="k3" href="https://news.example/c">
<div class="body"><h3>头条三真实新闻</h3></div>
</a>`;
try {
const result = await localizeNews002CardImages(source, {
publishDir: tmp,
slug: 'daily-news-0922',
fetchImpl: async (url) => {
if (String(url).includes('blocked.example')) {
return { ok: false, status: 401, arrayBuffer: async () => new ArrayBuffer(0) };
}
return {
ok: true,
arrayBuffer: async () => TINY_PNG,
};
},
});
assert.equal(result.localizedCount, 1);
assert.equal(result.failedCount, 1);
assert.match(result.html, /daily-news-0922-img-[a-f0-9]{12}\.jpg/);
assert.doesNotMatch(result.html, /cdn\.example\/story\.jpg/);
assert.doesNotMatch(result.html, /blocked\.example/);
assert.match(result.html, /data-event-key="k2"/);
const files = fs.readdirSync(tmp).filter((name) => name.endsWith('.jpg'));
assert.equal(files.length, 1);
assert.ok(fs.statSync(path.join(tmp, files[0])).size > 32);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('materializeNews002CardFallbackImage copies the shared default thumbnail once', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'news002-fallback-'));
try {
const filename = materializeNews002CardFallbackImage(tmp);
assert.equal(filename, NEWS002_CARD_FALLBACK_FILENAME);
assert.equal(fs.existsSync(path.join(tmp, filename)), true);
assert.match(fs.readFileSync(path.join(tmp, filename), 'utf8'), /svg/i);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
+50 -12
View File
@@ -25,6 +25,36 @@ export function shouldPrefetchNewsMorningSearxng(env = process.env) {
return envFlag(env.MEMIND_NEWS_MORNING_SEARXNG_PREFETCH, true);
}
export function shouldUseNewsMorningNewsCategory(env = process.env) {
return envFlag(env.MEMIND_NEWS_MORNING_SEARXNG_NEWS_CATEGORY, false);
}
export function resolveNewsMorningSearxngEngines(env = process.env) {
return String(env.MEMIND_NEWS_MORNING_SEARXNG_ENGINES ?? 'yandex,bing,360search').trim();
}
export function resolveNewsMorningSearxngConcurrency(env = process.env) {
const raw = Number(env.MEMIND_NEWS_MORNING_SEARXNG_CONCURRENCY ?? 3);
if (!Number.isFinite(raw) || raw < 1) return 3;
return Math.min(8, Math.floor(raw));
}
async function mapWithConcurrency(items, concurrency, mapper) {
const list = Array.isArray(items) ? items : [];
const results = new Array(list.length);
let next = 0;
const workerCount = Math.max(1, Math.min(Number(concurrency) || 1, list.length || 1));
async function worker() {
while (next < list.length) {
const index = next;
next += 1;
results[index] = await mapper(list[index], index);
}
}
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return results;
}
export function buildNewsMorningSearchQueries(dateLabel) {
const day = String(dateLabel?.iso ?? '').trim();
const label = String(dateLabel?.label ?? day).trim() || day;
@@ -206,21 +236,24 @@ async function searchNewsThenGeneral(query, {
searchImpl,
limit,
timeoutMs,
useNewsCategory = false,
engines = '',
}) {
const newsHits = await searchImpl(query, {
endpoint,
limit,
timeoutMs,
categories: 'news',
language: 'zh-CN',
}).catch(() => []);
if (Array.isArray(newsHits) && newsHits.length > 0) return newsHits;
return searchImpl(query, {
const common = {
endpoint,
limit,
timeoutMs,
language: 'zh-CN',
}).catch(() => []);
...(engines ? { engines } : {}),
};
if (useNewsCategory) {
const newsHits = await searchImpl(query, {
...common,
categories: 'news',
}).catch(() => []);
if (Array.isArray(newsHits) && newsHits.length > 0) return newsHits;
}
return searchImpl(query, common).catch(() => []);
}
export async function prefetchNewsMorningSearxngBundle({
@@ -243,19 +276,24 @@ export async function prefetchNewsMorningSearxngBundle({
return { brief: emptyBrief, groups: emptyGroups, dateLabel, dedupeRemovedCount: 0 };
}
const timeoutMs = Number(env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000) || 8000;
const groups = await Promise.all(queries.map(async (item) => {
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: dedupedGroups, removedCount } = dedupeNewsMorningSearchGroups(groups);
const imageEnrichment = await enrichNewsMorningSearchGroupsWithImages(dedupedGroups, {
fetchImpl,
+49 -2
View File
@@ -8,6 +8,8 @@ import {
isDuplicateNewsMorningStory,
prefetchNewsMorningSearxngBrief,
prefetchNewsMorningSearxngBundle,
resolveNewsMorningSearxngConcurrency,
shouldUseNewsMorningNewsCategory,
} from './wechat-news-morning-search.mjs';
test('buildNewsMorningSearchQueries covers core and extended channels', () => {
@@ -76,15 +78,37 @@ test('formatNewsMorningSearchBrief requires dual search even when empty', () =>
assert.match(text, /已剔除 2 条重复/);
});
test('prefetch defaults skip the news category and cap concurrency', () => {
assert.equal(shouldUseNewsMorningNewsCategory({}), false);
assert.equal(resolveNewsMorningSearxngConcurrency({}), 3);
assert.equal(resolveNewsMorningSearxngConcurrency({
MEMIND_NEWS_MORNING_SEARXNG_CONCURRENCY: '2',
}), 2);
});
test('prefetchNewsMorningSearxngBrief queries news category then falls back', async () => {
const calls = [];
let seq = 0;
let inFlight = 0;
let maxInFlight = 0;
const brief = await prefetchNewsMorningSearxngBrief({
now: Date.parse('2026-09-15T05:00:00+08:00'),
timezone: 'Asia/Shanghai',
env: { TKMIND_SEARCH_SEARXNG_URL: 'http://127.0.0.1:20080/search' },
env: {
TKMIND_SEARCH_SEARXNG_URL: 'http://127.0.0.1:20080/search',
MEMIND_NEWS_MORNING_SEARXNG_NEWS_CATEGORY: '1',
MEMIND_NEWS_MORNING_SEARXNG_CONCURRENCY: '2',
},
searchImpl: async (query, options) => {
calls.push({ query, categories: options.categories || '' });
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
calls.push({
query,
categories: options.categories || '',
engines: options.engines || '',
});
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
const slug = `${++seq}-${options.categories || 'general'}`;
if (options.categories === 'news' && /国内/.test(query)) {
return [];
@@ -103,6 +127,8 @@ test('prefetchNewsMorningSearxngBrief queries news category then falls back', as
});
assert.ok(calls.some((item) => item.categories === 'news'));
assert.ok(calls.some((item) => item.categories === '' && /国内/.test(item.query)));
assert.ok(calls.every((item) => item.engines === 'yandex,bing,360search'));
assert.ok(maxInFlight <= 2);
assert.match(brief, /## 国际[\s\S]*新闻源/);
assert.match(brief, /## 国内[\s\S]*通用源/);
assert.match(brief, /全页去重/);
@@ -129,6 +155,27 @@ test('prefetchNewsMorningSearxngBundle returns structured groups for fallback',
assert.match(bundle.brief, /专用联网搜索预取/);
});
test('prefetchNewsMorningSearxngBrief skips news category by default', async () => {
const calls = [];
const brief = await prefetchNewsMorningSearxngBrief({
now: Date.parse('2026-09-15T05:00:00+08:00'),
timezone: 'Asia/Shanghai',
env: { TKMIND_SEARCH_SEARXNG_URL: 'http://127.0.0.1:20080/search' },
searchImpl: async (query, options) => {
calls.push({ query, categories: options.categories || '' });
return [{ title: `通用 ${query}`, url: 'https://web.example/a', snippet: '摘要' }];
},
fetchImpl: async () => ({
ok: true,
headers: { get: () => 'text/html' },
text: async () => '',
}),
});
assert.equal(calls.some((item) => item.categories === 'news'), false);
assert.ok(calls.length > 0);
assert.match(brief, /专用联网搜索预取/);
});
test('prefetchNewsMorningSearxngBrief skips live calls without endpoint', async () => {
const brief = await prefetchNewsMorningSearxngBrief({
now: Date.parse('2026-09-15T05:00:00+08:00'),