d4ad546fe2
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>
1818 lines
65 KiB
JavaScript
1818 lines
65 KiB
JavaScript
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(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/&/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(/&/gi, '&')
|
||
.replace(/"/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,
|
||
};
|