Files
memind/wechat-news-morning-draft.mjs
T
john d4ad546fe2 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>
2026-09-24 10:51:59 +08:00

1818 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import path from 'node:path';
import { fetch as undiciFetch } from 'undici';
import sharp from 'sharp';
import {
buildPublicUrl,
PUBLISH_ROOT_DIR,
PUBLIC_ZONE_DIR,
resolvePublicBaseUrl,
} from './user-publish.mjs';
import { extractMindspaceCoverPath } from './wechat/verify/generated-thumbnail.mjs';
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
import {
convertDailyNewsHtmlToWechatInlineArticle,
renderCreatePortalCTA,
trimWechatInlineBodyByRemovingCards,
} from './wechat-daily-news-inline.mjs';
import {
applyWechatDraftArticleLayout,
clipWechatDraftBodyToFitFooter,
uploadWechatDraftBrandAssets,
} from './wechat-draft-article-layout.mjs';
import { getLocalParts, localDateKey, startOfLocalDay } from './schedule-time.mjs';
import { countNewsMorningStoryCards } from './wechat-news-morning-dedup.mjs';
import { maskBlockedNewsTerms } from './news-content-safety-rules.mjs';
import {
NEWS_MORNING_AI_DISCLOSURE_TEXT,
NEWS_MORNING_CONTENT_SAFETY_TASK_SPEC,
sanitizeNewsMorningHtmlSync,
} from './wechat-news-morning-content-safety.mjs';
import {
auditNews002ImageCoverage,
extractNews002CardImageUrls,
} from './wechat-news-morning-images.mjs';
import {
NEWS001_FRESH_CONTENT_SPEC,
NEWS001_SEO_SPEC,
NEWS001_TEMPLATE_SPEC,
NEWS001_VERSION,
NEWS002_VERSION,
NEWS_MORNING_TEMPLATE_IDS,
NEWS_MORNING_TEMPLATE_REFERENCE_URL as TEMPLATE_REFERENCE_URL,
auditNews002SectionFill,
buildNewsMorningTemplateSpec,
getNewsMorningTemplate,
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';
const RUNS_TABLE = 'h5_wechat_news_draft_runs';
const DEFAULT_WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
const DEFAULT_DRAFT_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/draft/add';
const DEFAULT_MATERIAL_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/material/add_material';
const DEFAULT_UPLOAD_IMG_URL = 'https://api.weixin.qq.com/cgi-bin/media/uploadimg';
const MAX_THUMB_BYTES = 64 * 1024;
// 版式规范已迁移到 news-morning-templates.mjs(news001 冻结 / news002 订阅版)。
// 以下导出仅为兼容既有引用,新代码请用 getNewsMorningTemplate() / buildNewsMorningTemplateSpec()。
export const NEWS_MORNING_TEMPLATE_REFERENCE_URL = TEMPLATE_REFERENCE_URL;
export const NEWS_MORNING_TEMPLATE_VERSION = NEWS002_VERSION;
export const NEWS_MORNING_SEO_SPEC = NEWS001_SEO_SPEC;
export const NEWS_MORNING_TEMPLATE_0915_SPEC = NEWS001_TEMPLATE_SPEC;
export const NEWS_MORNING_TEMPLATE_0910_SPEC = NEWS001_TEMPLATE_SPEC;
export const NEWS_MORNING_FRESH_CONTENT_SPEC = NEWS001_FRESH_CONTENT_SPEC;
function normalizeBoolean(value, fallback = false) {
if (value == null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function parseConfigJson(value) {
if (!value) return {};
if (typeof value === 'object') return value;
try {
return JSON.parse(String(value));
} catch {
return {};
}
}
function clampHour(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.min(23, Math.max(0, Math.floor(num)));
}
function clampMinute(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.min(59, Math.max(0, Math.floor(num)));
}
function clampGenerateLeadMinutes(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.min(24 * 60, Math.max(15, Math.floor(num)));
}
function formatLocalDateParts(date = new Date(), timezone = 'Asia/Shanghai') {
const parts = getLocalParts(date, timezone);
const year = String(parts.year);
const month = String(parts.month).padStart(2, '0');
const day = String(parts.day).padStart(2, '0');
return {
year,
month,
day,
iso: `${year}-${month}-${day}`,
mmdd: `${month}${day}`,
compact: `${year}${month}${day}`,
};
}
/** 检测「检索失败 / 空内容」降级页,需强制重生成。 */
export function isNewsMorningContentDegraded(html, { minStoryCards = 12 } = {}) {
const source = String(html ?? '');
if (!source.trim()) return true;
if (/关于今日早报的重要说明|未填充任何当日具体新闻条目|未能获取当日可核验新闻/u.test(source)) {
return true;
}
if (/class="[^"]*\bmasthead\b/i.test(source)) {
return !auditNews002SectionFill(source).ok;
}
// 必须按 class token 统计:news002 的头条卡是 a.lead-card,
// 旧写法 `class="card(?:\s|")` 匹配不到,会把正常页误判成降级页。
return countNewsMorningStoryCards(source) < minStoryCards;
}
export function isNewsMorningPageDegraded(page, { minStoryCards = 6 } = {}) {
if (!page?.localPath || !fs.existsSync(page.localPath)) return false;
try {
return isNewsMorningContentDegraded(
fs.readFileSync(page.localPath, 'utf8'),
{ minStoryCards },
);
} catch {
return false;
}
}
export function isNewsMorningPageForToday(
page,
{ date = new Date(), timezone = 'Asia/Shanghai' } = {},
) {
if (!page?.slug) return false;
const { iso, mmdd, compact } = formatLocalDateParts(date, timezone);
const slug = String(page.slug);
return (
slug.endsWith(`-${mmdd}`)
|| slug.includes(iso)
|| slug.includes(compact)
|| slug.includes(iso.replace(/-/g, ''))
);
}
export function isLocalScheduleDue(
{ hour, minute, timezone = 'Asia/Shanghai' },
now = Date.now(),
toleranceMinutes = 1,
) {
const parts = getLocalParts(now, timezone);
const current = parts.hour * 60 + parts.minute;
const target = clampHour(hour, 0) * 60 + clampMinute(minute, 0);
return Math.abs(current - target) <= Math.max(0, toleranceMinutes);
}
export function isWithinNewsMorningGenerateLeadWindow(
{
pushHour,
pushMinute,
generateLeadMinutes = 60,
timezone = 'Asia/Shanghai',
},
now = Date.now(),
) {
const parts = getLocalParts(now, timezone);
const current = parts.hour * 60 + parts.minute;
const push = clampHour(pushHour, 6) * 60 + clampMinute(pushMinute, 0);
const start = push - clampGenerateLeadMinutes(generateLeadMinutes, 60);
return current >= start && current < push;
}
function extractMindspaceCoverMeta(html) {
const raw = String(html ?? '');
const match = raw.match(/<meta[^>]+name=["']mindspace-cover["'][^>]+content=["']([^"']+)["']/i)
?? raw.match(/<meta[^>]+name=["']mindspace-cover["'][^>]+content=(\{[^>]+\})/i);
if (!match?.[1]) return null;
try {
const unfenced = match[1]
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
const parsed = JSON.parse(unfenced);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
function extractHeroSubtitle(html) {
return stripHtml(
String(html).match(/<div class="subtitle"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '',
);
}
export const NEWS_MORNING_COVER_BRAND = Object.freeze({
crimson: '#b0191e',
navy: '#123a6b',
});
/** 每日轮换的封面视觉概念;同一自然日固定同一套,避免重复推草稿时风格漂移。 */
export const NEWS_MORNING_COVER_CONCEPTS = Object.freeze([
{
id: 'split-skyline',
buildScene: () => [
'Cinematic aerial dawn over a modern Asian megacity skyline.',
'Split lighting: warm golden sunrise on one half and cool deep blue shadow on the other, symbolizing contrasting global headlines.',
'Atmospheric haze, subtle lens flare, crimson (#b0191e) and navy (#123a6b) color grading, fine art photography.',
].join(' '),
},
{
id: 'data-nebula',
buildScene: () => [
'Abstract 3D editorial art: luminous particle streams and wireframe globe floating in dark space.',
'Crimson and navy gradient nebula, geometric light trails suggesting global news flow.',
'Premium magazine cover aesthetic, cinematic depth of field, glossy render.',
].join(' '),
},
{
id: 'theme-mosaic',
buildScene: ({ headlineHints }) => [
'Bold editorial mosaic collage for a morning news digest: stylized vignettes in one square frame',
'(financial towers with abstract red-green light bars, ancient artifact silhouette, international color blocks, festival tent silhouette)',
'unified by diagonal crimson-to-navy gradient overlay, modern graphic design, high contrast.',
headlineHints ? `Mood cues: ${headlineHints}.` : null,
].filter(Boolean).join(' '),
},
{
id: 'brand-editorial',
buildScene: () => [
'Flat modern editorial vector cover: diagonal gradient from crimson (#b0191e) to navy (#123a6b),',
'soft sunrise glow, abstract globe wireframe, notification dots, subtle chart-line motifs, centered negative space.',
].join(' '),
},
{
id: 'morning-window',
buildScene: () => [
'Warm morning sunlight streaming through a window onto a minimal interior wall.',
'Soft bokeh garden outside, abstract crimson and navy color blocks on the wall suggesting news tiles,',
'cozy dawn atmosphere, lifestyle photography, shallow depth of field.',
].join(' '),
},
{
id: 'satellite-terminator',
buildScene: () => [
'Earth viewed from space at the day-night terminator line, city lights glowing on the dark side,',
'sunrise rim light on clouds, deep navy space background with crimson atmospheric glow,',
'NASA-style cinematic realism, global perspective.',
].join(' '),
},
{
id: 'geometric-pulse',
buildScene: () => [
'Minimal geometric editorial cover: layered crimson and navy arcs, pulse waves and dot grids,',
'clean Swiss design, subtle grain texture, strong diagonal composition, premium news brand feel.',
].join(' '),
},
]);
function buildNewsMorningCoverContext(html, { now = Date.now(), timezone = 'Asia/Shanghai' } = {}) {
const dateParts = formatLocalDateParts(now, timezone);
const dateBadge = stripHtml(
String(html).match(/<div class="date-badge"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '',
);
const coverMeta = extractMindspaceCoverMeta(html);
const subtitle = String(coverMeta?.subtitle ?? extractHeroSubtitle(html) ?? '').trim();
const headlineHints = subtitle
.split(/[·•]/)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 3)
.join(', ');
const dateLabel = dateBadge || `${dateParts.year}-${dateParts.month}-${dateParts.day}`;
return { dateParts, dateLabel, headlineHints };
}
function hashNewsMorningCoverDay(isoDate) {
let hash = 0;
for (const ch of String(isoDate)) {
hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
}
return hash;
}
export function resolveNewsMorningCoverConcept({ now = Date.now(), timezone = 'Asia/Shanghai' } = {}) {
const { dateParts } = buildNewsMorningCoverContext('', { now, timezone });
const index = hashNewsMorningCoverDay(dateParts.iso) % NEWS_MORNING_COVER_CONCEPTS.length;
const concept = NEWS_MORNING_COVER_CONCEPTS[index];
return { id: concept.id, index, isoDate: dateParts.iso };
}
export function buildNewsMorningCoverPrompt(html, { now = Date.now(), timezone = 'Asia/Shanghai' } = {}) {
const context = buildNewsMorningCoverContext(html, { now, timezone });
const conceptMeta = resolveNewsMorningCoverConcept({ now, timezone });
const concept = NEWS_MORNING_COVER_CONCEPTS[conceptMeta.index];
return [
'Square WeChat article cover thumbnail for the Chinese morning news digest brand "Daily News Morning Brief".',
`Edition date mood: ${context.dateLabel}.`,
`Visual concept (${concept.id}): ${concept.buildScene(context)}`,
context.headlineHints
? `Only subtle abstract mood hints, do not illustrate news stories literally: ${context.headlineHints}.`
: null,
'Must look like a professional daily news briefing product cover, not calligraphy art, not fantasy illustration.',
'No text, no letters, no numbers, no Chinese characters, no calligraphy, no seals, no logos, no watermark, no human faces.',
'No smartphone, no mobile phone, no newspaper, no coffee cup, no office desk still life.',
].filter(Boolean).join(' ');
}
export function buildNewsMorningCoverNegativePrompt() {
return [
'smartphone',
'mobile phone',
'newspaper',
'coffee cup',
'desk still life',
'calligraphy',
'Chinese characters',
'seal stamp',
'anime',
'cartoon child',
'fantasy',
'maple leaves',
'text',
'letters',
'watermark',
'logo',
].join(', ');
}
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,
config,
h5Root,
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 });
const negativePrompt = buildNewsMorningCoverNegativePrompt();
const result = await imageGenerationService.generate({
userId: config.sourceUserId,
purpose: 'inline_image',
prompt,
negativePrompt,
consumerRef: `wechat-news-morning:${page.slug}:cover`,
idempotencyKey: `wechat-news-morning-${page.slug}-cover-v3`,
});
if (result?.ok && result?.asset?.workspaceRelativePath) {
const assetPath = resolveGeneratedAssetPath(
h5Root,
config.sourceUserId,
result.asset.workspaceRelativePath,
);
if (fs.existsSync(assetPath)) {
const raw = fs.readFileSync(assetPath);
const square = await sharp(raw, { sequentialRead: true })
.rotate()
.resize(900, 900, { fit: 'cover' })
.png()
.toBuffer();
if (thumbPath) {
fs.mkdirSync(path.dirname(thumbPath), { recursive: true });
fs.writeFileSync(thumbPath, square);
}
return { buffer: square, source: 'generated', prompt, assetPath };
}
}
logger.warn?.('[NewsMorningDraft] cover generation returned without asset file', {
code: result?.code ?? null,
slug: page.slug,
});
} catch (error) {
logger.warn?.(
'[NewsMorningDraft] cover generation failed:',
error instanceof Error ? error.message : error,
);
}
}
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>',
),
).png().toBuffer();
return { buffer: fallback, source: 'fallback' };
}
export function buildNewsMorningAutoGenerationTask(config, { now = Date.now(), searchBrief = '' } = {}) {
const timezone = String(config?.timezone ?? 'Asia/Shanghai').trim() || 'Asia/Shanghai';
const dateLabel = formatLocalDateParts(now, timezone);
const brief = String(searchBrief ?? '').trim();
return {
id: 'news-morning-auto-generate',
userId: config.sourceUserId,
title: '每日新闻早报页面(自动)',
taskSpec: [
buildNewsMorningTemplateSpec(config?.templateId),
`今日日期:${dateLabel.iso}(${dateLabel.year}年${dateLabel.month}月${dateLabel.day}日)。正文必须是这一天的新闻,不能是其它日期的旧稿。`,
`输出文件名必须是 daily-news-${dateLabel.mmdd}.html(可覆盖同名旧文件)。`,
NEWS_MORNING_CONTENT_SAFETY_TASK_SPEC,
String(config?.taskSpecAppend ?? '').trim(),
brief,
].filter(Boolean).join('\n'),
recurrence: 'once',
timezone,
notifyChannel: 'web',
};
}
function slugPatternToRegExp(pattern) {
const raw = String(pattern ?? '').trim() || 'daily-news-*';
const escaped = raw.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return new RegExp(`^${escaped}\\.html$`, 'i');
}
function formatShanghaiDateParts(date = new Date()) {
return formatLocalDateParts(date, 'Asia/Shanghai');
}
export function isDailyNewsFormat(html) {
return /daily-news|每日新闻早报/u.test(String(html ?? ''))
|| /class="date-badge"/u.test(String(html ?? ''));
}
function extractMetaDescription(html) {
const match = String(html ?? '').match(
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
);
return match?.[1]?.trim() ?? '';
}
function stripHtml(value) {
return String(value ?? '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function extractCardArticlesFromHotspots(html) {
const source = String(html ?? '');
const parts = source.split(/<div class="card"[^>]*>/i).slice(1);
const cards = parts.map((chunk) => chunk.split(/<div class="(?:trending|note|foot)"/i)[0] ?? chunk);
return cards.map((block, index) => {
const title = stripHtml(block.match(/<h2[^>]*>([\s\S]*?)<\/h2>/i)?.[1] ?? '');
const tag = stripHtml(
block.match(/<span class="[^"]*\btag\b[^"]*"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '',
);
const keys = [...block.matchAll(/<div class="k"[^>]*>([\s\S]*?)<\/div>/gi)]
.map((item) => stripHtml(item[1]))
.filter(Boolean);
const context = stripHtml(block.match(/<div class="ctx"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
const sourceLine = stripHtml(block.match(/<div class="src"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
return { index: index + 1, title, tag, keys, context, source: sourceLine };
}).filter((item) => item.title);
}
function extractCardArticlesFromDailyNews(html) {
const source = String(html ?? '');
const parts = source.split(/<div class="card(?:\s|")/i).slice(1);
return parts.map((block, index) => {
const title = stripHtml(
block.match(/<h3[^>]*>([\s\S]*?)<\/h3>/i)?.[1]
?? block.match(/<h2[^>]*>([\s\S]*?)<\/h2>/i)?.[1]
?? '',
);
const tag = stripHtml(
block.match(/<span class="[^"]*\btag\b[^"]*"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '',
);
const paragraphs = [...block.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)]
.map((item) => stripHtml(item[1]))
.filter(Boolean);
const context = paragraphs[0] ?? '';
const sourceLine = stripHtml(block.match(/<div class="source"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
return { index: index + 1, title, tag, keys: [], context, source: sourceLine };
}).filter((item) => item.title);
}
function extractCardArticles(html) {
if (isDailyNewsFormat(html)) return extractCardArticlesFromDailyNews(html);
return extractCardArticlesFromHotspots(html);
}
/** 服务号订阅推送:从早报 HTML 提取纯文本摘要(5–10 条标题)。 */
export function buildNewsMorningWechatPushText(
html,
{ publicUrl = '', maxItems = 5, manageHint = '回复「取消2」关闭推送' } = {},
) {
const cards = extractCardArticles(html).slice(0, Math.max(1, Math.min(10, Number(maxItems) || 5)));
const heroDate = stripHtml(
String(html).match(/<div class="date-badge"[^>]*>([\s\S]*?)<\/div>/i)?.[1]
?? String(html).match(/<span class="date"[^>]*>([\s\S]*?)<\/span>/i)?.[1]
?? '',
);
const lines = [`📰 每日新闻早报${heroDate ? ` · ${heroDate}` : ''}`];
if (cards.length === 0) {
lines.push('', '今日早报正在整理中,请稍后再看。');
} else {
lines.push('');
for (const card of cards) {
const prefix = card.tag ? `[${card.tag}] ` : '';
lines.push(`${card.index}. ${prefix}${card.title}`);
}
}
if (publicUrl) {
lines.push('', `👉 阅读全文:${publicUrl}`);
}
if (manageHint) {
lines.push('', manageHint);
}
return lines.join('\n');
}
function extractDailyNewsSections(html) {
return [...String(html ?? '').matchAll(/<div class="section-title"[^>]*>([\s\S]*?)<\/div>/gi)]
.map((item) => stripHtml(item[1]))
.filter(Boolean);
}
function extractStats(html) {
return [...String(html ?? '').matchAll(/<div class="stat[^"]*"[^>]*><b>([\s\S]*?)<\/b><span>([\s\S]*?)<\/span><\/div>/gi)]
.map((item) => ({ value: stripHtml(item[1]), label: stripHtml(item[2]) }))
.filter((item) => item.value);
}
function extractTrendingSummary(html) {
const block = String(html ?? '').match(/<div class="trending"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '';
const chips = [...block.matchAll(/<span class="chip"[^>]*>([\s\S]*?)<\/span>/gi)]
.map((item) => stripHtml(item[1]))
.filter(Boolean);
const paragraph = stripHtml(block.match(/<p[^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? '');
return { chips, paragraph };
}
export function resolveDailyNewsPublicBaseUrl(env = process.env) {
return resolvePublicBaseUrl({
...env,
H5_PUBLIC_BASE_URL:
env.MEMIND_PAGE_PUBLIC_BASE_URL
|| env.H5_PORTAL_PUBLIC_BASE_URL
|| 'https://m.tkmind.cn',
});
}
export function rewriteDailyNewsHtmlLinks(html, { publicBaseUrl, userId }) {
const base = String(publicBaseUrl ?? '').replace(/\/$/, '');
const owner = String(userId ?? '').trim();
if (!base || !owner) return String(html ?? '');
const publicPrefix = `${base}/MindSpace/${encodeURIComponent(owner)}/public/`;
return String(html ?? '')
.replace(/\shref=(["'])(?!https?:|#|mailto:|tel:)([^"']+)\1/gi, (match, quote, href) => {
const target = href.startsWith('/') ? `${base}${href}` : `${publicPrefix}${href.replace(/^\.\//, '')}`;
return ` href=${quote}${target}${quote}`;
});
}
function stripMindspaceChromeFromDailyNewsBody(bodyHtml) {
let body = String(bodyHtml ?? '');
body = body.replace(/<div[^>]*data-mindspace-public-share[\s\S]*$/i, '');
body = body.replace(/<section[^>]*data-mindspace-seo-outline[\s\S]*$/i, '');
body = body.replace(/<p[^>]*>\s*✨\s*一起创作[\s\S]*?<\/p>/gi, '');
return body.trim();
}
function extractDailyNewsStyleBlocks(html) {
const blocks = [];
for (const match of String(html ?? '').matchAll(/<style([^>]*)>([\s\S]*?)<\/style>/gi)) {
const attrs = match[1] ?? '';
const block = match[0];
if (/mindspace-public-share/i.test(`${attrs}${block}`)) continue;
blocks.push(block);
}
return blocks.join('\n');
}
function replaceDailyNewsHtmlImageRefs(html, imageUrlMap = new Map()) {
if (!imageUrlMap || imageUrlMap.size === 0) return String(html ?? '');
return String(html).replace(/(<img[^>]+src=["'])([^"']+)(["'])/gi, (match, prefix, src, suffix) => {
const mapped =
imageUrlMap.get(src)
?? imageUrlMap.get(src.replace(/^\.\//, ''))
?? imageUrlMap.get(src.replace(/^\//, ''));
return mapped ? `${prefix}${mapped}${suffix}` : match;
});
}
export function buildDailyNewsWechatHtmlContent(
html,
{
publicUrl = '',
publicBaseUrl = '',
userId = '',
imageUrlMap = new Map(),
appendLegacyReadOriginalLink = false,
} = {},
) {
const source = String(html ?? '')
.replace(/<script[\s\S]*?<\/script>/gi, '');
const styleBlock = extractDailyNewsStyleBlocks(source);
const bodyInner = stripMindspaceChromeFromDailyNewsBody(
source.match(/<body[^>]*>([\s\S]*?)<\/body>/i)?.[1] ?? source,
);
let content = `${styleBlock}<body>${bodyInner}</body>`;
content = rewriteDailyNewsHtmlLinks(content, { publicBaseUrl, userId });
content = replaceDailyNewsHtmlImageRefs(content, imageUrlMap);
if (appendLegacyReadOriginalLink && publicUrl) {
content += `<p style="margin:16px 0 0;font-size:13px;color:#718096;text-align:center;">阅读原文:<a href="${publicUrl}">${publicUrl}</a></p>`;
}
return content;
}
const DAILY_NEWS_AI_DISCLOSURE_HTML = `<p style="margin:14px 0 0;font-size:12px;color:#8a8f98;text-align:center;line-height:1.6">${NEWS_MORNING_AI_DISCLOSURE_TEXT}</p>`;
function estimateDailyNewsDraftFooterLength({
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = 'https://m.tkmind.cn',
} = {}) {
const footer = applyWechatDraftArticleLayout('', { publicUrl, qrcodeImageUrl });
const cta = renderCreatePortalCTA(portalUrl);
return footer.length + (cta ? cta.length + 1 : 0) + DAILY_NEWS_AI_DISCLOSURE_HTML.length + 1;
}
export function applyDailyNewsWechatDraftFooter(
body,
{ publicUrl = '', qrcodeImageUrl = '', portalUrl = 'https://m.tkmind.cn' } = {},
) {
const footer = applyWechatDraftArticleLayout('', { publicUrl, qrcodeImageUrl });
const cta = renderCreatePortalCTA(portalUrl);
const footerWithCta = [DAILY_NEWS_AI_DISCLOSURE_HTML, footer, cta].filter(Boolean).join('\n');
const separator = body && footerWithCta ? '\n' : '';
const maxBody = Math.max(0, 20000 - footerWithCta.length - separator.length);
let fittedBody = trimWechatInlineBodyByRemovingCards(String(body ?? '').trim(), maxBody);
fittedBody = clipWechatDraftBodyToFitFooter(fittedBody, footerWithCta);
return [fittedBody, footerWithCta].filter(Boolean).join('\n');
}
export function extractDailyNewsArticleMeta(html) {
const title = extractPageTitle(html) || '每日新闻早报';
const digest = (extractMetaDescription(html) || title).slice(0, 120);
return { title: title.slice(0, 64), digest };
}
export function resolveMpFollowQrcodePath(memindLibRoot = process.cwd()) {
const candidates = [
path.join(memindLibRoot, 'wechat/assets/mp-follow-qrcode.png'),
path.join(memindLibRoot, 'public/assets/mp-follow-qrcode.png'),
path.join(process.cwd(), 'wechat/assets/mp-follow-qrcode.png'),
path.join(process.cwd(), 'public/assets/mp-follow-qrcode.png'),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
}
export async function uploadWechatArticleContentImage(
accessToken,
imageBuffer,
{ wechatFetch = undiciFetch, uploadUrl = DEFAULT_UPLOAD_IMG_URL, filename = 'content.png' } = {},
) {
if (!accessToken) throw new Error('缺少微信 access_token');
if (!Buffer.isBuffer(imageBuffer) || imageBuffer.length === 0) {
throw new Error('正文图片为空');
}
const form = new FormData();
form.append(
'media',
new Blob([imageBuffer], { type: 'image/png' }),
filename,
);
const endpoint = new URL(uploadUrl);
endpoint.searchParams.set('access_token', accessToken);
const payload = await readJsonResponse(
await wechatFetch(endpoint.toString(), { method: 'POST', body: form }),
);
if (Number(payload?.errcode ?? 0) !== 0 || !String(payload?.url ?? '').trim()) {
throw new Error(payload?.errmsg || '上传微信正文图片失败');
}
return String(payload.url);
}
function normalizeWechatBodyImageUrl(imageUrl) {
return String(imageUrl ?? '').trim().replace(/^http:\/\//i, 'https://');
}
function collectHtmlBodyImageRefs(html) {
const refs = [];
const seen = new Set();
const coverRef = extractMindspaceCoverPath(html);
if (coverRef && !/^data:/i.test(coverRef)) {
seen.add(coverRef);
refs.push(coverRef);
}
for (const match of String(html ?? '').matchAll(/<img[^>]+src=["']([^"']+)["']/gi)) {
const ref = String(match[1] ?? '')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.trim();
if (!ref || /^data:/i.test(ref) || seen.has(ref)) continue;
seen.add(ref);
refs.push(ref);
}
return refs;
}
function resolveHtmlBodyImagePath(ref, { publishDir = '', memindLibRoot = process.cwd() } = {}) {
if (/^https?:\/\//i.test(ref)) return ref;
const normalized = ref.replace(/^\.\//, '').replace(/^\/+/, '');
const candidates = [
path.resolve(publishDir, normalized),
path.resolve(publishDir, PUBLIC_ZONE_DIR, normalized),
path.join(memindLibRoot, 'public', normalized),
path.join(memindLibRoot, 'wechat/assets', normalized),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
}
export async function uploadDailyNewsBodyImagesForWechat(
accessToken,
html,
{
publishDir = '',
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
} = {},
) {
const imageUrlMap = new Map();
if (!accessToken) return imageUrlMap;
for (const ref of collectHtmlBodyImageRefs(html)) {
const resolved = resolveHtmlBodyImagePath(ref, { publishDir, memindLibRoot });
if (!resolved || imageUrlMap.has(ref)) continue;
let buffer;
try {
if (/^https?:\/\//i.test(resolved)) {
const response = await wechatFetch(resolved);
if (!response.ok) {
console.warn(`[wechat-draft] skip image upload, download failed: ${ref} (${response.status})`);
continue;
}
buffer = Buffer.from(await response.arrayBuffer());
} else {
buffer = fs.readFileSync(resolved);
}
if (/\.svg$/i.test(ref) || /\.svg$/i.test(resolved)) {
buffer = await sharp(buffer).png().toBuffer();
} else if (/\.(webp|jpe?g)$/i.test(ref) || /\.(webp|jpe?g)$/i.test(resolved)) {
buffer = await sharp(buffer).png().toBuffer();
}
} catch (error) {
console.warn(`[wechat-draft] skip image upload for ${ref}:`, error instanceof Error ? error.message : error);
continue;
}
const uploaded = normalizeWechatBodyImageUrl(
await uploadWechatArticleContentImage(accessToken, buffer, {
wechatFetch,
filename: `${path.basename(ref).replace(/\.(webp|svg|jpe?g)$/i, '') || 'body'}.png`,
}),
);
imageUrlMap.set(ref, uploaded);
}
return imageUrlMap;
}
export function buildDailyNewsWechatDraftArticle({
html,
publicUrl,
qrcodeImageUrl = '',
imageUrlMap = new Map(),
publicBaseUrl = '',
userId = '',
} = {}) {
const meta = extractDailyNewsArticleMeta(html);
const footerLength = estimateDailyNewsDraftFooterLength({ publicUrl, qrcodeImageUrl });
const inline = convertDailyNewsHtmlToWechatInlineArticle(html, {
publicUrl,
qrcodeImageUrl,
imageUrlMap,
includeFixedLayout: false,
fixedLayoutLength: footerLength,
});
let content = applyDailyNewsWechatDraftFooter(inline.content, {
publicUrl,
qrcodeImageUrl,
});
if (publicBaseUrl && userId) {
content = rewriteDailyNewsHtmlLinks(content, { publicBaseUrl, userId });
}
return {
title: meta.title.slice(0, 64),
digest: meta.digest,
content,
contentSourceUrl: publicUrl || undefined,
contentMode: 'inline_html',
contentLength: content.length,
cardCount: inline.cardCount ?? extractCardArticles(html).length,
statsCount: extractStats(html).length,
uploadedImageCount: imageUrlMap.size,
trimLevel: inline.trimLevel,
trimmed: inline.trimmed,
usedFallbackTrim: inline.usedFallbackTrim,
};
}
export async function buildDailyNewsWechatDraftArticleForPush({
html,
publicUrl,
accessToken = null,
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
publishDir = '',
} = {}) {
const { qrcodeImageUrl } = await uploadWechatDraftBrandAssets({
accessToken,
wechatFetch,
memindLibRoot,
});
const imageUrlMap = await uploadDailyNewsBodyImagesForWechat(accessToken, html, {
publishDir,
wechatFetch,
memindLibRoot,
});
let draftHtml = String(html ?? '');
const pushWarnings = [];
if (/class="[^"]*\bmasthead\b/i.test(draftHtml)) {
const audit = auditNews002ImageCoverage(draftHtml, {
requireTemplateStructure: true,
requireAllCoreSections: false,
requireLeadCount: false,
});
pushWarnings.push(...audit.violations.filter((item) => (
item === 'story-image-missing'
|| item === 'lead-image-missing'
|| item === 'too-many-story-cards'
|| item === 'legacy-news001-dom'
)));
const missingUploads = extractNews002CardImageUrls(draftHtml).filter((ref) => (
!imageUrlMap.has(ref)
&& !imageUrlMap.has(ref.replace(/^\.\//, ''))
&& !imageUrlMap.has(ref.replace(/^\//, ''))
));
if (missingUploads.length > 0) {
draftHtml = stripNews002ImagesByRef(draftHtml, missingUploads);
pushWarnings.push(`image-upload-missing:${missingUploads.length}`);
}
}
return {
...buildDailyNewsWechatDraftArticle({
html: draftHtml,
publicUrl,
qrcodeImageUrl,
imageUrlMap,
}),
pushWarnings,
};
}
/** 微信正文只认已上传的 mmbiz 图片:未上传成功的配图整块去掉,卡片保留文字。 */
export function stripNews002ImagesByRef(html, refs = []) {
const missing = new Set(refs);
if (missing.size === 0) return String(html ?? '');
return String(html ?? '')
.replace(/<div\b[^>]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<\/div>/gi, (block) => {
const src = block.match(/<img\b[^>]*\bsrc=["']([^"']+)["']/i)?.[1];
return src && missing.has(src) ? '' : block;
})
.replace(/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi, (tag, src) => (missing.has(src) ? '' : tag));
}
export function convertNewsPageHtmlToWechatArticle(html, { publicUrl = '', qrcodeImageUrl = '' } = {}) {
const dailyNews = isDailyNewsFormat(html);
if (dailyNews) {
return buildDailyNewsWechatDraftArticle({ html, publicUrl, qrcodeImageUrl });
}
const title = extractPageTitle(html) || '今日新闻热点分析';
const digest = (extractMetaDescription(html) || title).slice(0, 120);
const heroDate = dailyNews
? stripHtml(String(html).match(/<div class="date-badge"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '')
: stripHtml(String(html).match(/<span class="date"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '');
const heroSub = dailyNews
? stripHtml(String(html).match(/<div class="subtitle"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '')
: stripHtml(String(html).match(/<p class="sub"[^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? '');
const stats = extractStats(html);
const cards = extractCardArticles(html).slice(0, dailyNews ? 12 : undefined);
const trending = extractTrendingSummary(html);
const sectionTitles = dailyNews ? extractDailyNewsSections(html) : [];
const sections = [];
sections.push(`<p style="margin:0 0 12px;font-size:15px;color:#333;">${heroDate || title}</p>`);
if (heroSub) {
sections.push(`<p style="margin:0 0 16px;font-size:14px;color:#666;line-height:1.8;">${heroSub}</p>`);
}
if (sectionTitles.length > 0) {
sections.push(
`<p style="margin:0 0 16px;font-size:13px;color:#888;">栏目:${sectionTitles.slice(0, 8).join(' · ')}</p>`,
);
}
if (stats.length > 0) {
const statLine = stats.map((item) => `${item.value} ${item.label}`).join(' · ');
sections.push(`<p style="margin:0 0 18px;font-size:14px;color:#444;"><strong>速览:</strong>${statLine}</p>`);
}
for (const card of cards) {
const tagPrefix = card.tag ? `[${card.tag}] ` : '';
sections.push(`<section style="margin:0 0 20px;">`);
sections.push(`<p style="margin:0 0 8px;font-size:16px;font-weight:700;color:#111;">${card.index}. ${tagPrefix}${card.title}</p>`);
if (card.keys.length > 0) {
sections.push(`<p style="margin:0 0 8px;font-size:14px;color:#444;line-height:1.8;">${card.keys.join(' | ')}</p>`);
}
if (card.context) {
sections.push(`<p style="margin:0 0 8px;font-size:14px;color:#555;line-height:1.9;">${card.context}</p>`);
}
if (card.source) {
sections.push(`<p style="margin:0;font-size:12px;color:#888;">${card.source}</p>`);
}
sections.push('</section>');
}
if (trending.chips.length > 0 || trending.paragraph) {
sections.push('<section style="margin:0 0 16px;">');
sections.push('<p style="margin:0 0 8px;font-size:15px;font-weight:700;color:#111;">社交平台热榜</p>');
if (trending.chips.length > 0) {
sections.push(`<p style="margin:0 0 8px;font-size:14px;color:#444;">${trending.chips.join(' · ')}</p>`);
}
if (trending.paragraph) {
sections.push(`<p style="margin:0;font-size:14px;color:#555;line-height:1.8;">${trending.paragraph}</p>`);
}
sections.push('</section>');
}
return {
title: title.slice(0, 64),
digest,
content: sections.join('\n'),
contentSourceUrl: publicUrl || undefined,
cardCount: cards.length,
statsCount: stats.length,
};
}
export function findLatestNewsMorningPage({
h5Root,
userId,
slugPattern = 'news-hotspots-*',
date = new Date(),
timezone = 'Asia/Shanghai',
} = {}) {
if (!h5Root || !userId) throw new Error('缺少 h5Root 或 userId');
const publicDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR);
if (!fs.existsSync(publicDir)) {
return null;
}
const matcher = slugPatternToRegExp(slugPattern);
const { iso, mmdd, compact } = formatLocalDateParts(date, timezone);
const datedCandidates = [];
const fallbackCandidates = [];
for (const entry of fs.readdirSync(publicDir, { withFileTypes: true })) {
if (!entry.isFile() || !matcher.test(entry.name)) continue;
const fullPath = path.join(publicDir, entry.name);
const stat = fs.statSync(fullPath);
const item = {
slug: entry.name.replace(/\.html$/i, ''),
relativePath: `${PUBLIC_ZONE_DIR}/${entry.name}`,
localPath: fullPath,
modifiedAt: stat.mtimeMs,
};
const slug = entry.name.replace(/\.html$/i, '');
if (
slug.endsWith(`-${mmdd}`)
|| entry.name.includes(iso)
|| entry.name.includes(compact)
|| entry.name.includes(iso.replace(/-/g, ''))
) {
datedCandidates.push(item);
} else {
fallbackCandidates.push(item);
}
}
const pool = datedCandidates.length > 0 ? datedCandidates : fallbackCandidates;
if (pool.length === 0) return null;
const slugRank = (slug) => {
const match = String(slug).match(/(\d{4})(?!.*\d{4})/) ?? String(slug).match(/(\d{4})/);
return match ? Number(match[1]) : 0;
};
pool.sort((a, b) => {
const rankDiff = slugRank(b.slug) - slugRank(a.slug);
if (rankDiff !== 0) return rankDiff;
return b.modifiedAt - a.modifiedAt;
});
const selected = pool[0];
const thumbPath = `${selected.localPath.replace(/\.html$/i, '')}.thumbnail.png`;
return {
...selected,
thumbPath: fs.existsSync(thumbPath) ? thumbPath : null,
};
}
async function readJsonResponse(response) {
const text = await response.text().catch(() => '');
if (!response.ok) {
throw new Error(text || `upstream ${response.status}`);
}
return text ? JSON.parse(text) : null;
}
async function normalizeThumbBuffer(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw new Error('封面图为空');
}
const attempts = [
{ width: 900, quality: 82 },
{ width: 720, quality: 70 },
{ width: 540, quality: 58 },
{ width: 420, quality: 48 },
];
for (const attempt of attempts) {
const normalized = await sharp(buffer, { sequentialRead: true })
.rotate()
.resize({ width: attempt.width, height: attempt.width, fit: 'cover' })
.jpeg({ quality: attempt.quality, mozjpeg: true })
.toBuffer();
if (normalized.length <= MAX_THUMB_BYTES) {
return { buffer: normalized, contentType: 'image/jpeg', filename: 'news-thumb.jpg' };
}
}
throw new Error(`封面图压缩后仍超过微信 thumb 限制(${MAX_THUMB_BYTES} bytes)`);
}
export async function uploadWechatPermanentThumb(accessToken, imageBuffer, { wechatFetch = undiciFetch, uploadUrl = DEFAULT_MATERIAL_ADD_URL } = {}) {
if (!accessToken) throw new Error('缺少微信 access_token');
const normalized = await normalizeThumbBuffer(imageBuffer);
const form = new FormData();
form.append(
'media',
new Blob([normalized.buffer], { type: normalized.contentType }),
normalized.filename,
);
const endpoint = new URL(uploadUrl);
endpoint.searchParams.set('access_token', accessToken);
endpoint.searchParams.set('type', 'thumb');
const payload = await readJsonResponse(
await wechatFetch(endpoint.toString(), { method: 'POST', body: form }),
);
if (Number(payload?.errcode ?? 0) !== 0 || !String(payload?.media_id ?? '').trim()) {
throw new Error(payload?.errmsg || '上传微信封面素材失败');
}
return String(payload.media_id);
}
export async function addWechatDraftArticle(accessToken, article, { wechatFetch = undiciFetch, draftAddUrl = DEFAULT_DRAFT_ADD_URL } = {}) {
if (!accessToken) throw new Error('缺少微信 access_token');
const endpoint = new URL(draftAddUrl);
endpoint.searchParams.set('access_token', accessToken);
const payload = await readJsonResponse(
await wechatFetch(endpoint.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ articles: [article] }),
}),
);
if (Number(payload?.errcode ?? 0) !== 0 || !String(payload?.media_id ?? '').trim()) {
throw new Error(payload?.errmsg || '写入微信草稿箱失败');
}
return {
draftMediaId: String(payload.media_id),
raw: payload,
};
}
async function ensureConfigTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
config_key VARCHAR(64) PRIMARY KEY,
config_json JSON NOT NULL,
updated_by CHAR(36) NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async function ensureRunsTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${RUNS_TABLE} (
id CHAR(36) PRIMARY KEY,
status VARCHAR(16) NOT NULL,
page_slug VARCHAR(255) NULL,
page_url VARCHAR(512) NULL,
draft_media_id VARCHAR(128) NULL,
error_message TEXT NULL,
triggered_by CHAR(36) NULL,
created_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
function defaultsFromEnv(env = process.env) {
const autoPushEnabled = normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTO, false);
return {
enabled: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED, false),
autoPushEnabled,
autoGenerateEnabled: normalizeBoolean(
env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTO_GENERATE,
autoPushEnabled,
),
generateLeadMinutes: clampGenerateLeadMinutes(
env.H5_WECHAT_NEWS_MORNING_DRAFT_GENERATE_LEAD_MINUTES,
60,
),
pushHour: clampHour(env.H5_WECHAT_NEWS_MORNING_DRAFT_HOUR, 6),
pushMinute: clampMinute(env.H5_WECHAT_NEWS_MORNING_DRAFT_MINUTE, 0),
timezone: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_TZ ?? 'Asia/Shanghai').trim() || 'Asia/Shanghai',
sourceUserId: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID ?? '').trim() || null,
pageSlugPattern: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_SLUG ?? 'daily-news-*').trim() || 'daily-news-*',
author: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTHOR ?? 'TKMind').trim() || 'TKMind',
openComment: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_OPEN_COMMENT, true),
forceGenerateOnce: false,
forcePushOnce: false,
forcePushRequireToday: true,
templateId: resolveNewsMorningTemplateId(null, env),
templateVersion: getNewsMorningTemplate(null, env).version,
};
}
export function createWechatNewsMorningDraftService(
pool,
{
mpConfig = null,
h5Root = process.cwd(),
memindLibRoot = h5Root,
imageGenerationService = null,
env = process.env,
wechatFetch = undiciFetch,
logger = console,
} = {},
) {
let ensurePromise = null;
let accessTokenCache = { token: '', expiresAt: 0 };
async function ensureReady() {
if (!ensurePromise) {
ensurePromise = (async () => {
await ensureConfigTable(pool);
await ensureRunsTable(pool);
})();
}
await ensurePromise;
}
async function readConfigRow() {
await ensureReady();
const [rows] = await pool.query(
`SELECT config_json, updated_by, updated_at
FROM ${CONFIG_TABLE}
WHERE config_key = ?
LIMIT 1`,
[CONFIG_KEY],
);
return rows[0] ?? null;
}
function mergeConfig(row) {
const defaults = defaultsFromEnv(env);
const stored = parseConfigJson(row?.config_json);
return {
enabled: normalizeBoolean(stored.enabled, defaults.enabled),
autoPushEnabled: normalizeBoolean(stored.autoPushEnabled, defaults.autoPushEnabled),
autoGenerateEnabled: normalizeBoolean(stored.autoGenerateEnabled, defaults.autoGenerateEnabled),
generateLeadMinutes: clampGenerateLeadMinutes(
stored.generateLeadMinutes,
defaults.generateLeadMinutes,
),
pushHour: clampHour(stored.pushHour, defaults.pushHour),
pushMinute: clampMinute(stored.pushMinute, defaults.pushMinute),
timezone: String(stored.timezone ?? defaults.timezone).trim() || defaults.timezone,
sourceUserId: String(stored.sourceUserId ?? defaults.sourceUserId ?? '').trim() || null,
pageSlugPattern: String(stored.pageSlugPattern ?? defaults.pageSlugPattern).trim() || defaults.pageSlugPattern,
author: String(stored.author ?? defaults.author).trim().slice(0, 8) || defaults.author,
openComment: normalizeBoolean(stored.openComment, defaults.openComment),
forceGenerateOnce: normalizeBoolean(stored.forceGenerateOnce, false),
taskSpecAppend: (() => {
const storedAppend = String(stored.taskSpecAppend ?? '').trim();
if (storedAppend) return storedAppend;
const templateId = resolveNewsMorningTemplateId(stored.templateId, env);
return templateId === 'news002' ? NEWS002_DEFAULT_TASK_SPEC_APPEND : '';
})(),
forcePushOnce: normalizeBoolean(stored.forcePushOnce, false),
forcePushRequireToday: normalizeBoolean(stored.forcePushRequireToday, true),
templateId: resolveNewsMorningTemplateId(stored.templateId, env),
templateVersion: getNewsMorningTemplate(stored.templateId, env).version,
};
}
async function getAccessToken() {
if (!mpConfig?.enabled) throw new Error('微信服务号未启用');
if (accessTokenCache.token && accessTokenCache.expiresAt > Date.now() + 60_000) {
return accessTokenCache.token;
}
const payload = await readJsonResponse(
await wechatFetch(mpConfig.tokenUrl || DEFAULT_WECHAT_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credential',
appid: mpConfig.appId,
secret: mpConfig.appSecret,
force_refresh: false,
}),
}),
);
if (!payload?.access_token) {
throw new Error(payload?.errmsg || '获取微信 access_token 失败');
}
accessTokenCache = {
token: payload.access_token,
expiresAt: Date.now() + Number(payload.expires_in ?? 7200) * 1000,
};
return accessTokenCache.token;
}
function buildPageSummary(config, page, { now = Date.now() } = {}) {
const publicBaseUrl = resolveDailyNewsPublicBaseUrl(env);
const publicUrl = buildPublicUrl(publicBaseUrl, config.sourceUserId, page.relativePath);
const dateParts = formatLocalDateParts(new Date(now), config.timezone);
return {
slug: page.slug,
relativePath: page.relativePath,
publicUrl,
modifiedAt: page.modifiedAt,
hasThumb: Boolean(page.thumbPath),
isTodayPage: isNewsMorningPageForToday(page, {
date: new Date(now),
timezone: config.timezone,
}),
dateKey: dateParts.iso,
expectedSlug: `daily-news-${dateParts.mmdd}`,
};
}
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');
}
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 });
}
if (requireToday && !pageSummary.isTodayPage) {
throw new Error(`今日(${pageSummary.dateKey})新闻早报尚未生成,当前最新页面为 ${page.slug}`);
}
const html = sanitizeNewsMorningHtmlSync(fs.readFileSync(page.localPath, 'utf8')).html;
const article = isDailyNewsFormat(html)
? buildDailyNewsWechatDraftArticle({
html,
publicUrl: pageSummary.publicUrl,
publicBaseUrl: resolveDailyNewsPublicBaseUrl(env),
userId: config.sourceUserId,
})
: convertNewsPageHtmlToWechatArticle(html, { publicUrl: pageSummary.publicUrl });
return {
config,
page: pageSummary,
article,
template: {
id: resolveNewsMorningTemplateId(config.templateId, env),
version: getNewsMorningTemplate(config.templateId, env).version,
spec: buildNewsMorningTemplateSpec(config.templateId, env),
},
};
}
async function listRunsSince(sinceMs, { limit = 20 } = {}) {
await ensureReady();
const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100);
const [rows] = await pool.query(
`SELECT id, status, page_slug AS pageSlug, page_url AS pageUrl,
draft_media_id AS draftMediaId, error_message AS errorMessage,
triggered_by AS triggeredBy, created_at AS createdAt
FROM ${RUNS_TABLE}
WHERE created_at >= ?
ORDER BY created_at DESC
LIMIT ?`,
[sinceMs, safeLimit],
);
return rows.map((row) => ({
...row,
createdAt: Number(row.createdAt ?? 0),
}));
}
async function recordRun({
status,
pageSlug = null,
pageUrl = null,
draftMediaId = null,
errorMessage = null,
triggeredBy = null,
}) {
await ensureReady();
const id = cryptoRandomId();
const now = Date.now();
await pool.query(
`INSERT INTO ${RUNS_TABLE}
(id, status, page_slug, page_url, draft_media_id, error_message, triggered_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[id, status, pageSlug, pageUrl, draftMediaId, errorMessage, triggeredBy, now],
);
return { id, status, pageSlug, pageUrl, draftMediaId, errorMessage, triggeredBy, createdAt: now };
}
return {
async getConfig() {
const row = await readConfigRow();
return {
...mergeConfig(row),
updatedAt: row?.updated_at ? Number(row.updated_at) : null,
updatedBy: row?.updated_by ?? null,
};
},
async getTemplate() {
const config = await this.getConfig();
const template = getNewsMorningTemplate(config.templateId, env);
return {
id: template.id,
label: template.label,
version: template.version,
supportsCardImages: template.supportsCardImages,
available: NEWS_MORNING_TEMPLATE_IDS,
spec: buildNewsMorningTemplateSpec(config.templateId, env),
};
},
async updateConfig(payload = {}, { updatedBy = null } = {}) {
const current = await this.getConfig();
const next = {
enabled:
payload.enabled === undefined
? current.enabled
: normalizeBoolean(payload.enabled, current.enabled),
autoPushEnabled:
payload.autoPushEnabled === undefined
? current.autoPushEnabled
: normalizeBoolean(payload.autoPushEnabled, current.autoPushEnabled),
autoGenerateEnabled:
payload.autoGenerateEnabled === undefined
? current.autoGenerateEnabled
: normalizeBoolean(payload.autoGenerateEnabled, current.autoGenerateEnabled),
generateLeadMinutes:
payload.generateLeadMinutes === undefined
? current.generateLeadMinutes
: clampGenerateLeadMinutes(payload.generateLeadMinutes, current.generateLeadMinutes),
pushHour:
payload.pushHour === undefined ? current.pushHour : clampHour(payload.pushHour, current.pushHour),
pushMinute:
payload.pushMinute === undefined
? current.pushMinute
: clampMinute(payload.pushMinute, current.pushMinute),
timezone:
payload.timezone === undefined
? current.timezone
: String(payload.timezone ?? current.timezone).trim() || current.timezone,
sourceUserId:
payload.sourceUserId === undefined
? current.sourceUserId
: String(payload.sourceUserId ?? '').trim() || null,
pageSlugPattern:
payload.pageSlugPattern === undefined
? current.pageSlugPattern
: String(payload.pageSlugPattern ?? current.pageSlugPattern).trim() || current.pageSlugPattern,
author:
payload.author === undefined
? current.author
: String(payload.author ?? current.author).trim().slice(0, 8) || current.author,
openComment:
payload.openComment === undefined
? current.openComment
: normalizeBoolean(payload.openComment, current.openComment),
forceGenerateOnce:
payload.forceGenerateOnce === undefined
? current.forceGenerateOnce
: normalizeBoolean(payload.forceGenerateOnce, current.forceGenerateOnce),
taskSpecAppend:
payload.taskSpecAppend === undefined
? current.taskSpecAppend
: String(payload.taskSpecAppend ?? '').trim(),
templateId: resolveNewsMorningTemplateId(
payload.templateId === undefined ? current.templateId : payload.templateId,
env,
),
};
next.templateVersion = getNewsMorningTemplate(next.templateId, env).version;
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), updatedBy, now],
);
return this.getConfig();
},
async preview(options = {}) {
return resolvePreview(null, options);
},
async previewToday() {
return resolvePreview(null, { requireToday: true });
},
async getTodayStatus({ now = Date.now() } = {}) {
const config = await this.getConfig();
const dateParts = formatLocalDateParts(new Date(now), config.timezone);
const expectedSlug = `daily-news-${dateParts.mmdd}`;
if (!config.sourceUserId) {
return {
configured: false,
dateKey: dateParts.iso,
expectedSlug,
todayPageReady: false,
page: null,
autoGenerateEnabled: config.autoGenerateEnabled,
autoPushEnabled: config.autoPushEnabled,
pushHour: config.pushHour,
pushMinute: config.pushMinute,
generateLeadMinutes: config.generateLeadMinutes,
message: '请先配置新闻早报来源用户 ID',
runs: {
generate: null,
push: null,
},
};
}
const page = findLatestNewsMorningPage({
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
date: new Date(now),
timezone: config.timezone,
});
const pageSummary = page ? buildPageSummary(config, page, { now }) : null;
const todayPageReady = Boolean(pageSummary?.isTodayPage);
const dayStart = startOfLocalDay(now, config.timezone);
const runs = await listRunsSince(dayStart, { limit: 20 });
const generate = runs.find((run) => run.triggeredBy === 'auto-generate') ?? null;
const push = runs.find((run) => run.triggeredBy === 'auto-push') ?? null;
return {
configured: true,
dateKey: dateParts.iso,
expectedSlug,
todayPageReady,
page: todayPageReady ? pageSummary : null,
latestPageSlug: pageSummary && !todayPageReady ? pageSummary.slug : null,
autoGenerateEnabled: config.autoGenerateEnabled,
autoPushEnabled: config.autoPushEnabled,
pushHour: config.pushHour,
pushMinute: config.pushMinute,
generateLeadMinutes: config.generateLeadMinutes,
message: todayPageReady
? null
: `今日(${dateParts.iso})新闻早报尚未生成`,
runs: {
generate,
push,
},
};
},
async pushDraft({
triggeredBy = null,
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, pageOverride });
if (dryRun) {
return {
dryRun: true,
preview,
};
}
try {
const accessToken = await getAccessToken();
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 safety = sanitizeNewsMorningHtmlSync(fs.readFileSync(page.localPath, 'utf8'));
if (safety.removed.length > 0 || safety.maskedCount > 0) {
logger.warn?.('[NewsMorningDraft] content safety filtered at push time', {
slug: page.slug,
removed: safety.removed.map((item) => `${item.title.slice(0, 24)}(${item.reason})`),
masked: safety.maskedCount,
});
}
const html = safety.html;
const thumb = await resolveNewsMorningDraftThumbBuffer({
page,
html,
config,
h5Root,
imageGenerationService,
logger,
now,
publishDir: path.dirname(page.localPath),
memindLibRoot: h5Root,
});
const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumb.buffer, { wechatFetch });
const article = isDailyNewsFormat(html)
? await buildDailyNewsWechatDraftArticleForPush({
html,
publicUrl: preview.page.publicUrl,
accessToken,
wechatFetch,
memindLibRoot,
publishDir: path.dirname(page.localPath),
})
: preview.article;
if (article.pushWarnings?.length) {
logger.warn?.('[NewsMorningDraft] draft pushed with repairs', {
pageSlug: page.slug,
warnings: article.pushWarnings,
});
}
if (articleTitleOverride) {
article.title = String(articleTitleOverride).trim().slice(0, 64);
}
article.title = maskBlockedNewsTerms(article.title).text;
article.digest = maskBlockedNewsTerms(article.digest ?? '').text;
const draft = await addWechatDraftArticle(
accessToken,
{
title: article.title,
author: config.author,
digest: article.digest,
content: article.content,
content_source_url: article.contentSourceUrl,
thumb_media_id: thumbMediaId,
need_open_comment: config.openComment !== false ? 1 : 0,
only_fans_can_comment: 0,
},
{ wechatFetch },
);
preview = {
...preview,
article,
};
const run = await recordRun({
status: 'success',
pageSlug: preview.page.slug,
pageUrl: preview.page.publicUrl,
draftMediaId: draft.draftMediaId,
triggeredBy,
});
return {
ok: true,
draftMediaId: draft.draftMediaId,
cover: {
source: thumb.source,
prompt: thumb.prompt ?? null,
assetPath: thumb.assetPath ?? null,
},
preview,
run,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const run = await recordRun({
status: 'failed',
pageSlug: preview?.page?.slug ?? null,
pageUrl: preview?.page?.publicUrl ?? null,
errorMessage: message,
triggeredBy,
});
throw Object.assign(new Error(message), { run });
}
},
async listRuns({ limit = 20 } = {}) {
await ensureReady();
const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100);
const [rows] = await pool.query(
`SELECT id, status, page_slug AS pageSlug, page_url AS pageUrl,
draft_media_id AS draftMediaId, error_message AS errorMessage,
triggered_by AS triggeredBy, created_at AS createdAt
FROM ${RUNS_TABLE}
ORDER BY created_at DESC
LIMIT ?`,
[safeLimit],
);
return rows.map((row) => ({
...row,
createdAt: Number(row.createdAt ?? 0),
}));
},
async hasSuccessfulAutoPushToday({ now = Date.now() } = {}) {
const config = await this.getConfig();
const timezone = config.timezone;
const page = findLatestNewsMorningPage({
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
date: new Date(now),
timezone,
});
if (!page || !isNewsMorningPageForToday(page, { date: new Date(now), timezone })) {
return false;
}
await ensureReady();
const dayStart = startOfLocalDay(now, timezone);
const [rows] = await pool.query(
`SELECT id
FROM ${RUNS_TABLE}
WHERE status = 'success'
AND page_slug = ?
AND triggered_by = 'auto-push'
AND created_at >= ?
LIMIT 1`,
[page.slug, dayStart],
);
return rows.length > 0;
},
async requestForceGenerate({ updatedBy = null } = {}) {
return this.updateConfig({ forceGenerateOnce: true }, { updatedBy });
},
async recordAutoGenerationRun({
status,
pageSlug = null,
errorMessage = null,
}) {
return recordRun({
status,
pageSlug,
errorMessage,
triggeredBy: 'auto-generate',
});
},
};
}
function cryptoRandomId() {
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const wechatNewsMorningDraftInternals = {
normalizeBoolean,
defaultsFromEnv,
slugPatternToRegExp,
convertNewsPageHtmlToWechatArticle,
buildDailyNewsWechatHtmlContent,
applyDailyNewsWechatDraftFooter,
buildDailyNewsWechatDraftArticle,
findLatestNewsMorningPage,
isNewsMorningPageForToday,
isNewsMorningContentDegraded,
isNewsMorningPageDegraded,
isLocalScheduleDue,
isWithinNewsMorningGenerateLeadWindow,
buildNewsMorningAutoGenerationTask,
buildNewsMorningCoverPrompt,
buildNewsMorningCoverNegativePrompt,
resolveNewsMorningCoverConcept,
NEWS_MORNING_COVER_CONCEPTS,
buildNewsMorningWechatPushText,
extractMindspaceCoverMeta,
NEWS_MORNING_FRESH_CONTENT_SPEC,
formatLocalDateParts,
localDateKey,
};