6915b2da74
Co-authored-by: Cursor <cursoragent@cursor.com>
845 lines
31 KiB
JavaScript
845 lines
31 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 { 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. 多个 section:section-title + card(tag、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,
|
||
};
|