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:
@@ -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
|
||||
|
||||
@@ -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` 401;105 隧道端口 19081 健康
|
||||
|
||||
---
|
||||
|
||||
## `feature/wechat-push-secret-codes`
|
||||
|
||||
**状态:禁止再次引用。已 fast-forward 并入 `origin/main` 并完成 103 Portal 快速发布。**
|
||||
|
||||
@@ -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`,直接内嵌进 taskSpec,Agent 照抄即
|
||||
| 导语 | `.subtitle` | `.lede` |
|
||||
|
||||
news002 的卡片图会先上传到微信正文素材接口,再由 inline 渲染器输出微信 CDN 地址。
|
||||
任一新闻卡缺少真实同源图片、图片上传失败、头条不是 3~4 条、事件卡超过 28 条,
|
||||
或页面仍混有 news001 DOM,都会由生成/推送闸门阻断,不允许进入草稿箱。
|
||||
静态页与微信草稿均使用 **左上角 72~92px 小图**(`.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`;不使用与事件无法核验关系的泛图片搜索结果。
|
||||
|
||||
## 预览与验证
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(/&/gi, '&')
|
||||
.replace(/"/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
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user