feat: enforce real imagery for news002
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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})`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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(/&/gi, '&')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'|'/gi, "'")
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>');
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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(/ /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),
|
||||
};
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user