From 6d76618c0bee020224eecfd486f5f56f7fe09de8 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 10 Sep 2026 09:22:23 +0800 Subject: [PATCH] feat(wechat): add news morning draft inline HTML converter Push daily-news pages to WeChat drafts as inline-styled HTML instead of full-page long images, with automatic trimming to stay within the 20k limit. Co-authored-by: Cursor --- wechat-daily-news-inline.mjs | 356 +++++++++++++ wechat-news-morning-draft.mjs | 784 +++++++++++++++++++++++++++++ wechat-news-morning-draft.test.mjs | 171 +++++++ 3 files changed, 1311 insertions(+) create mode 100644 wechat-daily-news-inline.mjs create mode 100644 wechat-news-morning-draft.mjs create mode 100644 wechat-news-morning-draft.test.mjs diff --git a/wechat-daily-news-inline.mjs b/wechat-daily-news-inline.mjs new file mode 100644 index 0000000..ce1dd5e --- /dev/null +++ b/wechat-daily-news-inline.mjs @@ -0,0 +1,356 @@ +import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs'; + +const MAX_WECHAT_CONTENT_CHARS = 20000; +const MAX_WECHAT_CONTENT_TARGET_CHARS = 19800; + +const CARD_VARIANTS = { + 'highlight-box': { + wrap: 'background:linear-gradient(135deg,#b71c1c,#d32f2f);border-radius:12px;padding:14px;margin:0 0 10px;color:#fff;', + title: 'margin:0 0 6px;font-size:16px;font-weight:700;color:#fff;line-height:1.5;', + body: 'margin:0 0 6px;font-size:14px;line-height:1.7;color:#ffcdd2;', + source: 'margin:0;font-size:12px;color:#90caf4;', + tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:rgba(255,255,255,.18);color:#fff;', + }, + 'highlight-box-orange': { + wrap: 'background:linear-gradient(135deg,#e65100,#bf360c);border-radius:12px;padding:14px;margin:0 0 10px;color:#fff;', + title: 'margin:0 0 6px;font-size:16px;font-weight:700;color:#fff;line-height:1.5;', + body: 'margin:0 0 6px;font-size:14px;line-height:1.7;color:#ffe0b2;', + source: 'margin:0;font-size:12px;color:#ffcdd2;', + tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:rgba(255,255,255,.18);color:#fff;', + }, + 'highlight-box-gold': { + wrap: 'background:linear-gradient(135deg,#0d47a1,#1565c0);border-radius:12px;padding:14px;margin:0 0 10px;color:#fff;', + title: 'margin:0 0 6px;font-size:16px;font-weight:700;color:#fff;line-height:1.5;', + body: 'margin:0 0 6px;font-size:14px;line-height:1.7;color:#bbdefb;', + source: 'margin:0;font-size:12px;color:#bbdefb;', + tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:rgba(255,255,255,.18);color:#fff;', + }, + 'highlight-box-teal': { + wrap: 'background:linear-gradient(135deg,#004d40,#00695c);border-radius:12px;padding:14px;margin:0 0 10px;color:#fff;', + title: 'margin:0 0 6px;font-size:16px;font-weight:700;color:#fff;line-height:1.5;', + body: 'margin:0 0 6px;font-size:14px;line-height:1.7;color:#b2dfdb;', + source: 'margin:0;font-size:12px;color:#b2dfdb;', + tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:rgba(255,255,255,.18);color:#fff;', + }, + default: { + wrap: 'background:#fff;border-radius:12px;padding:14px;margin:0 0 10px;', + title: 'margin:0 0 4px;font-size:16px;font-weight:600;color:#1a202c;line-height:1.5;', + body: 'margin:0 0 6px;font-size:14px;color:#4a5568;line-height:1.7;', + source: 'margin:0;font-size:12px;color:#a0aec0;', + tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:#ffebee;color:#b71c1c;', + }, +}; + +const TAG_CLASS_COLORS = { + 'tag-red': 'background:#ffcdd2;color:#b71c1c;', + 'tag-blue': 'background:#bbdefb;color:#0d47a1;', + 'tag-green': 'background:#c8e6c9;color:#1b5e20;', + 'tag-purple': 'background:#e1bee7;color:#6a1b9a;', + 'tag-orange': 'background:#ffe0b2;color:#e65100;', + 'tag-teal': 'background:#b2dfdb;color:#004d40;', + 'tag-pink': 'background:#f8bbd0;color:#880e4f;', + 'tag-gray': 'background:#e0e0e0;color:#424242;', + 'tag-amber': 'background:#ffecb3;color:#ff6f00;', +}; + +function extractMetaDescription(html) { + const match = String(html ?? '').match( + /]+name=["']description["'][^>]+content=["']([^"']*)["']/i, + ); + return match?.[1]?.trim() ?? ''; +} + +function stripScripts(html) { + return String(html ?? '').replace(//gi, ''); +} + +function sanitizeInlineHtml(html) { + return stripScripts(String(html ?? '')) + .replace(/<(?!\/?(a\b|br\b|strong\b|span\b|b\b|em\b|i\b)\b)[^>]+>/gi, '') + .replace(/\son\w+="[^"]*"/gi, '') + .trim(); +} + +function extractInnerHtml(block, pattern) { + const match = String(block ?? '').match(pattern); + return match?.[1] ? sanitizeInlineHtml(match[1]) : ''; +} + +function detectCardVariant(cardHtml) { + if (/highlight-box-orange/u.test(cardHtml)) return 'highlight-box-orange'; + if (/highlight-box-gold/u.test(cardHtml)) return 'highlight-box-gold'; + if (/highlight-box-teal/u.test(cardHtml)) return 'highlight-box-teal'; + if (/highlight-box/u.test(cardHtml)) return 'highlight-box'; + return 'default'; +} + +function renderTagHtml(cardHtml, styles) { + const tagMatch = String(cardHtml).match(/]*(?:style="([^"]*)")?[^>]*>([\s\S]*?)<\/span>/i); + if (!tagMatch) return ''; + const className = tagMatch[1]?.trim() ?? ''; + const inlineStyle = tagMatch[2]?.trim() ?? ''; + const label = sanitizeInlineHtml(tagMatch[3]); + if (!label) return ''; + const colorStyle = TAG_CLASS_COLORS[className.replace(/^\s+/, '')] ?? ''; + const style = inlineStyle || colorStyle || styles.tag; + return `${label}`; +} + +function renderCard(cardHtml) { + const variant = detectCardVariant(cardHtml); + const styles = CARD_VARIANTS[variant] ?? CARD_VARIANTS.default; + const tag = renderTagHtml(cardHtml, styles); + const title = extractInnerHtml(cardHtml, /]*>([\s\S]*?)<\/h3>/i); + const body = extractInnerHtml(cardHtml, /]*>([\s\S]*?)<\/p>/i); + const sourceRaw = extractInnerHtml(cardHtml, /
]*>([\s\S]*?)<\/div>/i); + const source = sourceRaw.replace(/^来源:?/u, '').trim(); + if (!title) return ''; + + const parts = [ + `
`, + tag ? `

${tag}

` : '', + `

${title}

`, + body ? `

${body}

` : '', + source ? `

来源:${source}

` : '', + '
', + ]; + return parts.filter(Boolean).join(''); +} + +function renderSectionTitle(titleHtml) { + const text = sanitizeInlineHtml(titleHtml).replace(/]*>([\s\S]*?)<\/span>/gi, '$1'); + if (!text) return ''; + return `

${text}

`; +} + +function renderHero(html) { + const title = sanitizeInlineHtml(String(html).match(/]*>([\s\S]*?)<\/h1>/i)?.[1] ?? '') || '📰 每日新闻早报'; + const dateBadge = sanitizeInlineHtml(String(html).match(/
]*>([\s\S]*?)<\/div>/i)?.[1] ?? ''); + const subtitle = sanitizeInlineHtml(String(html).match(/
]*>([\s\S]*?)<\/div>/i)?.[1] ?? ''); + return [ + '
', + `

${title}

`, + dateBadge ? `

${dateBadge}

` : '', + subtitle ? `

${subtitle}

` : '', + '
', + ].filter(Boolean).join(''); +} + +function renderWeatherSection(sectionHtml) { + const title = extractInnerHtml(sectionHtml, /
]*>([\s\S]*?)<\/div>/i); + const summary = extractInnerHtml(sectionHtml, /
]*>([\s\S]*?)<\/div>/i); + const cityCards = [...String(sectionHtml).matchAll(/
]*>[\s\S]*?([\s\S]*?)<\/span>[\s\S]*?([\s\S]*?)<\/span>[\s\S]*?([\s\S]*?)<\/span>/gi)] + .map((item) => `${sanitizeInlineHtml(item[1])}${sanitizeInlineHtml(item[2])} ${sanitizeInlineHtml(item[3])}`) + .slice(0, 12); + const footer = extractInnerHtml(sectionHtml, /

([\s\S]*?)<\/p>/i); + + const parts = [renderSectionTitle(title)]; + parts.push('

'); + if (summary) { + parts.push(`

${summary.replace(/]*>/gi, ' ').replace(/<\/div>/gi, ' ')}

`); + } + if (cityCards.length > 0) { + parts.push(`

${cityCards.join(' · ')}

`); + } + parts.push('
'); + if (footer) { + parts.push(`

${footer}

`); + } + return parts.join(''); +} + +function splitSections(html) { + const body = String(html ?? '').match(/([\s\S]*?)<\/body>/i)?.[1] ?? String(html ?? ''); + return body + .split(/
]*>/i) + .slice(1) + .map((chunk) => { + const id = chunk.match(/^[^>]*id="([^"]+)"/i)?.[1] ?? ''; + const htmlChunk = `
]*>([\s\S]*?)<\/div>/i)); + return { id, title, html: htmlChunk }; + }); +} + +function stripInlineText(value) { + return sanitizeInlineHtml(String(value ?? '')) + .replace(/]*>([\s\S]*?)<\/span>/gi, '$1') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +const DEFAULT_SECTION_CARD_LIMITS = { + headlines: 6, + world: 3, + domestic: 3, + google: 2, + finance: 3, + tech: 3, + sports: 2, + technews: 2, + hot: 1, +}; + +const TRIM_PROFILES = [ + { skipHistory: true, sectionCardLimits: null, maxBodyChars: null }, + { + skipHistory: true, + sectionCardLimits: DEFAULT_SECTION_CARD_LIMITS, + maxBodyChars: 120, + }, + { + skipHistory: true, + sectionCardLimits: { ...DEFAULT_SECTION_CARD_LIMITS, headlines: 5, world: 2, domestic: 2 }, + maxBodyChars: 90, + }, + { + skipHistory: true, + sectionCardLimits: { headlines: 4, world: 2, domestic: 2, google: 2, finance: 2, tech: 2, sports: 1, technews: 1, hot: 1 }, + maxBodyChars: 72, + }, + { + skipHistory: true, + sectionCardLimits: { headlines: 3, world: 2, domestic: 2, google: 2, finance: 2, tech: 2, sports: 1, technews: 0, hot: 1 }, + maxBodyChars: 64, + }, + { + skipHistory: true, + sectionCardLimits: { headlines: 3, world: 2, domestic: 2, google: 1, finance: 2, tech: 2, sports: 1, technews: 0, hot: 0 }, + maxBodyChars: 56, + }, +]; + +function resolveSectionCardLimit(section, sectionCardLimits) { + if (!sectionCardLimits) return null; + return sectionCardLimits[section.id] ?? 3; +} + +function buildWechatInlineContent(html, { publicUrl = '', trimProfile = TRIM_PROFILES[0] } = {}) { + const sections = splitSections(html); + const parts = [renderHero(html)]; + + for (const section of sections) { + if (trimProfile.skipHistory && shouldSkipSection(section, trimProfile)) { + continue; + } + if (resolveSectionCardLimit(section, trimProfile.sectionCardLimits) === 0) { + continue; + } + if (section.id === 'weather') { + parts.push(renderWeatherSection(section.html)); + continue; + } + parts.push(renderGenericSection(section.html, { + maxCards: resolveSectionCardLimit(section, trimProfile.sectionCardLimits), + maxBodyChars: trimProfile.maxBodyChars, + })); + } + + parts.push(renderFooter(html)); + + if (publicUrl) { + parts.push( + `

✨ 一起创作 → 点击阅读原文

`, + ); + } + + return { + content: parts.join(''), + sectionCount: sections.length, + renderedSectionCount: sections.filter((section) => { + if (trimProfile.skipHistory && shouldSkipSection(section, trimProfile)) return false; + if (resolveSectionCardLimit(section, trimProfile.sectionCardLimits) === 0) return false; + return true; + }).length, + }; +} + +function truncatePlainText(value, maxChars) { + const text = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (!maxChars || text.length <= maxChars) return text; + return `${text.slice(0, Math.max(0, maxChars - 1))}…`; +} + +function shouldSkipSection(section, trimProfile) { + if (!section) return true; + if (['history', 'knowledge'].includes(section.id)) return true; + if (trimProfile?.skipSectionIds?.includes(section.id)) return true; + return /历史上的今天/u.test(section.title); +} + +function renderGenericSection(sectionHtml, { maxCards = null, maxBodyChars = null } = {}) { + const title = extractInnerHtml(sectionHtml, /
]*>([\s\S]*?)<\/div>/i); + const cards = sectionHtml.split(/
]*>([\s\S]*?)<\/p>/i, (match, body) => { + const plain = body.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); + return match.replace(body, truncatePlainText(plain, maxBodyChars)); + }); + } + const rendered = renderCard(`