Files
memind/wechat-daily-news-inline.mjs
T
john b8bcbcb764 feat(wechat): 早报草稿改用内联模板并去掉正文大封面
让定时任务按新版式推送草稿:热门旅游城市天气、无 Hero logo,并约束生成页必须写城市天气卡。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 15:20:44 +08:00

1000 lines
39 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
const MAX_WECHAT_CONTENT_CHARS = 20000;
const MAX_WECHAT_CONTENT_TARGET_CHARS = 19600;
const DEFAULT_PORTAL_URL = 'https://m.tkmind.cn';
const FOLLOW_QRCODE_SIZE_PX = 120;
/**
* 公众号正文只保留内联 style,微信会 strip <style> 块。
* flex 在部分微信版本会被降级,两列统一用 inline-block + vertical-align。
*/
const BRAND = Object.freeze({
red: '#c8102e',
redDeep: '#9b0c22',
navy: '#0d2b5c',
navyMid: '#1a4a8a',
navySoft: '#3a5f92',
ink: '#222',
body: '#555',
muted: '#999',
faint: '#ccc',
hair: '#e8e8e8',
line: '#eee',
tintRed: '#fff5f6',
tintBlue: '#f4f8ff',
onDark: '#f6e9eb',
onDarkSoft: '#e8c2c8',
});
const SECTION_LABELS = {
headlines: 'TOP NEWS',
weather: 'WEATHER',
world: 'WORLD',
domestic: 'CHINA',
google: 'GLOBAL',
finance: 'FINANCE',
tech: 'TECH',
technews: 'TECH',
sports: 'SPORTS',
hot: 'TRENDING',
knowledge: 'INSIGHT',
};
const FEATURE_CARD_THEMES = {
'highlight-box': `linear-gradient(135deg,${BRAND.red},${BRAND.redDeep})`,
'highlight-box-orange': `linear-gradient(135deg,${BRAND.red},${BRAND.redDeep} 70%,#7d0a1c)`,
'highlight-box-gold': `linear-gradient(135deg,${BRAND.navy},${BRAND.navyMid})`,
'highlight-box-blue': `linear-gradient(135deg,${BRAND.navy},${BRAND.navyMid})`,
'highlight-box-teal': `linear-gradient(135deg,${BRAND.navy},#123a6b)`,
};
function extractMetaDescription(html) {
const match = String(html ?? '').match(
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
);
return match?.[1]?.trim() ?? '';
}
function stripScripts(html) {
return String(html ?? '').replace(/<script[\s\S]*?<\/script>/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 extractBodyInner(html) {
return String(html ?? '').match(/<body[^>]*>([\s\S]*?)<\/body>/i)?.[1] ?? String(html ?? '');
}
function stripInlineText(value) {
return sanitizeInlineHtml(String(value ?? ''))
.replace(/<span class="icon"[^>]*>([\s\S]*?)<\/span>/gi, '$1')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
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 extractDivBlock(html, className) {
const source = String(html ?? '');
const openRe = new RegExp(`<div class="${className}"[^>]*>`, 'i');
const openMatch = openRe.exec(source);
if (!openMatch) return '';
let pos = openMatch.index + openMatch[0].length;
let depth = 1;
while (pos < source.length && depth > 0) {
const nextOpen = source.indexOf('<div', pos);
const nextClose = source.indexOf('</div>', pos);
if (nextClose === -1) break;
if (nextOpen !== -1 && nextOpen < nextClose) {
depth += 1;
pos = nextOpen + 4;
} else {
depth -= 1;
pos = nextClose + 6;
}
}
return source.slice(openMatch.index, pos);
}
function extractClassDivBlocks(parentHtml, classPrefix) {
const source = String(parentHtml ?? '');
const blocks = [];
const openRe = new RegExp(`<div class="${classPrefix}[^"]*"[^>]*>`, 'gi');
let openMatch = openRe.exec(source);
while (openMatch) {
let pos = openMatch.index + openMatch[0].length;
let depth = 1;
while (pos < source.length && depth > 0) {
const nextOpen = source.indexOf('<div', pos);
const nextClose = source.indexOf('</div>', pos);
if (nextClose === -1) break;
if (nextOpen !== -1 && nextOpen < nextClose) {
depth += 1;
pos = nextOpen + 4;
} else {
depth -= 1;
pos = nextClose + 6;
}
}
blocks.push(source.slice(openMatch.index, pos));
openMatch = openRe.exec(source);
}
return blocks;
}
/** feature=渐变头条卡,rank=多行榜单卡,list=带序号的列表条目。 */
function classifyCard(cardHtml) {
if (detectFeatureTheme(cardHtml)) return 'feature';
const rawBody = extractInnerHtml(cardHtml, /<p[^>]*>([\s\S]*?)<\/p>/i);
return /<br\s*\/?>/i.test(rawBody) ? 'rank' : 'list';
}
function detectFeatureTheme(cardHtml) {
if (/highlight-box-orange/u.test(cardHtml)) return FEATURE_CARD_THEMES['highlight-box-orange'];
if (/highlight-box-gold/u.test(cardHtml)) return FEATURE_CARD_THEMES['highlight-box-gold'];
if (/highlight-box-blue/u.test(cardHtml)) return FEATURE_CARD_THEMES['highlight-box-blue'];
if (/highlight-box-teal/u.test(cardHtml)) return FEATURE_CARD_THEMES['highlight-box-teal'];
if (/highlight-box/u.test(cardHtml)) return FEATURE_CARD_THEMES['highlight-box'];
return '';
}
function extractTagLabel(cardHtml) {
const match = String(cardHtml).match(/<span class="tag[^"]*"[^>]*>([\s\S]*?)<\/span>/i);
return match ? stripInlineText(match[1]) : '';
}
function extractSourceLabel(sourceRaw, maxChars = 34) {
const plain = String(sourceRaw ?? '')
.replace(/<a[^>]*>([\s\S]*?)<\/a>/gi, '$1')
.replace(/<[^>]+>/g, '')
.replace(/^\s*来源[::]?\s*/u, '')
.replace(/\s+/g, ' ')
.trim();
const firstSource = plain.split(/\s*·\s*/u)[0] ?? plain;
return truncatePlainText(firstSource, maxChars);
}
function formatCardIndex(index) {
return String(index + 1).padStart(2, '0');
}
/** 榜单型卡片(含 <br> 多行),保留逐行结构而不是压成一段。 */
function renderRankListBody(bodyHtml, { maxRows = 14 } = {}) {
if (!/<br\s*\/?>/i.test(String(bodyHtml))) return '';
const rows = String(bodyHtml)
.split(/<br\s*\/?>/i)
.map((row) => sanitizeInlineHtml(row).replace(/^[\s🔥•·-]+/u, '').trim())
.filter(Boolean)
.slice(0, maxRows);
if (rows.length < 3) return '';
// 整段用一个 <p> + <br> 分行,逐行独立 <p> 会多出几百字符的样式开销。
const lines = rows
.map((row, index) => {
const text = row.replace(/<\/?(?:span|em|i|b)[^>]*>/gi, '');
return `<span style="color:${BRAND.red};font-weight:700">${String(index + 1).padStart(2, '0')}</span> ${text}`;
})
.join('<br>');
return `<p style="margin:0;font-size:14px;color:${BRAND.body};line-height:1.95">${lines}</p>`;
}
function renderFeatureCard(cardHtml, { theme, title, body }) {
const tag = extractTagLabel(cardHtml);
return [
`<section style="margin:0 0 14px;background:${theme};border-radius:14px;padding:17px 16px;color:#fff">`,
tag ? `<p style="margin:0 0 9px;font-size:11px;color:${BRAND.onDarkSoft}">${tag}</p>` : '',
`<p style="margin:0;font-size:17px;font-weight:700;line-height:1.55"><strong>${title}</strong></p>`,
body ? `<p style="margin:9px 0 0;font-size:14px;line-height:1.85;color:${BRAND.onDark}">${body}</p>` : '',
'</section>',
].filter(Boolean).join('');
}
function renderListCard({ title, body, index }) {
return [
'<section style="margin:0 0 16px">',
`<p style="margin:0;font-size:16px;font-weight:700;color:${BRAND.ink};line-height:1.6"><span style="color:${BRAND.red}">${formatCardIndex(index)}</span> <strong>${title}</strong></p>`,
body ? `<p style="margin:7px 0 0;font-size:14px;color:${BRAND.body};line-height:1.85">${body}</p>` : '',
'</section>',
].filter(Boolean).join('');
}
function renderCard(cardHtml, {
maxBodyChars = null,
index = 0,
} = {}) {
const title = extractInnerHtml(cardHtml, /<h3[^>]*>([\s\S]*?)<\/h3>/i);
if (!title) return '';
const rawBody = extractInnerHtml(cardHtml, /<p[^>]*>([\s\S]*?)<\/p>/i);
const theme = detectFeatureTheme(cardHtml);
const rankBody = theme ? '' : renderRankListBody(rawBody);
if (rankBody) {
return [
`<section style="margin:0 0 15px;background:${BRAND.tintRed};border-radius:12px;padding:15px 15px 9px">`,
`<p style="margin:0 0 12px;font-size:16px;font-weight:700;color:${BRAND.ink};line-height:1.55"><strong>${stripInlineText(title)}</strong></p>`,
rankBody,
'</section>',
].join('');
}
const body = maxBodyChars
? truncatePlainText(rawBody.replace(/<[^>]+>/g, ''), maxBodyChars)
: rawBody;
if (theme) {
return renderFeatureCard(cardHtml, { theme, title, body });
}
return renderListCard({ title, body, index });
}
function renderSectionTitle(titleHtml, sectionId = '') {
const text = stripInlineText(titleHtml);
if (!text) return '';
const label = SECTION_LABELS[sectionId] ?? '';
const suffix = label
? `<span style="font-size:11px;color:${BRAND.faint};font-weight:400"> ${label}</span>`
: '';
return `<p style="margin:26px 0 14px;padding-bottom:8px;border-bottom:1px solid ${BRAND.line};font-size:18px;font-weight:700;color:${BRAND.ink}"><span style="color:${BRAND.red}">▎</span>${text}${suffix}</p>`;
}
function resolveMappedImageUrl(src, imageUrlMap = new Map()) {
const ref = String(src ?? '').trim();
if (!ref) return '';
return imageUrlMap.get(ref) ?? imageUrlMap.get(ref.replace(/^\.\//, '')) ?? '';
}
function extractHeroSubtitle(html) {
return stripInlineText(String(html).match(/<div class="subtitle"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
}
/**
* Hero 里的摘要通常是「要点·要点·要点」的长串,拆成导读列表比整段文字好读得多。
* 拆不出两条以上时返回空,由 Hero 继续以整段展示。
*/
function renderTodayDigest(html, { maxItems = 6, maxItemChars = 30 } = {}) {
const raw = extractHeroSubtitle(html);
if (!raw) return '';
const items = raw
.split(/\s*[·|、]\s*/u)
.map((item) => item.trim())
.filter((item) => item.length >= 4)
.slice(0, maxItems)
.map((item) => truncatePlainText(item, maxItemChars));
if (items.length < 2) return '';
const lines = items.map((item) => `<span style="color:${BRAND.red}">·</span> ${item}`).join('<br>');
return [
`<section style="margin:15px 0 2px;background:${BRAND.tintRed};border-radius:12px;padding:14px 15px">`,
`<p style="margin:0 0 9px;font-size:12px;font-weight:700;color:${BRAND.red};letter-spacing:1px">今日导读</p>`,
`<p style="margin:0;font-size:13px;color:${BRAND.body};line-height:1.95">${lines}</p>`,
'</section>',
].join('');
}
function renderHero(html, _imageUrlMap = new Map(), { includeSubtitle = true } = {}) {
const title = stripInlineText(String(html).match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1] ?? '')
|| '每日新闻早报';
const dateBadge = stripInlineText(String(html).match(/<div class="date-badge"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
const subtitle = includeSubtitle ? truncatePlainText(extractHeroSubtitle(html), 150) : '';
return [
`<section style="background:linear-gradient(135deg,${BRAND.red},${BRAND.redDeep} 55%,${BRAND.navy});border-radius:16px;padding:30px 20px 26px;text-align:center;color:#fff">`,
`<p style="margin:0;font-size:27px;font-weight:700;line-height:1.3"><strong>${title.replace(/^[📰\s]+/u, '')}</strong></p>`,
dateBadge ? `<p style="margin:11px 0 0;font-size:13px;color:${BRAND.onDark}">${dateBadge}</p>` : '',
subtitle ? `<p style="margin:16px 0 0;font-size:13px;line-height:1.85;color:${BRAND.onDark};text-align:left">${subtitle}</p>` : '',
'</section>',
].filter(Boolean).join('');
}
function renderBriefStats(entries = []) {
const items = entries.filter((item) => item && item.value);
if (items.length === 0) return '';
const width = Math.floor(100 / items.length) - 1;
const cells = items.map((item) => [
`<section style="display:inline-block;width:${width}%;vertical-align:top">`,
`<p style="margin:0;font-size:21px;font-weight:700;color:${BRAND.red}"><strong>${item.value}</strong></p>`,
`<p style="margin:4px 0 0;font-size:11px;color:${BRAND.muted}">${item.label}</p>`,
'</section>',
].join('')).join('');
return `<section style="margin:14px 0 2px;padding:14px 0;border-top:1px solid ${BRAND.line};border-bottom:1px solid ${BRAND.line};text-align:center;font-size:0">${cells}</section>`;
}
const WEATHER_MAX_CITY_CARDS = 6;
/** 热门旅游城市优先序;微信天气区只展示这些城市的 weather-card。 */
const POPULAR_TOURIST_CITIES = Object.freeze([
'北京', '上海', '广州', '深圳', '成都', '杭州', '西安', '重庆', '武汉', '南京',
'厦门', '三亚', '青岛', '大连', '苏州', '丽江', '桂林', '福州', '海口', '长沙',
'昆明', '哈尔滨', '沈阳', '郑州', '天津', '宁波', '无锡', '珠海', '拉萨', '乌鲁木齐',
'泉州', '南昌', '济南', '合肥', '南宁', '贵阳', '兰州', '西宁', '银川', '呼和浩特',
'漳州', '梅州', '东莞', '佛山', '温州', '绍兴', '黄山', '张家界', '敦煌', '秦皇岛',
]);
const POPULAR_TOURIST_CITY_INDEX = new Map(
POPULAR_TOURIST_CITIES.map((city, index) => [city, index]),
);
const REGION_WEATHER_NAME_RE = /(西部|东部|南部|北部|大部|地区|区域|平原|流域|棉区|秋收|华南|华北|江汉|西南|江南|黄淮|西北|东北|概况)/u;
function normalizeWeatherCityLabel(raw) {
return stripInlineText(String(raw ?? ''))
.replace(/^📍\s*/u, '')
.split(/[·•]/u)[0]
.replace(/市$/u, '')
.trim();
}
function isPopularTouristCityName(raw) {
const city = normalizeWeatherCityLabel(raw);
if (!city || city.length > 6 || REGION_WEATHER_NAME_RE.test(city)) return false;
return POPULAR_TOURIST_CITY_INDEX.has(city);
}
function extractWeatherCityName(cardHtml) {
return normalizeWeatherCityLabel(
extractInnerHtml(cardHtml, /<span class="w-city">([\s\S]*?)<\/span>/i),
);
}
function selectTouristWeatherCards(cards = []) {
return cards
.filter((card) => isPopularTouristCityName(extractWeatherCityName(card)))
.sort((left, right) => {
const leftIndex = POPULAR_TOURIST_CITY_INDEX.get(extractWeatherCityName(left)) ?? 999;
const rightIndex = POPULAR_TOURIST_CITY_INDEX.get(extractWeatherCityName(right)) ?? 999;
return leftIndex - rightIndex;
})
.slice(0, WEATHER_MAX_CITY_CARDS);
}
function formatTouristWeatherSectionTitle(sourceTitleHtml) {
const text = stripInlineText(sourceTitleHtml);
const dateMatch = text.match(/(\d{1,2}月\d{1,2}日)/u);
return dateMatch ? `🌤️ 热门旅游城市天气 · ${dateMatch[1]}` : '🌤️ 热门旅游城市天气';
}
function renderWeatherCityCard(cardHtml) {
const city = stripInlineText(extractInnerHtml(cardHtml, /<span class="w-city">([\s\S]*?)<\/span>/i));
const cond = stripInlineText(extractInnerHtml(cardHtml, /<span class="w-cond">([\s\S]*?)<\/span>/i));
const temp = stripInlineText(extractInnerHtml(cardHtml, /<span class="w-temp">([\s\S]*?)<\/span>/i));
if (!city) return '';
const detail = [cond, temp && temp.length <= 8 ? temp : ''].filter(Boolean).join(' · ');
return `<p style="margin:0;font-size:13px;color:${BRAND.navyMid};line-height:1.55"><strong style="color:${BRAND.navy}">${city}</strong>${detail ? `<br>${detail}` : ''}</p>`;
}
/**
* 微信部分版本会降级 flex,两列统一用 inline-block。
* 外层格子只吃 50% 宽度(border-box + padding 做列间距),背景和内边距放在内层,
* 这样即使 box-sizing 被 strip 也不会因为 padding 撑破 50% 而掉到下一行。
*/
function renderTwoColumnGrid(cells, { background = '', padding = '' } = {}) {
if (cells.length === 0) return '';
const innerStyle = [
background ? `background:${background}` : '',
background ? 'border-radius:10px' : '',
padding ? `padding:${padding}` : '',
].filter(Boolean).join(';');
const inner = cells.map((cell, index) => {
const gutter = index % 2 === 0 ? 'padding:0 4px 8px 0' : 'padding:0 0 8px 4px';
const content = innerStyle ? `<section style="${innerStyle}">${cell}</section>` : cell;
return `<section style="display:inline-block;width:50%;box-sizing:border-box;vertical-align:top;${gutter}">${content}</section>`;
}).join('');
return `<section style="font-size:0;margin:0 0 6px">${inner}</section>`;
}
function renderWeatherSection(sectionHtml, { sectionId = 'weather', maxDetailChars = 96 } = {}) {
const sourceTitle = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
const title = formatTouristWeatherSectionTitle(sourceTitle);
const parts = [renderSectionTitle(title, sectionId)];
const todayBlock = extractDivBlock(sectionHtml, 'weather-today');
if (todayBlock) {
const cityRaw = extractInnerHtml(todayBlock, /<div class="wt-city">([\s\S]*?)<\/div>/i);
const cityName = normalizeWeatherCityLabel(cityRaw);
const cityLabel = stripInlineText(cityRaw).replace(/^📍\s*/u, '📍 ');
const detail = truncatePlainText(
stripInlineText(extractInnerHtml(todayBlock, /<div class="wt-detail">([\s\S]*?)<\/div>/i)),
maxDetailChars,
);
if (isPopularTouristCityName(cityName) && (cityLabel || detail)) {
parts.push(
`<section style="margin:0 0 11px;background:${BRAND.tintBlue};border-left:3px solid ${BRAND.navy};border-radius:10px;padding:12px 13px">`,
cityLabel ? `<p style="margin:0;font-size:15px;font-weight:700;color:${BRAND.navy};line-height:1.5">${cityLabel}</p>` : '',
detail ? `<p style="margin:6px 0 0;font-size:13px;color:${BRAND.navySoft};line-height:1.8">${detail}</p>` : '',
'</section>',
);
}
}
const gridBlock = extractDivBlock(sectionHtml, 'weather-grid');
const cityCells = gridBlock
? selectTouristWeatherCards(extractClassDivBlocks(gridBlock, 'weather-card'))
.map(renderWeatherCityCard)
.filter(Boolean)
: [];
if (cityCells.length > 0) {
parts.push(renderTwoColumnGrid(cityCells, {
background: BRAND.tintBlue,
padding: '10px 11px',
}));
}
return parts.filter(Boolean).join('');
}
function renderKnowledgeSection(sectionHtml, { maxItems = 4, maxBodyChars = 130 } = {}) {
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
const items = extractClassDivBlocks(sectionHtml, 'knowledge-item')
.slice(0, maxItems)
.map((item) => {
const icon = stripInlineText(extractInnerHtml(item, /<div class="k-icon"[^>]*>([\s\S]*?)<\/div>/i));
const heading = stripInlineText(extractInnerHtml(item, /<h4[^>]*>([\s\S]*?)<\/h4>/i));
const body = truncatePlainText(
stripInlineText(extractInnerHtml(item, /<p[^>]*>([\s\S]*?)<\/p>/i)),
maxBodyChars,
);
if (!heading) return '';
return [
`<section style="margin:0 0 10px;background:${BRAND.tintRed};border-radius:10px;padding:13px 14px">`,
`<p style="margin:0;font-size:15px;font-weight:700;color:${BRAND.ink};line-height:1.55"><strong>${icon ? `${icon} ` : ''}${heading}</strong></p>`,
body ? `<p style="margin:7px 0 0;font-size:13px;color:${BRAND.body};line-height:1.8">${body}</p>` : '',
'</section>',
].filter(Boolean).join('');
})
.filter(Boolean);
if (items.length === 0) return '';
return [renderSectionTitle(title, 'knowledge'), ...items].join('');
}
function renderSectionFigure(figureHtml, imageUrlMap = new Map()) {
const src = String(figureHtml).match(/<img[^>]+src=["']([^"']+)["']/i)?.[1] ?? '';
const mapped = resolveMappedImageUrl(src, imageUrlMap);
if (!mapped) return '';
const alt = stripInlineText(String(figureHtml).match(/\balt=["']([^"']*)["']/i)?.[1] ?? '');
const caption = stripInlineText(
extractInnerHtml(figureHtml, /<figcaption[^>]*>([\s\S]*?)<\/figcaption>/i),
);
// margin 刻意避开 INLINE_CARD_SECTION_RE 识别的卡片区间。
return [
'<section style="margin:0 0 18px;text-align:center">',
`<img src="${mapped}" alt="${alt}" style="width:100%;border-radius:10px;display:block" />`,
caption ? `<p style="margin:8px 0 0;font-size:12px;color:${BRAND.muted};line-height:1.6">${caption}</p>` : '',
'</section>',
].filter(Boolean).join('');
}
function renderQuickLinks(html) {
const block = extractDivBlock(extractBodyInner(html), 'quick-links');
if (!block) return '';
const labels = [...block.matchAll(/<a[^>]*>([\s\S]*?)<\/a>/gi)]
.map((item) => stripInlineText(item[1]))
.filter(Boolean);
if (labels.length === 0) return '';
const pills = labels
.map((label) => `<span style="display:inline-block;padding:5px 12px;margin:0 6px 7px 0;border:1px solid ${BRAND.hair};border-radius:20px;font-size:12px;color:${BRAND.body}">${label}</span>`)
.join('');
return `<section style="margin:14px 0 0;line-height:1.6">${pills}</section>`;
}
function splitSections(html) {
const body = extractBodyInner(html);
const sections = [];
const openRe = /<div class="section"([^>]*)>/gi;
let match = openRe.exec(body);
while (match) {
const start = match.index + match[0].length;
const attrs = match[1] ?? '';
const id = attrs.match(/\bid="([^"]+)"/i)?.[1] ?? '';
const nextBoundary = body.slice(start).search(
/<div class="section"|<div class="footer"|<div[^>]*data-mindspace-public-share/i,
);
const inner = nextBoundary === -1 ? body.slice(start) : body.slice(start, start + nextBoundary);
const htmlChunk = `<div class="section"${attrs}>${inner}`;
const title = stripInlineText(extractInnerHtml(htmlChunk, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i));
sections.push({ id, title, html: htmlChunk });
openRe.lastIndex = nextBoundary === -1 ? body.length : start + nextBoundary;
match = openRe.exec(body);
}
return sections;
}
function renderGenericSection(
sectionHtml,
{ sectionId = '', maxCards = null, maxBodyChars = null, imageUrlMap = new Map() } = {},
) {
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
if (/近7天新闻早报/u.test(stripInlineText(title))) return '';
const body = String(sectionHtml).replace(
/^[\s\S]*?<div class="section-title"[^>]*>[\s\S]*?<\/div>/i,
'',
);
const tokens = body.split(/(?=<figure class="section-figure"|<div class="(?:card|highlight-box))/i).filter(Boolean);
const parts = [renderSectionTitle(title, sectionId)];
let cardCount = 0;
let listIndex = 0;
for (const token of tokens) {
if (/^<figure class="section-figure"/i.test(token)) {
const rendered = renderSectionFigure(token, imageUrlMap);
if (rendered) parts.push(rendered);
continue;
}
if (!/^<div class="(?:card|highlight-box)/i.test(token)) continue;
if (maxCards != null && cardCount >= maxCards) continue;
const cardChunk = token.replace(/^<div class="(?:card|highlight-box)(?:\s|")/i, '');
const cardHtml = `<div class="card ${cardChunk}`;
// 序号只给列表条目,头条卡与榜单卡靠自身样式区分,不占号。
const rendered = renderCard(cardHtml, { maxBodyChars, index: listIndex });
if (rendered) {
parts.push(rendered);
cardCount += 1;
if (classifyCard(cardHtml) === 'list') listIndex += 1;
}
}
if (cardCount === 0) return '';
return parts.join('');
}
const DEFAULT_SECTION_CARD_LIMITS = {
headlines: 7,
world: 5,
domestic: 5,
google: 3,
finance: 5,
tech: 5,
sports: 3,
technews: 2,
hot: 3,
knowledge: 4,
};
const DEFAULT_CARD_BODY_CHARS = 150;
const MIN_CARDS_TO_KEEP = 12;
const BASE_TRIM_FLAGS = Object.freeze({
skipHistory: true,
skipKnowledge: false,
skipWeather: false,
skipQuickLinks: true,
skipPageFooter: true,
});
/** 每档栏目容量,从「全量」到「精简」。 */
const CARD_LIMIT_TIERS = [
null,
DEFAULT_SECTION_CARD_LIMITS,
{ ...DEFAULT_SECTION_CARD_LIMITS, headlines: 6, world: 4, domestic: 4, finance: 4, tech: 4, knowledge: 3 },
{ headlines: 5, world: 3, domestic: 3, google: 2, finance: 3, tech: 3, sports: 2, technews: 1, hot: 2, knowledge: 3 },
{ headlines: 4, world: 3, domestic: 3, google: 1, finance: 2, tech: 2, sports: 2, technews: 1, hot: 1, knowledge: 2 },
];
/** 每档卡片摘要字数,标题信息已足够时优先压缩摘要来换取更多条目。 */
const BODY_CHAR_TIERS = [DEFAULT_CARD_BODY_CHARS, 132, 118, 104];
const TRIM_PROFILES = CARD_LIMIT_TIERS.map((sectionCardLimits, index) => ({
...BASE_TRIM_FLAGS,
sectionCardLimits,
maxBodyChars: BODY_CHAR_TIERS[Math.min(index, BODY_CHAR_TIERS.length - 1)],
}));
/**
* 缩容权重:数值越大越先被砍。要闻权重最低(最后才动),
* 知识小卡片、体育、热搜等补充栏目先让位,保证各栏目都还有内容。
*/
const SECTION_SHRINK_WEIGHTS = {
knowledge: 1.6,
sports: 1.4,
hot: 1.35,
google: 1.3,
technews: 1.3,
tech: 1,
finance: 1,
world: 0.9,
domestic: 0.9,
headlines: 0.65,
};
const MIN_SECTION_CARDS = { headlines: 3, knowledge: 2 };
function countSectionCards(sections) {
const counts = {};
for (const section of sections) {
if (!section.id || section.id === 'weather' || section.id === 'history') continue;
if (/历史上的今天|近7天新闻早报/u.test(section.title)) continue;
const total = section.id === 'knowledge'
? extractClassDivBlocks(section.html, 'knowledge-item').length
: (section.html.match(/<div class="(?:card|highlight-box)/gi) ?? []).length;
if (total > 0) counts[section.id] = total;
}
return counts;
}
function pickShrinkTarget(limits) {
let bestId = null;
let bestScore = 0;
for (const [id, count] of Object.entries(limits)) {
const floor = MIN_SECTION_CARDS[id] ?? 1;
if (count <= floor) continue;
const score = count * (SECTION_SHRINK_WEIGHTS[id] ?? 1.1);
if (score > bestScore) {
bestId = id;
bestScore = score;
}
}
return bestId;
}
function resolveSectionCardLimit(section, sectionCardLimits) {
if (!sectionCardLimits) return null;
return sectionCardLimits[section.id] ?? 3;
}
function shouldSkipSection(section, trimProfile) {
if (!section) return true;
if (section.id === 'history') return true;
if (section.id === 'knowledge' && trimProfile?.skipKnowledge === true) return true;
if (trimProfile?.skipSectionIds?.includes(section.id)) return true;
return /历史上的今天|近7天新闻早报/u.test(section.title);
}
function renderPageFooter(html) {
const footer = String(html).match(/<div class="footer"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '';
if (!footer) return '';
const lines = [...footer.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)]
.map((item) => stripInlineText(item[1]))
.filter(Boolean)
.slice(0, 2);
if (lines.length === 0) return '';
return [
`<section style="margin:22px 0 0;padding:16px 0 0;border-top:1px solid ${BRAND.line};text-align:center">`,
...lines.map((line) => `<p style="margin:0 0 6px;font-size:12px;color:${BRAND.muted};line-height:1.7">${line}</p>`),
'</section>',
].join('');
}
/** 与 wechat-draft-article-layout.mjs 的同名函数保持一致,改动需同步两处。 */
export function renderReadOriginalLink(publicUrl) {
if (!publicUrl) return '';
return [
`<section style="text-align:center;margin:28px 0 14px;padding:24px 18px;background:${BRAND.tintBlue};border:1px dashed ${BRAND.navyMid};border-radius:16px">`,
`<p style="margin:0;font-size:15px;font-weight:700;color:${BRAND.navy};line-height:1.6">完整网页版更精彩</p>`,
`<p style="margin:8px 0 0;font-size:12px;color:${BRAND.navySoft};line-height:1.6">全部资讯 · 高清配图 · 来源直达 · 历史早报</p>`,
`<p style="margin:15px 0 0;font-size:19px;line-height:1;color:${BRAND.navyMid};letter-spacing:8px">⬇ ⬇ ⬇</p>`,
'<p style="margin:15px 0 0">',
`<a href="${publicUrl}" style="display:inline-block;padding:13px 36px;border-radius:999px;background:linear-gradient(135deg,${BRAND.navy},${BRAND.navyMid});color:#fff;font-size:16px;font-weight:700;text-decoration:none">📖 阅读原文</a>`,
'</p>',
`<p style="margin:14px 0 0;font-size:12px;color:${BRAND.navySoft};line-height:1.5">点击上方按钮,查看完整早报</p>`,
'</section>',
].join('');
}
export function renderCreatePortalCTA(portalUrl = DEFAULT_PORTAL_URL) {
return `<p style="margin:18px 0 0;font-size:13px;color:${BRAND.muted};text-align:center;line-height:1.7">✨ 一起创作 → <a href="${portalUrl}" style="color:${BRAND.red};font-weight:600;text-decoration:underline">点我</a></p>`;
}
/** 与 wechat-draft-article-layout.mjs 的关注区保持一致,改动需同步两处。 */
function renderFollowMpSection(qrcodeImageUrl = '') {
const qrcodeBlock = qrcodeImageUrl
? `<img src="${qrcodeImageUrl}" alt="TKMind 服务号二维码" style="width:${FOLLOW_QRCODE_SIZE_PX}px;height:${FOLLOW_QRCODE_SIZE_PX}px;display:block;margin:14px auto 0;border-radius:10px" />`
: '';
return [
`<section style="text-align:center;padding:24px 16px;margin:18px 0 8px;background:${BRAND.tintRed};border-radius:14px">`,
`<p style="margin:0;font-size:16px;font-weight:700;color:${BRAND.red};line-height:1.5">欢迎关注 TKMind 智趣</p>`,
`<p style="margin:8px 0 0;font-size:12px;color:${BRAND.muted};line-height:1.6">每日早报 · 智能创作 · 一键成页</p>`,
qrcodeBlock,
qrcodeImageUrl
? `<p style="margin:12px 0 0;font-size:12px;color:${BRAND.muted}">长按扫描关注</p>`
: `<p style="margin:12px 0 0;font-size:12px;color:${BRAND.muted}">关注 TKMind 服务号</p>`,
'</section>',
].filter(Boolean).join('');
}
/**
* 内容卡(头条卡 / 列表卡 / 榜单卡 / 知识卡)统一以 `margin:0 0 {10|14|15|16}px` 开头,
* 其余区块(Hero 12px、配图 18px、天气 11px/6px、速览、栏目标题、底部)刻意用别的起始值,
* 便于按整卡统计与裁剪。调整任何一处 margin 都必须同步这里。
*/
const INLINE_CARD_SECTION_RE = /<section style="margin:0 0 1[0456]px[^"]*">[\s\S]*?<strong>[\s\S]*?<\/section>/g;
const INLINE_SECTION_TITLE_PREFIX = '<p style="margin:26px 0 14px;padding-bottom:8px;border-bottom:1px solid';
function countInlineCards(body) {
return (String(body ?? '').match(INLINE_CARD_SECTION_RE) ?? []).length;
}
/**
* 超预算时按整张卡片从尾部删除,避免 clip 把最后一张卡截成半段。
* 一个栏目只剩一张卡时连栏目标题一起删掉,不留孤立标题。
*/
export function trimWechatInlineBodyByRemovingCards(body, maxLen, {
minCards = MIN_CARDS_TO_KEEP,
} = {}) {
let result = String(body ?? '');
if (!maxLen || result.length <= maxLen) return result;
while (result.length > maxLen) {
const matches = [...result.matchAll(INLINE_CARD_SECTION_RE)];
if (matches.length <= minCards) break;
const last = matches[matches.length - 1];
let start = last.index ?? 0;
const end = start + last[0].length;
const before = result.slice(0, start);
// 被删卡片紧跟在栏目标题后面时,该栏目已经空了,标题一起删掉。
const titleStart = before.lastIndexOf(INLINE_SECTION_TITLE_PREFIX);
if (titleStart >= 0) {
const titleEnd = before.indexOf('</p>', titleStart);
const between = titleEnd === -1 ? 'keep' : before.slice(titleEnd + 4).trim();
const cardsBeforeTitle = [...before.slice(0, titleStart).matchAll(INLINE_CARD_SECTION_RE)];
if (between === '' && cardsBeforeTitle.length > 0) {
start = titleStart;
}
}
result = `${result.slice(0, start)}${result.slice(end)}`.trimEnd();
}
return result;
}
function buildWechatInlineContent(html, {
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = DEFAULT_PORTAL_URL,
trimProfile = TRIM_PROFILES[0],
includeFixedLayout = true,
imageUrlMap = new Map(),
} = {}) {
const sections = splitSections(html);
const digest = renderTodayDigest(html);
const bodyParts = [renderHero(html, imageUrlMap, { includeSubtitle: !digest })];
if (digest) bodyParts.push(digest);
const contentParts = [];
let weatherCityCount = 0;
let renderedSectionCount = 0;
for (const section of sections) {
if (shouldSkipSection(section, trimProfile)) continue;
if (resolveSectionCardLimit(section, trimProfile.sectionCardLimits) === 0) continue;
if (section.id === 'weather') {
if (trimProfile.skipWeather) continue;
const rendered = renderWeatherSection(section.html, { sectionId: section.id });
if (rendered) {
contentParts.push(rendered);
renderedSectionCount += 1;
weatherCityCount = selectTouristWeatherCards(
extractClassDivBlocks(extractDivBlock(section.html, 'weather-grid'), 'weather-card'),
).length;
}
continue;
}
if (section.id === 'knowledge') {
const rendered = renderKnowledgeSection(section.html, {
maxItems: resolveSectionCardLimit(section, trimProfile.sectionCardLimits) ?? 4,
maxBodyChars: Math.min(trimProfile.maxBodyChars ?? 130, 130),
});
if (rendered) {
contentParts.push(rendered);
renderedSectionCount += 1;
}
continue;
}
const rendered = renderGenericSection(section.html, {
sectionId: section.id,
maxCards: resolveSectionCardLimit(section, trimProfile.sectionCardLimits),
maxBodyChars: trimProfile.maxBodyChars,
imageUrlMap,
});
if (rendered) {
contentParts.push(rendered);
renderedSectionCount += 1;
}
}
const newsCount = countInlineCards(contentParts.join(''));
bodyParts.push(renderBriefStats([
{ value: String(newsCount), label: '条资讯' },
{ value: String(renderedSectionCount), label: '个栏目' },
weatherCityCount > 0 ? { value: String(weatherCityCount), label: '地天气' } : null,
].filter(Boolean)));
if (!trimProfile.skipQuickLinks) {
bodyParts.push(renderQuickLinks(html));
}
bodyParts.push(...contentParts);
if (!trimProfile.skipPageFooter) {
bodyParts.push(renderPageFooter(html));
}
const ordered = bodyParts.filter(Boolean);
if (includeFixedLayout) {
if (publicUrl) ordered.push(renderReadOriginalLink(publicUrl));
ordered.push(renderFollowMpSection(qrcodeImageUrl));
ordered.push(renderCreatePortalCTA(portalUrl));
}
return {
content: ordered.join(''),
sectionCount: sections.length,
renderedSectionCount,
};
}
/**
* 从「全部栏目全部卡片」出发,按缩容权重逐条让位,直到正文压进预算。
* 相比固定档位,这样能精确填满预算并保持各栏目都有内容。
* 外层再扫几档摘要字数,取资讯条数最多(条数相同时摘要更长)的一版。
*/
function fitInlineContentToBudget(html, bodyBudget, {
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = DEFAULT_PORTAL_URL,
imageUrlMap = new Map(),
} = {}) {
const fullCounts = countSectionCards(splitSections(html));
const render = (trimProfile) => buildWechatInlineContent(html, {
publicUrl,
qrcodeImageUrl,
portalUrl,
trimProfile,
includeFixedLayout: false,
imageUrlMap,
});
let best = null;
for (const [tier, maxBodyChars] of BODY_CHAR_TIERS.entries()) {
const sectionCardLimits = { ...fullCounts };
let built = render({ ...BASE_TRIM_FLAGS, sectionCardLimits, maxBodyChars });
let guard = 0;
while (built.content.length > bodyBudget && guard < 80) {
guard += 1;
const target = pickShrinkTarget(sectionCardLimits);
if (!target) break;
sectionCardLimits[target] -= 1;
built = render({ ...BASE_TRIM_FLAGS, sectionCardLimits, maxBodyChars });
}
if (built.content.length > bodyBudget) continue;
const cards = countInlineCards(built.content);
const length = built.content.length;
if (!best || cards > best.cards || (cards === best.cards && length > best.length)) {
best = { built, cards, length, bodyCharTier: tier, sectionCardLimits, usedFallback: false };
}
}
if (best) return best;
const fallbackProfile = {
...BASE_TRIM_FLAGS,
skipKnowledge: true,
sectionCardLimits: { headlines: 3, world: 2, domestic: 2, google: 1, finance: 2, tech: 2, sports: 1, technews: 1, hot: 1, knowledge: 0 },
maxBodyChars: 80,
};
const built = render(fallbackProfile);
return {
built,
cards: countInlineCards(built.content),
length: built.content.length,
bodyCharTier: BODY_CHAR_TIERS.length,
sectionCardLimits: fallbackProfile.sectionCardLimits,
usedFallback: true,
};
}
function estimateQrcodeUrlForLayout(qrcodeImageUrl = '') {
const url = String(qrcodeImageUrl ?? '');
if (!url) return '';
if (url.length >= 180) return url;
return `${url}${'x'.repeat(180 - url.length)}`;
}
function estimateFixedLayoutLength({
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = DEFAULT_PORTAL_URL,
} = {}) {
const qrcodeForEstimate = estimateQrcodeUrlForLayout(qrcodeImageUrl);
return (
(publicUrl ? renderReadOriginalLink(publicUrl).length : 0)
+ renderFollowMpSection(qrcodeForEstimate).length
+ renderCreatePortalCTA(portalUrl).length
);
}
export function convertDailyNewsHtmlToWechatInlineArticle(html, {
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = DEFAULT_PORTAL_URL,
imageUrlMap = new Map(),
includeFixedLayout = true,
fixedLayoutLength = null,
} = {}) {
const title = (extractPageTitle(html) || '每日新闻早报').slice(0, 32);
const digest = (extractMetaDescription(html) || title).slice(0, 120);
const fixedLayoutLengthResolved = Number.isFinite(fixedLayoutLength)
? fixedLayoutLength
: estimateFixedLayoutLength({ publicUrl, qrcodeImageUrl, portalUrl });
const bodyBudget = Math.max(12000, MAX_WECHAT_CONTENT_TARGET_CHARS - fixedLayoutLengthResolved);
const fitted = fitInlineContentToBudget(html, bodyBudget, {
publicUrl,
qrcodeImageUrl,
portalUrl,
imageUrlMap,
});
const trimLevel = fitted.bodyCharTier;
const built = fitted.built;
let usedFallback = fitted.usedFallback;
let content = built?.content ?? '';
if (content.length > bodyBudget) {
content = trimWechatInlineBodyByRemovingCards(content, bodyBudget);
usedFallback = true;
}
const bodyLength = content.length;
if (includeFixedLayout) {
const ordered = [content];
if (publicUrl) ordered.push(renderReadOriginalLink(publicUrl));
ordered.push(renderFollowMpSection(qrcodeImageUrl));
ordered.push(renderCreatePortalCTA(portalUrl));
content = ordered.join('');
}
if (includeFixedLayout && content.length > MAX_WECHAT_CONTENT_CHARS) {
throw new Error(
`微信正文超过 ${MAX_WECHAT_CONTENT_CHARS} 字符(当前 ${content.length}),请缩短 HTML 页面后重试`,
);
}
return {
title,
digest,
content,
contentSourceUrl: publicUrl || undefined,
contentMode: 'inline_html',
contentLength: content.length,
bodyLength,
cardCount: countInlineCards(content),
sectionCount: built?.sectionCount ?? 0,
renderedSectionCount: built?.renderedSectionCount ?? 0,
trimLevel,
trimmed: trimLevel > 0 || usedFallback,
usedFallbackTrim: usedFallback,
};
}
export const wechatDailyNewsInlineInternals = {
renderCard,
renderHero,
renderTodayDigest,
renderBriefStats,
renderWeatherSection,
selectTouristWeatherCards,
isPopularTouristCityName,
renderKnowledgeSection,
renderQuickLinks,
renderSectionFigure,
renderSectionTitle,
renderCreatePortalCTA,
buildWechatInlineContent,
splitSections,
trimWechatInlineBodyByRemovingCards,
countInlineCards,
TRIM_PROFILES,
MAX_WECHAT_CONTENT_CHARS,
MAX_WECHAT_CONTENT_TARGET_CHARS,
};