Files
memind/wechat-news-morning-draft.mjs
T
2026-09-10 09:47:51 +08:00

845 lines
31 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 { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
import { convertDailyNewsHtmlToWechatInlineArticle } from './wechat-daily-news-inline.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;
export const NEWS_MORNING_TEMPLATE_VERSION = '2026-09-10';
/** 0910 新闻早报页面结构说明,供定时任务 taskSpec 与草稿转换共用。 */
export const NEWS_MORNING_TEMPLATE_0910_SPEC = [
'生成「每日新闻早报」静态 HTML 页面,版式对齐 2026-09-10 生产版(daily-news-MMDD):',
'1. Hero 渐变区:主标题「📰 每日新闻早报」、date-badge、subtitle 导语;',
'2. quick-links 锚点导航(要闻/天气/国际/国内/财经/科技/体育等);',
'3. 多个 sectionsection-title + cardtag、h3 标题、p 正文、source 来源);',
'4. 可选 weather、knowledge、history 等区块;',
'5. footer 品牌;',
'6. 文件名 daily-news-MMDD.html(如 daily-news-0910.html),并生成同名 .thumbnail.png',
'7. 推送微信草稿时转为内联 style HTML(参考公众号排版),禁止整页长图;正文需控制在 2 万字符内。',
].join('\n');
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 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()) {
const formatter = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const parts = formatter.formatToParts(date);
const year = parts.find((item) => item.type === 'year')?.value ?? '';
const month = parts.find((item) => item.type === 'month')?.value ?? '';
const day = parts.find((item) => item.type === 'day')?.value ?? '';
return {
year,
month,
day,
iso: `${year}-${month}-${day}`,
mmdd: `${month}${day}`,
compact: `${year}${month}${day}`,
};
}
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="tag"[^>]*>([\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="tag"[^>]*>([\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);
}
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}`;
});
}
export function buildDailyNewsWechatHtmlContent(html, { publicUrl = '', publicBaseUrl = '', userId = '' } = {}) {
const source = String(html ?? '');
const styleBlock = source.match(/<style>([\s\S]*?)<\/style>/i)?.[0] ?? '';
const bodyBlock = source.match(/<body>([\s\S]*?)<\/body>/i)?.[0] ?? '';
let content = `${styleBlock}${bodyBlock}`;
content = rewriteDailyNewsHtmlLinks(content, { publicBaseUrl, userId });
if (publicUrl) {
content += `<p style="margin:16px 0 0;font-size:13px;color:#718096;text-align:center;">阅读原文:<a href="${publicUrl}">${publicUrl}</a></p>`;
}
return content;
}
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(process.cwd(), 'wechat/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);
}
export function buildDailyNewsWechatDraftArticle({
html,
publicUrl,
qrcodeImageUrl = '',
} = {}) {
const article = convertDailyNewsHtmlToWechatInlineArticle(html, { publicUrl, qrcodeImageUrl });
return {
...article,
cardCount: extractCardArticles(html).length,
statsCount: extractStats(html).length,
};
}
export async function buildDailyNewsWechatDraftArticleForPush({
html,
publicUrl,
accessToken = null,
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
} = {}) {
let qrcodeImageUrl = '';
if (accessToken) {
const qrcodePath = resolveMpFollowQrcodePath(memindLibRoot);
if (qrcodePath) {
qrcodeImageUrl = await uploadWechatArticleContentImage(
accessToken,
fs.readFileSync(qrcodePath),
{ wechatFetch, filename: 'mp-follow-qrcode.png' },
);
}
}
return buildDailyNewsWechatDraftArticle({ html, publicUrl, qrcodeImageUrl });
}
export function convertNewsPageHtmlToWechatArticle(html, { publicUrl = '' } = {}) {
const dailyNews = isDailyNewsFormat(html);
if (dailyNews) {
return buildDailyNewsWechatDraftArticle({ html, publicUrl });
}
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>');
}
if (publicUrl) {
sections.push(
`<p style="margin:16px 0 0;font-size:13px;color:#888;">阅读原文:<a href="${publicUrl}">${publicUrl}</a></p>`,
);
}
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(),
} = {}) {
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 } = formatShanghaiDateParts(date);
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) {
return {
enabled: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED, false),
autoPushEnabled: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTO, false),
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',
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
};
}
export function createWechatNewsMorningDraftService(
pool,
{
mpConfig = null,
h5Root = process.cwd(),
memindLibRoot = h5Root,
env = process.env,
wechatFetch = undiciFetch,
} = {},
) {
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),
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,
templateVersion: NEWS_MORNING_TEMPLATE_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;
}
async function resolvePreview(configOverride = null) {
const config = configOverride ?? mergeConfig(await readConfigRow());
if (!config.sourceUserId) {
throw new Error('请先配置新闻早报来源用户 ID');
}
const page = findLatestNewsMorningPage({
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
});
if (!page) {
throw new Error(`未找到匹配 ${config.pageSlugPattern} 的新闻早报页面`);
}
const html = fs.readFileSync(page.localPath, 'utf8');
const publicBaseUrl = resolveDailyNewsPublicBaseUrl(env);
const publicUrl = buildPublicUrl(publicBaseUrl, config.sourceUserId, page.relativePath);
const article = isDailyNewsFormat(html)
? buildDailyNewsWechatDraftArticle({ html, publicUrl })
: convertNewsPageHtmlToWechatArticle(html, { publicUrl });
return {
config,
page: {
slug: page.slug,
relativePath: page.relativePath,
publicUrl,
modifiedAt: page.modifiedAt,
hasThumb: Boolean(page.thumbPath),
},
article,
template: {
version: NEWS_MORNING_TEMPLATE_VERSION,
spec: NEWS_MORNING_TEMPLATE_0910_SPEC,
},
};
}
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,
};
},
getTemplate() {
return {
version: NEWS_MORNING_TEMPLATE_VERSION,
spec: NEWS_MORNING_TEMPLATE_0910_SPEC,
};
},
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),
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,
templateVersion: NEWS_MORNING_TEMPLATE_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() {
return resolvePreview();
},
async pushDraft({ triggeredBy = null, dryRun = false } = {}) {
const config = await this.getConfig();
if (!config.enabled) {
throw new Error('新闻早报草稿推送未启用');
}
let preview = null;
preview = await resolvePreview(config);
if (dryRun) {
return {
dryRun: true,
preview,
};
}
try {
const accessToken = await getAccessToken();
let thumbBuffer = null;
const page = findLatestNewsMorningPage({
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
});
if (page?.thumbPath) {
thumbBuffer = fs.readFileSync(page.thumbPath);
} else if (page?.localPath) {
thumbBuffer = 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>',
);
thumbBuffer = await sharp(thumbBuffer).png().toBuffer();
}
const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumbBuffer, { wechatFetch });
const html = fs.readFileSync(page.localPath, 'utf8');
const article = isDailyNewsFormat(html)
? await buildDailyNewsWechatDraftArticleForPush({
html,
publicUrl: preview.page.publicUrl,
accessToken,
wechatFetch,
memindLibRoot,
})
: preview.article;
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: 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,
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),
}));
},
};
}
function cryptoRandomId() {
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const wechatNewsMorningDraftInternals = {
normalizeBoolean,
defaultsFromEnv,
slugPatternToRegExp,
convertNewsPageHtmlToWechatArticle,
buildDailyNewsWechatHtmlContent,
buildDailyNewsWechatDraftArticle,
findLatestNewsMorningPage,
};