b8bcbcb764
让定时任务按新版式推送草稿:热门旅游城市天气、无 Hero logo,并约束生成页必须写城市天气卡。 Co-authored-by: Cursor <cursoragent@cursor.com>
1000 lines
39 KiB
JavaScript
1000 lines
39 KiB
JavaScript
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,
|
||
};
|