Prefer news images for WeChat draft cover and tighten news002 subscribe/history blocks.

Cover upload now tries lead-card and card images before image_make; template spec requires bind-then-reply-2 subscribe flow and inline on-this-day history facts instead of external link grids.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-24 10:51:59 +08:00
parent 3cfd807db6
commit d4ad546fe2
4 changed files with 219 additions and 8 deletions
+86
View File
@@ -0,0 +1,86 @@
/**
* news002 早报页固定区块:订阅引导、历史上的今天、封面图候选。
* 纯函数,供模板 spec 与 draft 推送共用。
*/
export function resolveNewsMorningSubscribeBindUrl(env = process.env) {
const explicit = String(env.H5_WECHAT_NEWS_MORNING_SUBSCRIBE_BIND_URL ?? '').trim();
if (explicit) return explicit;
const bindPath = String(env.H5_WECHAT_MP_BIND_PATH ?? '/bind').trim() || '/bind';
if (/^https?:\/\//i.test(bindPath)) return bindPath;
const base = String(
env.H5_WECHAT_MP_PUBLIC_BASE_URL
?? env.H5_PUBLIC_BASE_URL
?? 'https://m.tkmind.cn',
).trim().replace(/\/+$/, '');
return `${base}${bindPath.startsWith('/') ? bindPath : `/${bindPath}`}`;
}
export function resolveNewsMorningPushSubscriptionId(env = process.env) {
const raw = String(env.H5_WECHAT_NEWS_MORNING_PUSH_SUBSCRIPTION_ID ?? '2').trim();
return /^\d+$/.test(raw) ? raw : '2';
}
export function buildNewsMorningSubscribeBlockSpec(env = process.env) {
const bindUrl = resolveNewsMorningSubscribeBindUrl(env);
const subId = resolveNewsMorningPushSubscriptionId(env);
return [
'`.subscribe` 订阅引导(必须放在 history 之前):',
`- 按钮:\`<a href="${bindUrl}" target="_blank" rel="noopener">立即订阅</a>\`(禁止 href="#masthead" 或 # 空链)`,
`- 副文案两行:① 点此完成微信账号绑定;② 绑定后回到本服务号,回复 **${subId}** 开通「每日新闻早报」推送(默认 07:30,可说「${subId}改到7点」调整)`,
'- 可加小字:未绑定时回复数字不会生效,须先完成绑定。',
].join('\n');
}
export function buildNewsMorningHistorySectionSpec() {
return [
'id=history 栏目「历史上的今天」(禁止做成外链小卡片或往期早报链接):',
'- 结构:`<ol class="history-list">` + 6~7 条 `<li class="history-item">`',
'- 每条:`<span class="hy">{四位年份}</span><span class="he">{与当天月日相关的可核实中外历史事件,20~45 字}</span>`',
'- 须覆盖不同年代、中外各至少 2 条;禁止 `.h-grid` / `.h-item` / Wikipedia / checkiday 等外链占位',
'- 禁止只写「历史/节日/足球」等标签词而不写具体事件',
].join('\n');
}
export function collectNewsMorningCoverImageCandidates(html) {
const source = String(html ?? '');
const candidates = [];
const seen = new Set();
const push = (url) => {
const normalized = String(url ?? '')
.trim()
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"');
if (!normalized || /^data:/i.test(normalized) || seen.has(normalized)) return;
seen.add(normalized);
candidates.push(normalized);
};
for (const match of source.matchAll(
/<a\b[^>]*\blead-card\b[^>]*>[\s\S]*?<img\b[^>]*\bsrc=["']([^"']+)["']/gi,
)) {
push(match[1]);
}
const headlinesBlock = source.match(
/<div class="section" id="headlines"[\s\S]*?(?=<div class="section" id="|<div class="subscribe"|<div class="footer"|<\/div>\s*<\/div>\s*<div class="footer")/i,
)?.[0] ?? '';
if (headlinesBlock) {
for (const match of headlinesBlock.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)["']/gi)) {
push(match[1]);
}
}
for (const match of source.matchAll(
/<div class="card"[\s\S]*?<img\b[^>]*\bsrc=["']([^"']+)["']/gi,
)) {
push(match[1]);
}
return candidates;
}
export function shouldPreferNewsMorningCoverImage(env = process.env) {
const raw = String(env.MEMIND_NEWS_MORNING_COVER_PREFER_NEWS_IMAGE ?? '1').trim().toLowerCase();
return !['0', 'false', 'no', 'off'].includes(raw);
}
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildNewsMorningHistorySectionSpec,
buildNewsMorningSubscribeBlockSpec,
collectNewsMorningCoverImageCandidates,
resolveNewsMorningSubscribeBindUrl,
} from './news-morning-page-blocks.mjs';
import { buildNewsMorningTemplateSpec } from './news-morning-templates.mjs';
test('resolveNewsMorningSubscribeBindUrl defaults to m.tkmind.cn/bind', () => {
assert.equal(
resolveNewsMorningSubscribeBindUrl({}),
'https://m.tkmind.cn/bind',
);
});
test('buildNewsMorningSubscribeBlockSpec mentions bind url and reply 2', () => {
const spec = buildNewsMorningSubscribeBlockSpec({});
assert.match(spec, /https:\/\/m\.tkmind\.cn\/bind/u);
assert.match(spec, /回复 \*\*2\*\*/u);
assert.match(spec, /禁止 href="#masthead"/u);
});
test('collectNewsMorningCoverImageCandidates prefers lead-card images', () => {
const html = `<div class="section" id="headlines">
<a class="lead-card" href="https://news.example/a">
<div class="media"><img src="https://cdn.example/lead.jpg" alt=""></div>
</a>
<div class="card"><div class="media"><img src="https://cdn.example/card.jpg"></div></div>
</div>`;
const candidates = collectNewsMorningCoverImageCandidates(html);
assert.deepEqual(candidates, [
'https://cdn.example/lead.jpg',
'https://cdn.example/card.jpg',
]);
});
test('buildNewsMorningTemplateSpec includes subscribe and history rules for news002', () => {
const spec = buildNewsMorningTemplateSpec('news002');
assert.match(spec, /history-list/u);
assert.match(spec, /立即订阅/u);
assert.ok(spec.includes('历史上的今天'));
assert.ok(spec.includes('history-item'));
});
+22 -3
View File
@@ -4,9 +4,14 @@
* news001:2026-09-18 定稿版式(固定 23 栏目 + 定额卡片 + 无配图),已冻结,只做兼容保留。
* news002:订阅向改版(事件唯一化 + 每条配图 + 报头版式 + 深色模式)。
*
* 本文件不得 import 其它业务模块,避免与 wechat-news-morning-draft.mjs 形成循环依赖。
* 本文件仅允许 import news-morning-page-blocks.mjs(纯函数),避免与 wechat-news-morning-draft 循环依赖。
*/
import {
buildNewsMorningHistorySectionSpec,
buildNewsMorningSubscribeBlockSpec,
} from './news-morning-page-blocks.mjs';
export const NEWS_MORNING_TEMPLATE_REFERENCE_URL =
'https://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/daily-news-0918.html';
@@ -103,7 +108,7 @@ export const NEWS002_SECTIONS = [
{ id: 'culture', label: '文娱·体育', icon: '🎬', tier: 'flex', min: 1, max: 3 },
{ id: 'voices', label: '观点·热议', icon: '💬', tier: 'flex', min: 1, max: 3 },
{ id: 'knowledge', label: '知识卡片', icon: '📚', tier: 'core', min: 1, max: 2 },
{ id: 'history', label: '往期回顾', icon: '📅', tier: 'core', min: 7, max: 7 },
{ id: 'history', label: '历史上的今天', icon: '📅', tier: 'core', min: 6, max: 7 },
];
export const NEWS002_CORE_SECTION_IDS = NEWS002_SECTIONS
@@ -217,6 +222,8 @@ export function countNews002SectionItems(html, sectionId) {
return (block.match(/class="[^"]*\bk-item\b/gi) ?? []).length;
}
if (sectionId === 'history') {
const historyItems = block.match(/class="[^"]*\bhistory-item\b/gi) ?? [];
if (historyItems.length) return historyItems.length;
return (block.match(/class="[^"]*\bh-item\b/gi) ?? []).length;
}
return countNews002StoryCardsInBlock(block);
@@ -438,6 +445,11 @@ export const NEWS002_STYLE_BLOCK = `<style>
.h-item .hw{font-size:10px;color:var(--ink-3)}
.h-item.today{border-color:var(--brand);background:var(--brand-soft)}
.h-item.today .hd{color:var(--brand)}
.history-list{list-style:none;padding:0;margin:0}
.history-item{display:flex;gap:10px;padding:10px 0;border-bottom:1px solid var(--line)}
.history-item:last-child{border-bottom:none}
.history-item .hy{flex-shrink:0;font-weight:700;color:var(--brand);min-width:58px;font-size:13px}
.history-item .he{font-size:13.5px;color:var(--ink-2);line-height:1.65}
/* 订阅位 */
.subscribe{background:linear-gradient(135deg,var(--brand-soft),var(--gold-soft),var(--accent-soft));
border:1px solid var(--line);border-radius:14px;
@@ -490,7 +502,7 @@ export const NEWS002_TEMPLATE_SPEC = [
'3. `<div class="lede">`:一句主编导语,25~45 字,点出当天最值得关心的一件事,不要罗列。',
'4. `<nav class="nav"><div class="nav-inner">`:只放 8 个锚点(速读/头条/深读/国内/国际/财经/科技/天气),flex 栏目不进导航。',
'5. `<div class="container">` 内按栏目顺序输出 section。',
'6. 在 history 之前插入 `.subscribe` 订阅引导块(标题 + 一句话价值 + 按钮)。',
'6. 在 history 之前插入 `.subscribe` 订阅引导块(见下方订阅规范)。',
'7. `<div class="footer">`:来源域名列表 + 整理时间 + TKMind 品牌。',
'',
'四、卡片写法',
@@ -597,11 +609,18 @@ export function buildNewsMorningTemplateSpec(templateId, env = process.env) {
const template = getNewsMorningTemplate(templateId, env);
const requireFill = template.id === 'news002'
&& envFlag(env.MEMIND_NEWS_MORNING_NEWS002_REQUIRE_FILL, true);
const subscribeHistorySpec = template.id === 'news002'
? [
buildNewsMorningSubscribeBlockSpec(env),
buildNewsMorningHistorySectionSpec(),
].join('\n\n')
: '';
return [
`版式模板:${template.id}(${template.version})`,
template.freshContentSpec,
template.seoSpec,
template.templateSpec,
subscribeHistorySpec,
requireFill ? NEWS002_FILL_REQUIREMENT_SPEC : '',
template.referenceUrl ? `版式参照(只读结构):${template.referenceUrl}` : '',
].filter(Boolean).join('\n');
+66 -5
View File
@@ -46,6 +46,10 @@ import {
NEWS002_DEFAULT_TASK_SPEC_APPEND,
resolveNewsMorningTemplateId,
} from './news-morning-templates.mjs';
import {
collectNewsMorningCoverImageCandidates,
shouldPreferNewsMorningCoverImage,
} from './news-morning-page-blocks.mjs';
const CONFIG_TABLE = 'h5_wechat_admin_config';
const CONFIG_KEY = 'news_morning_draft';
@@ -347,6 +351,37 @@ function resolveGeneratedAssetPath(h5Root, userId, workspaceRelativePath) {
return path.join(h5Root, PUBLISH_ROOT_DIR, userId, workspaceRelativePath);
}
async function loadNewsMorningCoverImageBuffer(
candidate,
{ publishDir = '', memindLibRoot = process.cwd(), fetchImpl = undiciFetch } = {},
) {
const resolved = resolveHtmlBodyImagePath(candidate, { publishDir, memindLibRoot });
if (!resolved) return null;
let buffer;
try {
if (/^https?:\/\//i.test(resolved)) {
const response = await fetchImpl(resolved, {
headers: { accept: 'image/*,*/*' },
});
if (!response.ok) return null;
buffer = Buffer.from(await response.arrayBuffer());
} else {
buffer = fs.readFileSync(resolved);
}
if (/\.svg$/i.test(candidate) || /\.svg$/i.test(resolved)) {
buffer = await sharp(buffer).png().toBuffer();
}
return await sharp(buffer, { sequentialRead: true })
.rotate()
.resize(900, 900, { fit: 'cover' })
.png()
.toBuffer();
} catch {
return null;
}
}
async function resolveNewsMorningDraftThumbBuffer({
page,
html,
@@ -355,11 +390,39 @@ async function resolveNewsMorningDraftThumbBuffer({
imageGenerationService,
logger = console,
now = Date.now(),
}) {
env = process.env,
fetchImpl = undiciFetch,
publishDir = page?.localPath ? path.dirname(page.localPath) : '',
memindLibRoot = h5Root,
} = {}) {
const thumbPath = page?.localPath
? `${page.localPath.replace(/\.html$/i, '')}.thumbnail.png`
: null;
if (shouldPreferNewsMorningCoverImage(env)) {
for (const candidate of collectNewsMorningCoverImageCandidates(html)) {
const buffer = await loadNewsMorningCoverImageBuffer(candidate, {
publishDir,
memindLibRoot,
fetchImpl,
});
if (!buffer) continue;
if (thumbPath) {
fs.mkdirSync(path.dirname(thumbPath), { recursive: true });
fs.writeFileSync(thumbPath, buffer);
}
logger.log?.('[NewsMorningDraft] cover from news source image', {
slug: page?.slug ?? null,
image: candidate.slice(0, 120),
});
return { buffer, source: 'news-image', imageUrl: candidate };
}
}
if (page?.thumbPath && fs.existsSync(page.thumbPath)) {
return { buffer: fs.readFileSync(page.thumbPath), source: 'sidecar' };
}
if (imageGenerationService?.generate && config.sourceUserId && page?.slug) {
try {
const prompt = buildNewsMorningCoverPrompt(html, { now, timezone: config.timezone });
@@ -404,10 +467,6 @@ async function resolveNewsMorningDraftThumbBuffer({
}
}
if (page?.thumbPath && fs.existsSync(page.thumbPath)) {
return { buffer: fs.readFileSync(page.thumbPath), source: 'sidecar' };
}
const fallback = await sharp(
Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="900" height="900"><rect width="900" height="900" fill="#1a1a2e"/><text x="50%" y="50%" fill="#fff" font-size="48" text-anchor="middle" dominant-baseline="middle">今日新闻</text></svg>',
@@ -1588,6 +1647,8 @@ export function createWechatNewsMorningDraftService(
imageGenerationService,
logger,
now,
publishDir: path.dirname(page.localPath),
memindLibRoot: h5Root,
});
const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumb.buffer, { wechatFetch });
const article = isDailyNewsFormat(html)