feat: enforce real imagery for news002

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-21 11:03:55 +08:00
parent 91d5c7a1fd
commit 6a88b98044
15 changed files with 1293 additions and 56 deletions
+10 -3
View File
@@ -98,9 +98,12 @@ CSS 固化在 `NEWS002_STYLE_BLOCK`,直接内嵌进 taskSpec,Agent 照抄即
| 日期行 | `.date-badge` | `.masthead .meta` |
| 导语 | `.subtitle` | `.lede` |
**已知缺口**:news002 的 per-card 配图目前不会上传到微信正文。
`uploadDailyNewsBodyImagesForWechat()` 只处理页面里已有的 `<img>`,而 inline 渲染的
`renderListCard` / `renderFeatureCard` 不输出图片。公众号侧配图需要单独一轮改造。
news002 的卡片图会先上传到微信正文素材接口,再由 inline 渲染器输出微信 CDN 地址。
任一新闻卡缺少真实同源图片、图片上传失败、头条不是 3~4 条、事件卡超过 28 条,
或页面仍混有 news001 DOM,都会由生成/推送闸门阻断,不允许进入草稿箱。
真实图片来源按优先级取文章来源的 `og:image:secure_url`、`og:image`、
`twitter:image`;不使用与事件无法核验关系的泛图片搜索结果。
## 预览与验证
@@ -108,8 +111,12 @@ CSS 固化在 `NEWS002_STYLE_BLOCK`,直接内嵌进 taskSpec,Agent 照抄即
# 渲染一张 news002 静态预览页(样例数据,占位图)
node scripts/preview-news002-template.mjs /tmp/news002-preview.html
# 用当天真实新闻 + 对应文章来源图片生成视觉样图(不推微信、不写生产目录)
node scripts/build-news002-real-design-sample.mjs
# 相关单测
node --test news-morning-templates.test.mjs \
wechat-news-morning-images.test.mjs \
wechat-news-morning-dedup.test.mjs \
wechat-news-morning-draft.test.mjs \
wechat-news-morning-draft-worker.test.mjs \
+31 -9
View File
@@ -124,25 +124,34 @@ export const NEWS002_STYLE_BLOCK = `<style>
--ink:#1C1B19; --ink-2:#4A4742; --ink-3:#8A857D;
--paper:#FAF7F2; --surface:#FFFFFF; --line:#E8E2D9;
--brand:#C8362F; --brand-soft:#FBEDEB; --accent:#1B3A6B; --accent-soft:#EAF0F8;
--gold:#B7791F; --gold-soft:#FFF5D9; --green:#28705A; --green-soft:#E8F5EF;
--violet:#6653A3; --violet-soft:#F0ECFA;
--serif:"Noto Serif SC","Source Han Serif SC","Songti SC",Georgia,serif;
--sans:-apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
}
@media (prefers-color-scheme:dark){
:root{--ink:#F0EDE8;--ink-2:#B8B2A9;--ink-3:#807A72;--paper:#14130F;--surface:#1E1C18;
--line:#2E2B25;--brand:#E8635B;--brand-soft:#33201E;--accent:#7FA6DC;--accent-soft:#1B2433}
--line:#2E2B25;--brand:#E8635B;--brand-soft:#33201E;--accent:#7FA6DC;--accent-soft:#1B2433;
--gold:#E2AD51;--gold-soft:#302719;--green:#70B99E;--green-soft:#182B24;
--violet:#A89ADE;--violet-soft:#252037}
}
body{background:var(--paper);color:var(--ink);font-family:var(--sans);line-height:1.68;
-webkit-font-smoothing:antialiased;padding-bottom:40px}
.progress{position:fixed;top:0;left:0;height:3px;width:0;background:var(--brand);z-index:99}
/* 报头 */
.masthead{max-width:720px;margin:0 auto;padding:26px 20px 14px;text-align:center}
.masthead{max-width:720px;margin:14px auto 0;padding:28px 20px 18px;text-align:center;
border:1px solid var(--line);border-radius:18px 18px 0 0;
background:radial-gradient(circle at 16% 0,var(--brand-soft),transparent 36%),
radial-gradient(circle at 88% 12%,var(--accent-soft),transparent 38%),var(--surface);
box-shadow:0 8px 30px rgba(52,42,31,.06)}
.masthead .kicker{font-size:11px;letter-spacing:.32em;color:var(--brand);font-weight:700}
.masthead h1{font-family:var(--serif);font-size:32px;font-weight:700;letter-spacing:2px;margin:6px 0 10px}
.masthead .meta{display:flex;justify-content:center;gap:10px;flex-wrap:wrap;
font-size:12px;color:var(--ink-3);padding-top:10px;border-top:1px solid var(--line)}
.masthead .meta b{color:var(--ink-2);font-weight:600}
.lede{max-width:720px;margin:0 auto;padding:0 20px 14px;font-family:var(--serif);
font-size:15px;line-height:1.8;color:var(--ink-2);text-align:center}
.lede{max-width:720px;margin:0 auto;padding:13px 24px 15px;font-family:var(--serif);
font-size:15px;line-height:1.8;color:var(--ink-2);text-align:left;
background:var(--gold-soft);border-left:4px solid var(--gold)}
/* 吸顶导航:只放 8 个主锚点 */
.nav{position:sticky;top:0;z-index:50;background:color-mix(in srgb,var(--paper) 92%,transparent);
backdrop-filter:blur(12px);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
@@ -159,6 +168,15 @@ export const NEWS002_STYLE_BLOCK = `<style>
.section-title .icon{font-size:17px}
.section-title h2{font-family:var(--serif);font-size:19px;font-weight:700;letter-spacing:.5px}
.section-title .count{margin-left:auto;font-size:11px;color:var(--ink-3);letter-spacing:.1em}
#headlines .section-title{border-color:var(--brand)}
#domestic .section-title,#living .section-title{border-color:var(--green)}
#world .section-title,#tech .section-title{border-color:var(--accent)}
#business .section-title{border-color:var(--gold)}
#science .section-title,#culture .section-title{border-color:var(--violet)}
#domestic .tag,#living .tag{background:var(--green-soft);color:var(--green)}
#world .tag,#tech .tag{background:var(--accent-soft);color:var(--accent)}
#business .tag{background:var(--gold-soft);color:var(--gold)}
#science .tag,#culture .tag{background:var(--violet-soft);color:var(--violet)}
/* 一分钟速读 */
.brief{background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:6px 16px}
.brief li{list-style:none;display:flex;gap:10px;padding:11px 0;
@@ -170,7 +188,8 @@ export const NEWS002_STYLE_BLOCK = `<style>
.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:14px;overflow:hidden;margin-bottom:12px;text-decoration:none;color:inherit}
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 .media img{width:100%;height:100%;object-fit:cover;display:block}
.lead-card .body{padding:14px 16px 16px}
@@ -178,7 +197,8 @@ export const NEWS002_STYLE_BLOCK = `<style>
.lead-card p{font-size:14px;color:var(--ink-2)}
/* 图文混排卡 */
.card{display:flex;gap:13px;background:var(--surface);border:1px solid var(--line);
border-radius:12px;padding:13px;margin-bottom:10px}
border-radius:12px;padding:13px;margin-bottom:10px;transition:transform .18s ease,box-shadow .18s ease}
@media (hover:hover){.card:hover,.lead-card:hover{transform:translateY(-2px);box-shadow:0 10px 26px rgba(52,42,31,.1)}}
/* align-self 不能省:flex 默认 stretch 会把缩略图拉成整卡高度,aspect-ratio 失效。 */
.card .media{flex-shrink:0;align-self:flex-start;width:92px;aspect-ratio:4/3;
border-radius:8px;overflow:hidden;background:var(--accent-soft)}
@@ -232,7 +252,8 @@ export const NEWS002_STYLE_BLOCK = `<style>
.h-item.today{border-color:var(--brand);background:var(--brand-soft)}
.h-item.today .hd{color:var(--brand)}
/* 订阅位 */
.subscribe{background:var(--surface);border:1px dashed var(--brand);border-radius:14px;
.subscribe{background:linear-gradient(135deg,var(--brand-soft),var(--gold-soft),var(--accent-soft));
border:1px solid var(--line);border-radius:14px;
padding:20px 18px;text-align:center;margin-bottom:28px}
.subscribe h3{font-family:var(--serif);font-size:17px;margin-bottom:6px}
.subscribe p{font-size:13px;color:var(--ink-2);margin-bottom:12px}
@@ -297,10 +318,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" onerror="this.parentNode.classList.add(\'ph\');this.remove()"></div>`。',
'3. 无图写占位:`<div class="media ph">{栏目 emoji}</div>`,禁止留空 div,禁止为了配图去编造图片地址。',
'3. `.media.ph` 只允许作为图片加载失败时的视觉降级,正式落盘验收不接受无 `<img>` 的占位卡;禁止为了过闸门编造图片地址。',
'3b. 知识卡片的 emoji 用 `<div class="k-icon">`,否则字号会掉到正文大小。',
'4. 头条至少 2 条要有真实配图;配图必须与该条新闻同源,禁止拿甲事件的图配乙事件。',
'4. 头条 3~4 条及所有普通新闻卡必须 100% 有真实配图;配图必须来自对应来源文章的 `og:image` / `twitter:image`,禁止拿甲事件的图配乙事件。',
'5. 图片一律 https,禁止 data URI 长图,禁止整页长图截图。',
'',
'六、视觉(照抄,禁止自创色值)',
@@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* 用当天已生成的真实新闻页制作 news002 视觉样图:
* - 正文与来源仍来自当天真实页
* - 配图只取对应来源文章的 og:image/twitter:image
* - 无真实同源图片的卡片不进入样图
* - 不写生产目录,不调用微信 API
*/
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { NEWS002_STYLE_BLOCK } from '../news-morning-templates.mjs';
import {
auditNews002ImageCoverage,
enrichNews002HtmlWithSourceImages,
extractNews002StoryCards,
} from '../wechat-news-morning-images.mjs';
const DEFAULT_SOURCE =
'https://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/daily-news-0921.html';
const sourceUrl = process.env.NEWS002_SAMPLE_SOURCE_URL || DEFAULT_SOURCE;
const outputPath = path.resolve(
process.argv[2] || path.join(process.cwd(), '.tmp', 'news002-real-design-sample.html'),
);
function cardHasImage(raw) {
return /<div\b[^>]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<img\b/i.test(raw);
}
function stripRanges(source, ranges) {
let output = source;
for (const range of [...ranges].sort((a, b) => b.start - a.start)) {
output = output.slice(0, range.start) + output.slice(range.end);
}
return output;
}
function keepOnlyStrictCards(html, maxStoryCards = 28) {
const cards = extractNews002StoryCards(html).filter((card) => card.title);
const keep = [];
const remove = [];
for (const card of cards) {
if (!cardHasImage(card.raw) || keep.length >= maxStoryCards) {
remove.push(card);
} else {
keep.push(card);
}
}
return { html: stripRanges(html, remove), keptCount: keep.length, removedCount: remove.length };
}
function normalizeHeadlineCards(html) {
let output = String(html).replace(
/<a\b[^>]*class="[^"]*\blead-card\b[^"]*"[^>]*>[\s\S]*?<\/a>\s*(?=<ol class="brief")/i,
'',
);
const candidates = extractNews002StoryCards(output)
.filter((card) => card.title && cardHasImage(card.raw))
.slice(0, 3);
for (const card of [...candidates].sort((a, b) => b.start - a.start)) {
const promoted = card.raw.replace(
/^<div class="card"/i,
'<div class="lead-card"',
);
output = output.slice(0, card.start) + promoted + output.slice(card.end);
}
return output;
}
function applyCanonicalNews002Design(html) {
const withoutStyles = String(html).replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
const withStyle = withoutStyles.replace('</head>', `${NEWS002_STYLE_BLOCK}
<style>
.wrap{max-width:720px;margin:0 auto;padding:0 14px 36px}
.anchors{position:sticky;top:0;z-index:50;display:flex;gap:6px;overflow-x:auto;
padding:9px 10px;margin:0 -14px 18px;background:rgba(250,247,242,.95);
border-top:1px solid var(--line);border-bottom:1px solid var(--line);backdrop-filter:blur(12px)}
.anchors a{flex-shrink:0;padding:5px 10px;border-radius:999px;color:var(--ink-2);
font-size:13px;text-decoration:none}
.wrap>.brief{margin:0 0 28px}
footer{padding:22px 16px 34px;text-align:center;font-size:12px;color:var(--ink-3);
line-height:1.9;border-top:1px solid var(--line)}
.sample-note{max-width:720px;margin:12px auto 0;padding:10px 16px;border-radius:12px;
background:#172F57;color:#fff;font-size:12px;line-height:1.7;text-align:center}
.sample-note b{color:#FFE3A3}
</style>
</head>`);
return withStyle
.replace(/<title>([\s\S]*?)<\/title>/i, '<title>news002 真实新闻视觉样图 · $1</title>')
.replace(
/<body([^>]*)>/i,
'<body$1><div class="sample-note"><b>news002 视觉样图</b> · 正文来自当天真实新闻,图片来自对应文章来源;尚未推送微信。</div>',
);
}
async function main() {
const response = await fetch(sourceUrl, {
signal: AbortSignal.timeout(20_000),
headers: { accept: 'text/html', 'user-agent': 'TKMind-News002-Sample/1.0' },
});
if (!response.ok) throw new Error(`读取真实新闻页失败:HTTP ${response.status}`);
const source = await response.text();
if (!/class="[^"]*\bmasthead\b/i.test(source)) {
throw new Error('来源页不是 news002 masthead 结构,拒绝制作误导样图');
}
const enriched = await enrichNews002HtmlWithSourceImages(source, {
timeoutMs: 8000,
concurrency: 8,
});
const normalized = normalizeHeadlineCards(enriched.html);
const strict = keepOnlyStrictCards(normalized, 28);
const designed = applyCanonicalNews002Design(strict.html);
const audit = auditNews002ImageCoverage(designed);
if (!audit.ok) {
throw new Error(
`视觉样图未通过 news002 闸门:${audit.violations.join(', ')}`
+ `;尝试配图 ${enriched.attemptedCount},解析成功 ${enriched.resolvedCount},`
+ `保留卡片 ${strict.keptCount},头条 ${audit.leadCount}`,
);
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, designed);
console.log(JSON.stringify({
outputPath,
sourceUrl,
attemptedImages: enriched.attemptedCount,
resolvedImages: enriched.resolvedCount,
removedCards: strict.removedCount,
audit,
}, null, 2));
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
+94 -18
View File
@@ -3,13 +3,14 @@
* 用固定样例数据渲染一张 news002 静态预览页,用于人工评审版式/色调/排版。
* 生产页由 Agent 按 NEWS002_TEMPLATE_SPEC 生成,这里只保证 CSS 骨架可视化。
*
* 用法:node scripts/preview-news002-template.mjs [输出路径]
* 用法:
* node scripts/preview-news002-template.mjs [输出路径]
* import { renderNews002PreviewHtml, writeNews002PreviewFile } from './preview-news002-template.mjs';
*/
import fs from 'node:fs';
import path from 'node:path';
import { NEWS002_STYLE_BLOCK } from '../news-morning-templates.mjs';
const OUT = process.argv[2] || path.join(process.cwd(), 'news002-preview.html');
import { getLocalParts, normalizeTimezone } from '../schedule-time.mjs';
/** 预览用占位图:避免评审时依赖外网图片。生产页用的是新闻原文配图。 */
function placeholder(label, from, to) {
@@ -124,19 +125,73 @@ const KNOWLEDGE = [
['🛢️', '布伦特与WTI的差价', '两者分别对应北海与美国内陆原油,价差主要反映运输成本与区域供需,常被用作地缘风险指标。'],
];
const img = (src, alt, emoji) => (src
? `<div class="media"><img src="${src}" alt="${alt}" loading="lazy" referrerpolicy="no-referrer" onerror="this.parentNode.classList.add('ph');this.remove()"></div>`
: `<div class="media ph">${emoji}</div>`);
const WEEKDAY_SHORT = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const source = (name, time) => `<div class="source"><a href="#">${name}</a><span class="dot"></span>${time}</div>`;
function pad2(value) {
return String(value).padStart(2, '0');
}
const section = (id, icon, label, inner, count = '') => `
export function buildNews002PreviewContext({
now = Date.now(),
timezone = normalizeTimezone(),
previewMode = false,
} = {}) {
const parts = getLocalParts(now, timezone);
const dateLabel = `${parts.year}年${parts.month}月${parts.day}日`;
const weekdayLabel = new Intl.DateTimeFormat('zh-CN', {
timeZone: timezone,
weekday: 'long',
}).format(new Date(now));
const mmdd = `${pad2(parts.month)}${pad2(parts.day)}`;
const slug = `daily-news-${mmdd}-news002-preview`;
const headlineCount = BRIEF.length + LEADS.length;
const footerTime = `${parts.year}-${pad2(parts.month)}-${pad2(parts.day)} 05:30 (UTC+8)`;
const historyDays = Array.from({ length: 7 }, (_, index) => {
const dayMs = now - index * 24 * 60 * 60 * 1000;
const dayParts = getLocalParts(dayMs, timezone);
return {
label: `${pad2(dayParts.month)}-${pad2(dayParts.day)}`,
weekday: WEEKDAY_SHORT[new Date(dayMs).getDay()],
today: index === 0,
};
});
return {
now,
timezone,
previewMode,
dateLabel,
weekdayLabel,
mmdd,
slug,
headlineCount,
footerTime,
historyDays,
pageTitle: previewMode
? `每日新闻早报 · news002预览 · ${dateLabel} · TKMind`
: `每日新闻早报 · ${dateLabel} · TKMind`,
draftTitle: `[news002预览] 每日新闻早报 · ${dateLabel}`,
metaDescription: previewMode
? 'news002 版式预览草稿:样例数据,仅供评审版式/色调/排版,非当日 Agent 全量生成。'
: 'news002 版式预览:事件唯一化、每条配图、报头版式、支持深色模式。',
};
}
export function renderNews002PreviewHtml(context = buildNews002PreviewContext()) {
const ctx = context.slug ? context : buildNews002PreviewContext(context);
const img = (src, alt, emoji) => (src
? `<div class="media"><img src="${src}" alt="${alt}" loading="lazy" referrerpolicy="no-referrer" onerror="this.parentNode.classList.add('ph');this.remove()"></div>`
: `<div class="media ph">${emoji}</div>`);
const source = (name, time) => `<div class="source"><a href="#">${name}</a><span class="dot"></span>${time}</div>`;
const section = (id, icon, label, inner, count = '') => `
<div class="section" id="${id}">
<div class="section-title"><span class="icon">${icon}</span><h2>${label}</h2>${count ? `<span class="count">${count}</span>` : ''}</div>
${inner}
</div>`;
const renderCards = (items, emoji) => items.map((c) => `
const renderCards = (items, emoji) => items.map((c) => `
<div class="card" data-event-key="${c.key}">
${img(c.img, c.title, emoji)}
<div class="body">
@@ -148,23 +203,30 @@ const renderCards = (items, emoji) => items.map((c) => `
</div>
</div>`).join('');
const html = `<!DOCTYPE html>
const previewBanner = ctx.previewMode
? `<div class="lede" style="background:#FFF3E8;border-left:4px solid #C8362F;color:#7A1C18;margin-top:0">
这是 <b>news002 模板预览草稿</b>,内容为固定样例数据,用于评审新版式。满意后再决定是否切换正式模板并发布。
</div>`
: '';
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>每日新闻早报 · 2026年9月18日 · TKMind</title>
<meta name="description" content="news002 版式预览:事件唯一化、每条配图、报头版式、支持深色模式。">
<title>${ctx.pageTitle}</title>
<meta name="description" content="${ctx.metaDescription}">
${NEWS002_STYLE_BLOCK}
</head>
<body>
<div class="progress" id="progress"></div>
<div class="masthead">
<div class="kicker">TKMIND DAILY</div>
<div class="kicker">TKMIND DAILY${ctx.previewMode ? ' · PREVIEW' : ''}</div>
<h1>每日新闻早报</h1>
<div class="meta"><b>2026年9月18日</b><span>星期五</span><span>第 218 期</span><span>今日 11 条</span></div>
<div class="meta"><b>${ctx.dateLabel}</b><span>${ctx.weekdayLabel}</span><span>${ctx.previewMode ? 'news002 预览' : `第 ${ctx.headlineCount} 条速读`}</span><span>样例 ${ctx.headlineCount} 条</span></div>
</div>
${previewBanner}
<div class="lede">中美外长再次通话,是今天最值得看的一件事 —— 它决定了后面一周市场对地缘风险的定价。</div>
<nav class="nav"><div class="nav-inner">
@@ -218,14 +280,14 @@ ${section('knowledge', '📚', '知识卡片', `<div class="k-grid">${KNOWLEDGE.
</div>
${section('history', '📅', '往期回顾', `<div class="h-grid">
${['09-18', '09-17', '09-16', '09-15', '09-14', '09-13', '09-12'].map((d, i) => `<a class="h-item${i === 0 ? ' today' : ''}" href="#"><div class="hd">${d}</div><div class="hw">${['今天', '周四', '周三', '周二', '周一', '周日', '周六'][i]}</div></a>`).join('')}
${ctx.historyDays.map((d) => `<a class="h-item${d.today ? ' today' : ''}" href="#"><div class="hd">${d.label}</div><div class="hw">${d.weekday}</div></a>`).join('')}
</div>`)}
</div>
<div class="footer">
来源:新华社 · 人民日报 · 路透社 · Bloomberg · 36氪 · Hacker News<br>
整理时间 2026-09-18 05:30 (UTC+8) · TKMind 智趣
整理时间 ${ctx.footerTime} · TKMind 智趣${ctx.previewMode ? ' · news002 预览草稿' : ''}
</div>
<script>
@@ -241,6 +303,20 @@ ${section('history', '📅', '往期回顾', `<div class="h-grid">
</script>
</body>
</html>`;
}
fs.writeFileSync(OUT, html);
console.log(`news002 preview written to ${OUT}`);
export function writeNews002PreviewFile(outputPath, options = {}) {
const context = buildNews002PreviewContext({ ...options, previewMode: options.previewMode ?? true });
const html = renderNews002PreviewHtml(context);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, html);
return { context, html, outputPath };
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename);
if (isMain) {
const OUT = process.argv[2] || path.join(process.cwd(), 'news002-preview.html');
const { context } = writeNews002PreviewFile(OUT, { previewMode: false });
console.log(`news002 preview written to ${OUT} (${context.slug})`);
}
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* 推送今日 Agent 生成的 news002 页面到微信草稿箱(非样例 preview)。
*/
import process from 'node:process';
import mysql from 'mysql2/promise';
import { loadH5Environment } from './load-env.mjs';
import { loadWechatMpConfig } from '../wechat-mp-config.mjs';
import { createWechatNewsMorningDraftService } from '../wechat-news-morning-draft.mjs';
import { getLocalParts, normalizeTimezone } from '../schedule-time.mjs';
loadH5Environment(import.meta.dirname);
const DEFAULT_TANG_USER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db';
function buildDraftTitle(now = Date.now(), timezone = normalizeTimezone()) {
const parts = getLocalParts(now, timezone);
return `[news002] 每日新闻早报 · ${parts.year}年${parts.month}月${parts.day}日`;
}
async function ensureDraftConfig(service) {
const current = await service.getConfig();
if (current.sourceUserId && current.enabled) return current;
const userId = String(process.env.H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID ?? DEFAULT_TANG_USER_ID).trim();
return service.updateConfig({
enabled: true,
sourceUserId: userId,
autoPushEnabled: current.autoPushEnabled ?? false,
autoGenerateEnabled: current.autoGenerateEnabled ?? false,
}, { updatedBy: 'news002-live-push' });
}
async function main() {
const dryRun = process.argv.includes('--dry-run');
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL missing');
process.exit(1);
}
const mpConfig = loadWechatMpConfig(process.env);
if (!mpConfig.enabled) {
console.error('微信服务号未启用');
process.exit(1);
}
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const h5Root = process.env.H5_ROOT?.trim() || process.cwd();
const service = createWechatNewsMorningDraftService(pool, {
mpConfig,
h5Root,
memindLibRoot: process.env.MEMIND_LIB_ROOT?.trim() || h5Root,
env: process.env,
logger: console,
});
const config = await ensureDraftConfig(service);
const draftTitle = buildDraftTitle();
console.log(JSON.stringify({
dryRun,
sourceUserId: config.sourceUserId,
templateId: config.templateId ?? null,
draftTitle,
h5Root,
}, null, 2));
const preview = await service.pushDraft({
dryRun: true,
requireToday: true,
triggeredBy: 'news002-live',
articleTitleOverride: draftTitle,
});
const htmlPath = preview.preview?.page?.slug
? `${h5Root}/MindSpace/${config.sourceUserId}/public/${preview.preview.page.slug}.html`
: null;
let hasMasthead = false;
let cardCount = 0;
if (htmlPath) {
try {
const fs = await import('node:fs');
const html = fs.readFileSync(htmlPath, 'utf8');
hasMasthead = html.includes('class="masthead"');
cardCount = (html.match(/data-event-key=/g) ?? []).length;
} catch {
// ignore
}
}
console.log('\n[preview]');
console.log(JSON.stringify({
pageSlug: preview.preview?.page?.slug ?? null,
pageUrl: preview.preview?.page?.publicUrl ?? null,
title: preview.preview?.article?.title ?? null,
contentLength: preview.preview?.article?.content?.length ?? 0,
hasMasthead,
eventKeyCount: cardCount,
}, null, 2));
if (!hasMasthead) {
console.error('\n[blocked] 今日页面仍是 news001 或尚未生成 news002(缺少 .masthead)');
await pool.end();
process.exit(2);
}
if (dryRun) {
console.log('\ndry-run:未调用微信 draft/add');
await pool.end();
return;
}
const result = await service.pushDraft({
requireToday: true,
triggeredBy: 'news002-live',
articleTitleOverride: draftTitle,
});
console.log('\n[success]');
console.log(JSON.stringify({
draftMediaId: result.draftMediaId,
pageSlug: result.preview?.page?.slug ?? null,
pageUrl: result.preview?.page?.publicUrl ?? null,
title: result.preview?.article?.title ?? null,
coverSource: result.cover?.source ?? null,
runId: result.run?.id ?? null,
eventKeyCount: cardCount,
}, null, 2));
await pool.end();
}
main().catch((error) => {
console.error('\n[failed]', error instanceof Error ? error.message : error);
if (error?.run) {
console.error(JSON.stringify(error.run, null, 2));
}
process.exit(1);
});
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* 将 news002 样例预览页推送到微信公众号草稿箱(不切换生产模板、不发布 Portal)。
*
* 用法(建议在 143 上执行,微信 IP 白名单已覆盖):
* node scripts/push-news002-preview-draft.mjs
* node scripts/push-news002-preview-draft.mjs --dry-run
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import mysql from 'mysql2/promise';
import { loadH5Environment } from './load-env.mjs';
import { writeNews002PreviewFile } from './preview-news002-template.mjs';
import { loadWechatMpConfig } from '../wechat-mp-config.mjs';
import { createWechatNewsMorningDraftService } from '../wechat-news-morning-draft.mjs';
const PUBLIC_ZONE_DIR = 'public';
loadH5Environment(import.meta.dirname);
const DEFAULT_TANG_USER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db';
async function ensureDraftConfig(service) {
const current = await service.getConfig();
if (current.sourceUserId && current.enabled) return current;
const userId = String(process.env.H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID ?? DEFAULT_TANG_USER_ID).trim();
return service.updateConfig({
enabled: true,
sourceUserId: userId,
autoPushEnabled: current.autoPushEnabled ?? false,
autoGenerateEnabled: current.autoGenerateEnabled ?? false,
}, { updatedBy: 'news002-preview-script' });
}
async function main() {
const dryRun = process.argv.includes('--dry-run');
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL missing');
process.exit(1);
}
const mpConfig = loadWechatMpConfig(process.env);
if (!mpConfig.enabled) {
console.error('微信服务号未启用:请确认 H5_WECHAT_MP_ENABLED=1 且 appId/secret/token/publicBaseUrl 齐全');
process.exit(1);
}
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const h5Root = process.env.H5_ROOT?.trim() || process.cwd();
const service = createWechatNewsMorningDraftService(pool, {
mpConfig,
h5Root,
memindLibRoot: process.env.MEMIND_LIB_ROOT?.trim() || path.dirname(import.meta.dirname),
env: process.env,
logger: console,
});
const config = await ensureDraftConfig(service);
const previewDir = path.join(os.tmpdir(), 'memind-news002-preview');
const previewPath = path.join(previewDir, 'page.html');
const { context } = writeNews002PreviewFile(previewPath, { previewMode: true });
const relativePath = `${PUBLIC_ZONE_DIR}/${context.slug}.html`;
console.log(JSON.stringify({
dryRun,
template: 'news002-preview',
sourceUserId: config.sourceUserId,
productionTemplateId: config.templateId ?? 'news001 (default)',
previewPath,
previewSlug: context.slug,
draftTitle: context.draftTitle,
}, null, 2));
const pageOverride = {
localPath: previewPath,
slug: context.slug,
relativePath,
isTodayPage: true,
};
const preview = await service.pushDraft({
dryRun: true,
requireToday: false,
triggeredBy: 'news002-preview',
pageOverride,
articleTitleOverride: context.draftTitle,
});
console.log('\n[preview]');
console.log(JSON.stringify({
pageSlug: preview.preview?.page?.slug ?? null,
pageUrl: preview.preview?.page?.publicUrl ?? null,
title: preview.preview?.article?.title ?? null,
contentLength: preview.preview?.article?.content?.length ?? 0,
hasMasthead: preview.preview?.article?.content?.includes('masthead') ?? false,
hasLeadCard: preview.preview?.article?.content?.includes('lead-card') ?? false,
}, null, 2));
if (dryRun) {
console.log('\ndry-run:未调用微信 draft/add');
await pool.end();
return;
}
const result = await service.pushDraft({
requireToday: false,
triggeredBy: 'news002-preview',
pageOverride,
articleTitleOverride: context.draftTitle,
});
console.log('\n[success]');
console.log(JSON.stringify({
draftMediaId: result.draftMediaId,
pageSlug: result.preview?.page?.slug ?? null,
pageUrl: result.preview?.page?.publicUrl ?? null,
title: result.preview?.article?.title ?? null,
coverSource: result.cover?.source ?? null,
runId: result.run?.id ?? null,
note: '请在微信草稿箱查看 [news002预览] 标题;内容为样例数据,非 Agent 全量生成。',
}, null, 2));
await pool.end();
}
main().catch((error) => {
console.error('\n[failed]', error instanceof Error ? error.message : error);
if (error?.run) {
console.error(JSON.stringify(error.run, null, 2));
}
process.exit(1);
});
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* 将 news_morning_draft 切到 news002 并触发一次强制重生成(143 worker 拾取)。
*
* 用法:
* node scripts/trigger-news002-generate-draft.mjs
* node scripts/trigger-news002-generate-draft.mjs --apply
* node scripts/trigger-news002-generate-draft.mjs --apply --restore-template
*/
import process from 'node:process';
import mysql from 'mysql2/promise';
import { loadH5Environment } from './load-env.mjs';
loadH5Environment(import.meta.dirname);
const CONFIG_KEY = 'news_morning_draft';
const CONFIG_TABLE = 'h5_wechat_admin_config';
/** Agent 易回退到旧 daily-news 骨架,必须显式禁止 news001 DOM。 */
export const NEWS002_FORCE_TASK_APPEND = [
'【强制 news002 版式 · 覆盖一切旧稿参考】',
'1. 禁止输出 news001 结构:不得出现 class="hero"、class="date-badge"、class="quick-links"、class="highlight-box"、背景 #f7fafc / 主色 #b0191e。',
'2. 必须使用 news002 结构:div.masthead + div.lede + a.lead-card + ol.brief + div.card[data-event-key],并内嵌 taskSpec 里的 NEWS002_STYLE_BLOCK(暖纸 #FAF7F2、主色 #C8362F)。',
'3. 写页前不要 read_file 任何旧 daily-news-*.html 学版式;旧稿正文禁止复用。只允许按本 taskSpec 生成。',
'4. 全页每张故事卡必须带 data-event-key;独立事件 ≤28;flex 栏目无独立事件则整节删除。',
'5. 导航仅保留 news002 8 个主锚点(速读/头条/深读/国内/国际/财经/科技/天气/知识),禁止 25 项 quick-links。',
].join('\n');
async function main() {
const apply = process.argv.includes('--apply');
const restoreTemplate = process.argv.includes('--restore-template');
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL missing');
process.exit(1);
}
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const [rows] = await pool.query(
`SELECT config_json FROM ${CONFIG_TABLE} WHERE config_key = ? LIMIT 1`,
[CONFIG_KEY],
);
const current = rows[0]?.config_json
? (typeof rows[0].config_json === 'string'
? JSON.parse(rows[0].config_json)
: rows[0].config_json)
: {};
const previousTemplateId = current.templateId ?? 'news001';
const next = {
...current,
enabled: current.enabled !== false,
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({
apply,
restoreTemplate,
previousTemplateId,
nextTemplateId: next.templateId,
forceGenerateOnce: next.forceGenerateOnce,
sourceUserId: current.sourceUserId ?? null,
autoGenerateEnabled: current.autoGenerateEnabled ?? null,
autoPushEnabled: current.autoPushEnabled ?? null,
}, null, 2));
if (apply) {
const now = Date.now();
await pool.query(
`INSERT INTO ${CONFIG_TABLE} (config_key, config_json, updated_by, updated_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
config_json = VALUES(config_json),
updated_by = VALUES(updated_by),
updated_at = VALUES(updated_at)`,
[
CONFIG_KEY,
JSON.stringify(next),
restoreTemplate ? 'news002-restore-template' : 'news002-force-generate',
now,
],
);
console.log(restoreTemplate
? 'template restored to news001; forceGenerateOnce cleared'
: 'templateId=news002 + forceGenerateOnce=true persisted');
}
await pool.end();
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
+34 -6
View File
@@ -231,10 +231,13 @@ function renderRankListBody(bodyHtml, { maxRows = 14 } = {}) {
return `<p style="${WX.rankBody}">${lines}</p>`;
}
function renderFeatureCard(cardHtml, { theme, title, body }) {
function renderFeatureCard(cardHtml, { theme, title, body, imageUrl = '' }) {
const tag = extractTagLabel(cardHtml);
return [
`<section style="${WX.feature};background:${theme}">`,
`<section style="${WX.feature};background:${theme};overflow:hidden">`,
imageUrl
? `<img src="${imageUrl}" alt="${stripInlineText(title)}" style="display:block;width:calc(100% + 28px);max-width:none;height:auto;margin:-14px -14px 12px" />`
: '',
tag ? `<p style="${WX.featureTag}">${tag}</p>` : '',
`<p style="${WX.featureTitle}"><strong>${title}</strong></p>`,
body ? `<p style="${WX.featureBody}">${body}</p>` : '',
@@ -242,9 +245,12 @@ function renderFeatureCard(cardHtml, { theme, title, body }) {
].filter(Boolean).join('');
}
function renderListCard({ title, body, index }) {
function renderListCard({ title, body, index, imageUrl = '' }) {
return [
`<section style="${WX.listWrap}">`,
imageUrl
? `<img src="${imageUrl}" alt="${stripInlineText(title)}" style="display:block;width:100%;height:auto;margin:0 0 9px;border-radius:9px" />`
: '',
`<p style="${WX.listTitle}"><span style="color:${BRAND.red}">${formatCardIndex(index)}</span> <strong>${title}</strong></p>`,
body ? `<p style="${WX.listBody}">${body}</p>` : '',
'</section>',
@@ -254,6 +260,7 @@ function renderListCard({ title, body, index }) {
function renderCard(cardHtml, {
maxBodyChars = null,
index = 0,
imageUrlMap = new Map(),
} = {}) {
const title = extractInnerHtml(cardHtml, /<h3[^>]*>([\s\S]*?)<\/h3>/i);
if (!title) return '';
@@ -275,9 +282,19 @@ function renderCard(cardHtml, {
: rawBody;
if (theme) {
return renderFeatureCard(cardHtml, { theme, title, body });
return renderFeatureCard(cardHtml, {
theme,
title,
body,
imageUrl: extractMappedCardImage(cardHtml, imageUrlMap),
});
}
return renderListCard({ title, body, index });
return renderListCard({
title,
body,
index,
imageUrl: extractMappedCardImage(cardHtml, imageUrlMap),
});
}
function renderSectionTitle(titleHtml, sectionId = '') {
@@ -296,6 +313,13 @@ function resolveMappedImageUrl(src, imageUrlMap = new Map()) {
return imageUrlMap.get(ref) ?? imageUrlMap.get(ref.replace(/^\.\//, '')) ?? '';
}
function extractMappedCardImage(cardHtml, imageUrlMap = new Map()) {
const src = String(cardHtml ?? '').match(
/<div\b[^>]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<img\b[^>]*src=["']([^"']+)["']/i,
)?.[1] ?? '';
return resolveMappedImageUrl(src, imageUrlMap);
}
function extractHeroSubtitle(html) {
// news001 用 .subtitle,news002 用 .lede。
const source = String(html);
@@ -594,7 +618,11 @@ function renderGenericSection(
const cardChunk = token.replace(/^(?:<div class="(?:card|highlight-box)|<a class="lead-card)(?:\s|")/i, '');
const cardHtml = `<div class="card ${isLeadCard ? 'lead-card ' : ''}${cardChunk}`;
// 序号只给列表条目,头条卡与榜单卡靠自身样式区分,不占号。
const rendered = renderCard(cardHtml, { maxBodyChars, index: listIndex });
const rendered = renderCard(cardHtml, {
maxBodyChars,
index: listIndex,
imageUrlMap,
});
if (rendered) {
parts.push(rendered);
cardCount += 1;
+20
View File
@@ -181,6 +181,26 @@ test('news002 card with media block still yields title and body', () => {
assert.match(rendered, /国内正文摘要/u, '.media 在前不应挤掉正文 <p>');
});
test('news002 cards render uploaded source images in the wechat draft', () => {
const sourceImage = 'https://news.example/story.jpg';
const wechatImage = 'https://mmbiz.qpic.cn/wechat-story.jpg';
const html = NEWS002_HTML
.replace('<div class="media ph">📰</div>', `<div class="media"><img src="${sourceImage}" alt="头条"></div>`)
.replace('<div class="media ph">🇨🇳</div>', `<div class="media"><img src="${sourceImage}" alt="国内"></div>`);
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 },
);
const domestic = wechatDailyNewsInlineInternals.renderGenericSection(
html.match(/<div class="section" id="domestic">[\s\S]*$/)[0],
{ sectionId: 'domestic', imageUrlMap },
);
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);
});
test('renderHero reads news002 masthead meta and lede', () => {
const rendered = wechatDailyNewsInlineInternals.renderHero(NEWS002_HTML);
assert.match(rendered, /每日新闻早报/u);
+32
View File
@@ -15,6 +15,10 @@ import {
dedupeNewsMorningHtml,
formatNewsMorningDedupeSummary,
} from './wechat-news-morning-dedup.mjs';
import {
auditNews002ImageCoverage,
enrichNews002HtmlWithSourceImages,
} from './wechat-news-morning-images.mjs';
import {
isWechatNewsMorningDraftWorkerEnabled,
wechatNewsMorningDraftExecutionTimeoutMs,
@@ -61,6 +65,33 @@ function applyNewsMorningDedupe(page, logger) {
}
}
async function applyNews002ImageGate(page, config, logger, env = process.env) {
if (config?.templateId !== 'news002') return null;
if (!page?.localPath || !fs.existsSync(page.localPath)) {
throw new Error('news002 配图闸门无法读取生成页面');
}
const source = fs.readFileSync(page.localPath, 'utf8');
const 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);
const audit = auditNews002ImageCoverage(enriched.html, { requireTemplateStructure: true });
logger.log?.('[NewsMorningDraft] news002 image audit', {
slug: page.slug,
attempted: enriched.attemptedCount,
resolved: enriched.resolvedCount,
...audit,
});
if (!audit.ok) {
throw new Error(
`news002 配图/结构闸门未通过:${audit.violations.join(', ')}`
+ `(卡片 ${audit.cardCount},真实配图 ${audit.imageCount},头条配图 ${audit.leadImageCount}/${audit.leadCount})`,
);
}
return audit;
}
function archiveTodayPageForForce(config, h5Root, now, logger) {
const page = resolveTodayPage(config, h5Root, now);
if (!page?.localPath || !fs.existsSync(page.localPath)) return null;
@@ -150,6 +181,7 @@ export function startWechatNewsMorningDraftWorker({
throw new Error('新闻早报页面生成未完成');
}
applyNewsMorningDedupe(page, logger);
await applyNews002ImageGate(page, config, logger, env);
await wechatNewsMorningDraftService.recordAutoGenerationRun({
status: 'success',
pageSlug: page.slug,
+75 -19
View File
@@ -22,6 +22,10 @@ import {
} from './wechat-draft-article-layout.mjs';
import { getLocalParts, localDateKey, startOfLocalDay } from './schedule-time.mjs';
import { countNewsMorningStoryCards } from './wechat-news-morning-dedup.mjs';
import {
auditNews002ImageCoverage,
extractNews002CardImageUrls,
} from './wechat-news-morning-images.mjs';
import {
NEWS001_FRESH_CONTENT_SPEC,
NEWS001_SEO_SPEC,
@@ -831,6 +835,20 @@ export async function buildDailyNewsWechatDraftArticleForPush({
wechatFetch,
memindLibRoot,
});
if (/class="[^"]*\bmasthead\b/i.test(String(html ?? ''))) {
const audit = auditNews002ImageCoverage(html, { requireTemplateStructure: true });
if (!audit.ok) {
throw new Error(`news002 页面未通过推送闸门:${audit.violations.join(', ')}`);
}
const missingUploads = extractNews002CardImageUrls(html).filter((ref) => (
!imageUrlMap.has(ref)
&& !imageUrlMap.has(ref.replace(/^\.\//, ''))
&& !imageUrlMap.has(ref.replace(/^\//, ''))
));
if (missingUploads.length > 0) {
throw new Error(`news002 有 ${missingUploads.length} 张正文配图未成功上传微信,已阻止草稿推送`);
}
}
return buildDailyNewsWechatDraftArticle({
html,
publicUrl,
@@ -1201,22 +1219,48 @@ export function createWechatNewsMorningDraftService(
};
}
async function resolvePreview(configOverride = null, { requireToday = false, now = Date.now() } = {}) {
async function resolvePreview(
configOverride = null,
{ requireToday = false, now = Date.now(), pageOverride = null } = {},
) {
const config = configOverride ?? mergeConfig(await readConfigRow());
if (!config.sourceUserId) {
throw new Error('请先配置新闻早报来源用户 ID');
}
const page = findLatestNewsMorningPage({
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
date: new Date(now),
timezone: config.timezone,
});
if (!page) {
throw new Error(`未找到匹配 ${config.pageSlugPattern} 的新闻早报页面`);
let page;
let pageSummary;
if (pageOverride?.localPath) {
if (!fs.existsSync(pageOverride.localPath)) {
throw new Error(`指定页面不存在:${pageOverride.localPath}`);
}
const slug = String(pageOverride.slug ?? path.basename(pageOverride.localPath, '.html')).trim();
page = {
slug,
relativePath: String(pageOverride.relativePath ?? `${PUBLIC_ZONE_DIR}/${slug}.html`).trim(),
localPath: pageOverride.localPath,
modifiedAt: fs.statSync(pageOverride.localPath).mtimeMs,
thumbPath: pageOverride.thumbPath ?? null,
};
pageSummary = buildPageSummary(config, page, { now });
if (pageOverride.publicUrl) {
pageSummary = { ...pageSummary, publicUrl: String(pageOverride.publicUrl).trim() };
}
if (pageOverride.isTodayPage != null) {
pageSummary = { ...pageSummary, isTodayPage: Boolean(pageOverride.isTodayPage) };
}
} else {
page = findLatestNewsMorningPage({
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
date: new Date(now),
timezone: config.timezone,
});
if (!page) {
throw new Error(`未找到匹配 ${config.pageSlugPattern} 的新闻早报页面`);
}
pageSummary = buildPageSummary(config, page, { now });
}
const pageSummary = buildPageSummary(config, page, { now });
if (requireToday && !pageSummary.isTodayPage) {
throw new Error(`今日(${pageSummary.dateKey})新闻早报尚未生成,当前最新页面为 ${page.slug}`);
}
@@ -1448,13 +1492,15 @@ export function createWechatNewsMorningDraftService(
dryRun = false,
requireToday = true,
now = Date.now(),
pageOverride = null,
articleTitleOverride = null,
} = {}) {
const config = await this.getConfig();
if (!config.enabled) {
throw new Error('新闻早报草稿推送未启用');
}
let preview = null;
preview = await resolvePreview(config, { requireToday, now });
preview = await resolvePreview(config, { requireToday, now, pageOverride });
if (dryRun) {
return {
dryRun: true,
@@ -1463,13 +1509,20 @@ export function createWechatNewsMorningDraftService(
}
try {
const accessToken = await getAccessToken();
const page = findLatestNewsMorningPage({
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
date: new Date(now),
timezone: config.timezone,
});
const page = pageOverride?.localPath
? {
slug: preview.page.slug,
relativePath: preview.page.relativePath,
localPath: pageOverride.localPath,
thumbPath: pageOverride.thumbPath ?? null,
}
: findLatestNewsMorningPage({
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
date: new Date(now),
timezone: config.timezone,
});
const html = fs.readFileSync(page.localPath, 'utf8');
const thumb = await resolveNewsMorningDraftThumbBuffer({
page,
@@ -1490,6 +1543,9 @@ export function createWechatNewsMorningDraftService(
memindLibRoot,
})
: preview.article;
if (articleTitleOverride) {
article.title = String(articleTitleOverride).trim().slice(0, 64);
}
const draft = await addWechatDraftArticle(
accessToken,
{
+367
View File
@@ -0,0 +1,367 @@
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';
function decodeHtmlAttribute(value) {
return String(value ?? '')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'")
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>');
}
function escapeHtmlAttribute(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
const CARD_OPEN_RE = /<(div|a)\b[^>]*\bclass="([^"]*)"[^>]*>/gi;
const CARD_CLASS_TOKENS = new Set(['card', 'lead-card']);
function hasCardClass(classAttr) {
return String(classAttr).split(/\s+/).some((token) => CARD_CLASS_TOKENS.has(token));
}
function sliceBalancedBlock(html, tagName, openStart, openEnd) {
const open = new RegExp(`<${tagName}\\b`, 'gi');
const close = new RegExp(`</${tagName}\\s*>`, 'gi');
let depth = 1;
let cursor = openEnd;
while (depth > 0 && cursor < html.length) {
open.lastIndex = cursor;
close.lastIndex = cursor;
const nextOpen = open.exec(html);
const nextClose = close.exec(html);
if (!nextClose) return { start: openStart, end: html.length };
if (nextOpen && nextOpen.index < nextClose.index) {
depth += 1;
cursor = nextOpen.index + nextOpen[0].length;
} else {
depth -= 1;
cursor = nextClose.index + nextClose[0].length;
}
}
return { start: openStart, end: cursor };
}
function textOf(value) {
return String(value ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function attrOf(tag, name) {
return String(tag).match(new RegExp(`${name}=["']([^"']*)["']`, 'i'))?.[1]?.trim() ?? '';
}
export function extractNews002StoryCards(html) {
const source = String(html ?? '');
const cards = [];
CARD_OPEN_RE.lastIndex = 0;
let match;
while ((match = CARD_OPEN_RE.exec(source)) != null) {
if (!hasCardClass(match[2])) continue;
const tagName = match[1].toLowerCase();
const { start, end } = sliceBalancedBlock(
source,
tagName,
match.index,
match.index + match[0].length,
);
const raw = source.slice(start, end);
const sourceBlock = raw.match(
/<div[^>]*\bclass="[^"]*\bsource\b[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
)?.[1] ?? '';
cards.push({
start,
end,
raw,
tagName,
title: textOf(raw.match(/<h[23]\b[^>]*>([\s\S]*?)<\/h[23]>/i)?.[1] ?? ''),
url: attrOf(match[0], 'href') || sourceBlock.match(/href=["'](https?:\/\/[^"']+)["']/i)?.[1] || '',
eventKey: attrOf(match[0], 'data-event-key').toLowerCase(),
isLead: /\blead-card\b/i.test(match[0]),
});
CARD_OPEN_RE.lastIndex = end;
}
return cards;
}
export function normalizeNewsMorningImageUrl(value, { pageUrl = '' } = {}) {
const raw = decodeHtmlAttribute(value).trim();
if (!raw || /^data:/i.test(raw)) return '';
try {
const url = pageUrl ? new URL(raw, pageUrl) : new URL(raw);
if (url.protocol !== 'https:') return '';
if (['localhost', '127.0.0.1', '::1'].includes(url.hostname)) return '';
return url.toString().slice(0, 1600);
} catch {
return '';
}
}
function readMetaContent(html, key, value) {
const source = String(html ?? '');
const tags = source.match(/<meta\b[^>]*>/gi) ?? [];
for (const tag of tags) {
const keyMatch = tag.match(new RegExp(`\\b${key}\\s*=\\s*["']([^"']+)["']`, 'i'));
if (keyMatch?.[1]?.toLowerCase() !== value.toLowerCase()) continue;
const content = tag.match(/\bcontent\s*=\s*["']([^"']+)["']/i)?.[1] ?? '';
if (content) return content;
}
return '';
}
export function extractNewsMorningSourceImage(html, pageUrl = '') {
const candidates = [
readMetaContent(html, 'property', 'og:image:secure_url'),
readMetaContent(html, 'property', 'og:image'),
readMetaContent(html, 'name', 'twitter:image'),
readMetaContent(html, 'property', 'twitter:image'),
readMetaContent(html, 'itemprop', 'image'),
];
for (const candidate of candidates) {
const normalized = normalizeNewsMorningImageUrl(candidate, { pageUrl });
if (normalized) return normalized;
}
return '';
}
export async function resolveNewsMorningSourceImage(
pageUrl,
{
fetchImpl = fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
userAgent = DEFAULT_USER_AGENT,
} = {},
) {
let normalizedPageUrl = '';
try {
const parsed = new URL(String(pageUrl ?? '').trim());
if (parsed.protocol !== 'https:') return '';
normalizedPageUrl = parsed.toString();
} catch {
return '';
}
try {
const response = await fetchImpl(normalizedPageUrl, {
redirect: 'follow',
signal: AbortSignal.timeout(timeoutMs),
headers: {
accept: 'text/html,application/xhtml+xml',
'user-agent': userAgent,
},
});
if (!response.ok) return '';
const contentType = String(response.headers?.get?.('content-type') ?? '');
if (contentType && !/html|xhtml/i.test(contentType)) return '';
const html = (await response.text()).slice(0, 500_000);
return extractNewsMorningSourceImage(html, response.url || normalizedPageUrl);
} catch {
return '';
}
}
export async function enrichNewsMorningSearchGroupsWithImages(
groups = [],
{
fetchImpl = fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
maxItems = 48,
concurrency = 8,
} = {},
) {
const cloned = (Array.isArray(groups) ? groups : []).map((group) => ({
...group,
results: (Array.isArray(group?.results) ? group.results : []).map((item) => ({ ...item })),
}));
const pending = [];
for (const group of cloned) {
for (const item of group.results) {
if (item.image || !item.url || pending.length >= maxItems) continue;
pending.push(item);
}
}
let cursor = 0;
let resolvedCount = 0;
async function worker() {
while (cursor < pending.length) {
const item = pending[cursor];
cursor += 1;
const image = await resolveNewsMorningSourceImage(item.url, { fetchImpl, timeoutMs });
if (image) {
item.image = image;
item.imageSource = 'article-meta';
resolvedCount += 1;
}
}
}
await Promise.all(
Array.from({ length: Math.min(Math.max(1, concurrency), pending.length || 1) }, () => worker()),
);
return { groups: cloned, attemptedCount: pending.length, resolvedCount };
}
function extractCardImage(cardRaw) {
const media = String(cardRaw ?? '').match(
/<div\b[^>]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<img\b[^>]*src=["']([^"']+)["']/i,
);
return normalizeNewsMorningImageUrl(media?.[1] ?? '');
}
export function extractNews002CardImageUrls(html) {
return extractNews002StoryCards(html)
.filter((card) => card.title)
.map((card) => extractCardImage(card.raw))
.filter(Boolean);
}
function splitCardRaw(card) {
const raw = String(card.raw ?? '');
const openEnd = raw.indexOf('>') + 1;
const closeStart = raw.toLowerCase().lastIndexOf(`</${card.tagName}>`);
if (openEnd <= 0 || closeStart < openEnd) return null;
return {
open: raw.slice(0, openEnd),
inner: raw.slice(openEnd, closeStart),
close: raw.slice(closeStart),
};
}
function removeExistingMedia(inner) {
return String(inner ?? '').replace(
/<div\b[^>]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<\/div>\s*/i,
'',
);
}
function ensureBodyWrapper(inner) {
const source = String(inner ?? '')
.replace(/<h2(\b[^>]*)>/i, '<h3$1>')
.replace(/<\/h2>/i, '</h3>')
.trim();
if (/^<div\b[^>]*class="[^"]*\bbody\b/i.test(source)) return source;
return `<div class="body">${source}</div>`;
}
export async function enrichNews002HtmlWithSourceImages(
html,
{
fetchImpl = fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
concurrency = 8,
} = {},
) {
const source = String(html ?? '');
const cards = extractNews002StoryCards(source);
const unresolved = cards.filter((card) => card.title && !extractCardImage(card.raw) && card.url);
let cursor = 0;
let resolvedCount = 0;
const imageByStart = new Map();
async function worker() {
while (cursor < unresolved.length) {
const card = unresolved[cursor];
cursor += 1;
const image = await resolveNewsMorningSourceImage(card.url, { fetchImpl, timeoutMs });
if (image) {
imageByStart.set(card.start, image);
resolvedCount += 1;
}
}
}
await Promise.all(
Array.from({ length: Math.min(Math.max(1, concurrency), unresolved.length || 1) }, () => worker()),
);
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) || '';
const cleanedInner = ensureBodyWrapper(removeExistingMedia(parts.inner));
const media = image
? `<div class="media"><img src="${escapeHtmlAttribute(image)}" alt="${escapeHtmlAttribute(card.title)}" loading="lazy" referrerpolicy="no-referrer"></div>`
: '<div class="media ph">📰</div>';
const replacement = `${parts.open}${media}${cleanedInner}${parts.close}`;
output = output.slice(0, card.start) + replacement + output.slice(card.end);
}
return {
html: output,
cardCount: cards.filter((card) => card.title).length,
attemptedCount: unresolved.length,
resolvedCount,
};
}
export function auditNews002ImageCoverage(
html,
{ maxStoryCards = 28, requireTemplateStructure = false } = {},
) {
const source = String(html ?? '');
const cards = extractNews002StoryCards(source).filter((card) => card.title);
const missingImages = cards.filter((card) => !extractCardImage(card.raw));
const leads = cards.filter((card) => card.isLead);
const missingLeadImages = leads.filter((card) => !extractCardImage(card.raw));
const violations = [];
if (!/class="[^"]*\bmasthead\b/i.test(source)) violations.push('missing-masthead');
if (cards.length > maxStoryCards) violations.push('too-many-story-cards');
if (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');
}
if (!/class="[^"]*\blede\b/i.test(source)) violations.push('missing-lede');
if (!/class="[^"]*\bbrief\b/i.test(source)) violations.push('missing-brief');
if (!/#C8362F/i.test(source) || !/#FAF7F2/i.test(source) || !/#1B3A6B/i.test(source)) {
violations.push('noncanonical-palette');
}
const requiredSectionIds = [
'brief',
'headlines',
'deepdive',
'domestic',
'world',
'business',
'tech',
'weather',
'knowledge',
'history',
];
const missingSections = requiredSectionIds.filter((id) => (
!new RegExp(`<div\\b[^>]*class=["'][^"']*\\bsection\\b[^"']*["'][^>]*id=["']${id}["']`, 'i')
.test(source)
));
if (missingSections.length > 0) violations.push(`missing-sections:${missingSections.join('|')}`);
if (cards.some((card) => !card.eventKey)) violations.push('missing-event-key');
if (cards.some((card) => !card.url)) violations.push('missing-source-url');
if (cards.some((card) => !/class="[^"]*\bbody\b/i.test(card.raw))) {
violations.push('missing-card-body');
}
if (cards.some((card) => !card.isLead && !/class="[^"]*\bwhy\b/i.test(card.raw))) {
violations.push('missing-card-why');
}
}
return {
ok: violations.length === 0,
violations,
cardCount: cards.length,
imageCount: cards.length - missingImages.length,
leadCount: leads.length,
leadImageCount: leads.length - missingLeadImages.length,
missingTitles: missingImages.map((card) => card.title),
};
}
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
auditNews002ImageCoverage,
enrichNews002HtmlWithSourceImages,
enrichNewsMorningSearchGroupsWithImages,
extractNewsMorningSourceImage,
normalizeNewsMorningImageUrl,
} from './wechat-news-morning-images.mjs';
function htmlResponse(html, url = 'https://news.example/story') {
return {
ok: true,
url,
headers: { get: () => 'text/html; charset=utf-8' },
text: async () => html,
};
}
test('extractNewsMorningSourceImage resolves article-relative og:image', () => {
const html = '<meta property="og:image" content="/media/story.jpg">';
assert.equal(
extractNewsMorningSourceImage(html, 'https://news.example/2026/story'),
'https://news.example/media/story.jpg',
);
assert.equal(normalizeNewsMorningImageUrl('http://news.example/a.jpg'), '');
});
test('enrichNewsMorningSearchGroupsWithImages reads source article metadata', async () => {
const result = await enrichNewsMorningSearchGroupsWithImages([
{
id: 'domestic',
results: [{ title: '真实新闻', url: 'https://news.example/story' }],
},
], {
fetchImpl: async () => htmlResponse(
'<meta property="og:image:secure_url" content="https://cdn.example/story.webp">',
),
});
assert.equal(result.resolvedCount, 1);
assert.equal(result.groups[0].results[0].image, 'https://cdn.example/story.webp');
assert.equal(result.groups[0].results[0].imageSource, 'article-meta');
});
test('enrichNews002HtmlWithSourceImages inserts media and body wrappers', 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">
<span class="tag">国内</span><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 fetchImpl = async (url) => htmlResponse(
`<meta property="og:image" content="https://cdn.example/${new URL(url).pathname.slice(1)}.jpg">`,
url,
);
const result = await enrichNews002HtmlWithSourceImages(source, { fetchImpl });
assert.equal(result.resolvedCount, 4);
assert.match(result.html, /class="media"><img src="https:\/\/cdn\.example\/a\.jpg"/u);
assert.match(result.html, /class="body"><span class="tag">国内<\/span>/u);
assert.deepEqual(auditNews002ImageCoverage(result.html), {
ok: true,
violations: [],
cardCount: 4,
imageCount: 4,
leadCount: 3,
leadImageCount: 3,
missingTitles: [],
});
});
test('auditNews002ImageCoverage blocks missing images and excess cards', () => {
const cards = Array.from({ length: 29 }, (_, index) => (
`<div class="card" data-event-key="k${index}"><h3>事件标题${index}</h3></div>`
)).join('');
const audit = auditNews002ImageCoverage(
`<div class="masthead"></div>${cards}`,
);
assert.equal(audit.ok, false);
assert.ok(audit.violations.includes('too-many-story-cards'));
assert.ok(audit.violations.includes('story-image-missing'));
});
test('strict news002 audit rejects legacy or incomplete template structure', () => {
const audit = auditNews002ImageCoverage(
'<div class="hero"></div><div class="masthead"></div>',
{ requireTemplateStructure: true },
);
assert.equal(audit.ok, false);
assert.ok(audit.violations.includes('legacy-news001-dom'));
assert.ok(audit.violations.includes('missing-lede'));
assert.ok(audit.violations.some((item) => item.startsWith('missing-sections:')));
assert.ok(audit.violations.includes('noncanonical-palette'));
});
+12 -1
View File
@@ -1,6 +1,7 @@
import { searchSearxng } from './mindsearch-providers.mjs';
import { resolvePortalSearxngEndpoint } from './mindsearch-prefetch.mjs';
import { getLocalParts } from './schedule-time.mjs';
import { enrichNewsMorningSearchGroupsWithImages } from './wechat-news-morning-images.mjs';
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
@@ -227,6 +228,7 @@ export async function prefetchNewsMorningSearxngBrief({
timezone = 'Asia/Shanghai',
env = process.env,
searchImpl = searchSearxng,
fetchImpl = fetch,
limit = 5,
} = {}) {
const dateLabel = formatLocalDateParts(now, timezone);
@@ -257,5 +259,14 @@ export async function prefetchNewsMorningSearxngBrief({
return { ...item, results };
}));
const { groups: dedupedGroups, removedCount } = dedupeNewsMorningSearchGroups(groups);
return formatNewsMorningSearchBrief(dedupedGroups, { dateLabel, dedupeRemovedCount: removedCount });
const imageEnrichment = await enrichNewsMorningSearchGroupsWithImages(dedupedGroups, {
fetchImpl,
timeoutMs: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_TIMEOUT_MS ?? 5000) || 5000,
maxItems: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_MAX_ITEMS ?? 48) || 48,
concurrency: Number(env.MEMIND_NEWS_MORNING_IMAGE_META_CONCURRENCY ?? 8) || 8,
});
return formatNewsMorningSearchBrief(imageEnrichment.groups, {
dateLabel,
dedupeRemovedCount: removedCount,
});
}
+7
View File
@@ -93,6 +93,12 @@ test('prefetchNewsMorningSearxngBrief queries news category then falls back', as
}
return [{ title: `[web-${slug}] 通用源`, url: `https://web.example/${slug}`, snippet: '补搜' }];
},
fetchImpl: async (url) => ({
ok: true,
url,
headers: { get: () => 'text/html' },
text: async () => '<meta property="og:image" content="https://cdn.example/news.jpg">',
}),
});
assert.ok(calls.some((item) => item.categories === 'news'));
assert.ok(calls.some((item) => item.categories === '' && /国内/.test(item.query)));
@@ -100,6 +106,7 @@ test('prefetchNewsMorningSearxngBrief queries news category then falls back', as
assert.match(brief, /## 国内[\s\S]*通用源/);
assert.match(brief, /全页去重/);
assert.match(brief, /tkmind_search/);
assert.match(brief, /图:https:\/\/cdn\.example\/news\.jpg/u);
});
test('prefetchNewsMorningSearxngBrief skips live calls without endpoint', async () => {