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:
@@ -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(/&/gi, '&')
|
||||
.replace(/"/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);
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user