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( /]+name=["']description["'][^>]+content=["']([^"']*)["']/i, ); return match?.[1]?.trim() ?? ''; } function stripHtml(value) { return String(value ?? '') .replace(//gi, '\n') .replace(/<[^>]+>/g, '') .replace(/\s+/g, ' ') .trim(); } function extractCardArticlesFromHotspots(html) { const source = String(html ?? ''); const parts = source.split(/
]*>/i).slice(1); const cards = parts.map((chunk) => chunk.split(/
{ const title = stripHtml(block.match(/]*>([\s\S]*?)<\/h2>/i)?.[1] ?? ''); const tag = stripHtml(block.match(/]*>([\s\S]*?)<\/span>/i)?.[1] ?? ''); const keys = [...block.matchAll(/
]*>([\s\S]*?)<\/div>/gi)] .map((item) => stripHtml(item[1])) .filter(Boolean); const context = stripHtml(block.match(/
]*>([\s\S]*?)<\/div>/i)?.[1] ?? ''); const sourceLine = stripHtml(block.match(/
]*>([\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(/
{ const title = stripHtml( block.match(/]*>([\s\S]*?)<\/h3>/i)?.[1] ?? block.match(/]*>([\s\S]*?)<\/h2>/i)?.[1] ?? '', ); const tag = stripHtml(block.match(/]*>([\s\S]*?)<\/span>/i)?.[1] ?? ''); const paragraphs = [...block.matchAll(/]*>([\s\S]*?)<\/p>/gi)] .map((item) => stripHtml(item[1])) .filter(Boolean); const context = paragraphs[0] ?? ''; const sourceLine = stripHtml(block.match(/
]*>([\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(/
]*>([\s\S]*?)<\/div>/gi)] .map((item) => stripHtml(item[1])) .filter(Boolean); } function extractStats(html) { return [...String(html ?? '').matchAll(/
]*>([\s\S]*?)<\/b>([\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(/