Files
memind/wechat-daily-news-inline.mjs

1293 lines
48 KiB
JavaScript
Raw Permalink 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';
import { isWeakNewsMorningImageUrl } from './wechat-news-morning-images.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',
});
/** 紧凑 inline 样式,降低 HTML 开销以便在 2 万字内塞入更多资讯条。 */
const WX = Object.freeze({
hero: `background:linear-gradient(135deg,${BRAND.red},${BRAND.navy});border-radius:14px;padding:22px 16px 20px;text-align:center;color:#fff`,
heroTitle: 'margin:0;font-size:24px;font-weight:700;line-height:1.35',
heroMeta: `margin:8px 0 0;font-size:12px;color:${BRAND.onDark}`,
heroSub: `margin:12px 0 0;font-size:12px;line-height:1.8;color:${BRAND.onDark};text-align:left`,
digest: `margin:12px 0 0;background:${BRAND.tintRed};border-radius:10px;padding:12px 14px`,
digestLabel: `margin:0 0 6px;font-size:11px;font-weight:700;color:${BRAND.red}`,
digestBody: `margin:0;font-size:12px;color:${BRAND.body};line-height:1.85`,
section: `margin:20px 0 10px;padding-bottom:6px;border-bottom:1px solid ${BRAND.line};font-size:16px;font-weight:700;color:${BRAND.ink}`,
feature: 'margin:0 0 12px;border-radius:12px;padding:14px 14px 12px;color:#fff',
featureTag: `margin:0 0 6px;font-size:10px;color:${BRAND.onDarkSoft}`,
featureTitle: 'margin:0;font-size:15px;font-weight:700;line-height:1.5',
featureBody: `margin:8px 0 0;font-size:13px;line-height:1.75;color:${BRAND.onDark}`,
listWrap: 'margin:0 0 12px',
listTitle: `margin:0;font-size:14px;font-weight:700;color:${BRAND.ink};line-height:1.5`,
listBody: `margin:5px 0 0;font-size:13px;color:${BRAND.body};line-height:1.75`,
compactWrap: 'margin:0 0 12px',
compactThumb: 'display:inline-block;vertical-align:top;width:72px;margin-right:10px',
compactThumbImg: 'display:block;width:72px;height:54px;object-fit:cover;border-radius:8px',
compactBody: 'display:inline-block;vertical-align:top;width:calc(100% - 82px);max-width:calc(100% - 82px)',
compactTag: `margin:0 0 4px;font-size:10px;font-weight:700;color:${BRAND.red}`,
compactLeadTitle: `margin:0;font-size:15px;font-weight:700;color:${BRAND.ink};line-height:1.5`,
rankWrap: `margin:0 0 12px;background:${BRAND.tintRed};border-radius:10px;padding:12px 12px 8px`,
rankTitle: `margin:0 0 8px;font-size:14px;font-weight:700;color:${BRAND.ink};line-height:1.5`,
rankBody: `margin:0;font-size:13px;color:${BRAND.body};line-height:1.85`,
});
const SECTION_LABELS = {
headlines: 'TOP NEWS',
weather: 'WEATHER',
world: 'WORLD',
domestic: 'CHINA',
google: 'GLOBAL',
x: 'SOCIAL',
asia_pacific: 'ASIA',
commodities: 'ENERGY',
ai_chips: 'AI',
policy: 'POLICY',
universities: 'CAMPUS',
global_markets: 'MARKETS',
community: 'COMMUNITY',
science: 'SCIENCE',
culture: 'CULTURE',
startup: 'STARTUP',
crypto: 'CRYPTO',
geopolitics: 'GEOPOL',
local_life: 'CITIES',
official_feeds: 'WIRE',
finance: 'FINANCE',
tech: 'TECH',
technews: 'TECH',
sports: 'SPORTS',
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;
}
function extractHistoryItemBlocks(parentHtml) {
const source = String(parentHtml ?? '');
const blocks = [];
const re = /<(?:div|li)\s+class="history-item[^"]*"[^>]*>[\s\S]*?<\/(?:div|li)>/gi;
let match = re.exec(source);
while (match) {
blocks.push(match[0]);
match = re.exec(source);
}
return blocks;
}
function isNews002Html(html) {
const source = String(html ?? '');
if (/\bclass="[^"]*\bmasthead\b/u.test(source)) return true;
return /\bclass="[^"]*\blead-card\b/u.test(source) && !/\bclass="hero\b/u.test(source);
}
/** feature=渐变头条卡,rank=多行榜单卡,list=带序号的列表条目,lead=news002 头条小图卡。 */
function classifyCard(cardHtml, { news002Mode = false } = {}) {
if (news002Mode && /\blead-card\b/u.test(cardHtml)) return 'lead';
if (detectFeatureTheme(cardHtml, { news002Mode })) return 'feature';
const rawBody = extractInnerHtml(cardHtml, /<p[^>]*>([\s\S]*?)<\/p>/i);
return /<br\s*\/?>/i.test(rawBody) ? 'rank' : 'list';
}
function detectFeatureTheme(cardHtml, { news002Mode = false } = {}) {
// news002 草稿与静态页统一为左上角小图,不走 news001 渐变头条卡。
if (news002Mode && /\blead-card\b/u.test(cardHtml)) return '';
// news002 用 .lead-card 取代 news001 的 .highlight-box 系列做头条卡。
if (/\blead-card\b/u.test(cardHtml)) return FEATURE_CARD_THEMES['highlight-box'];
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="${WX.rankBody}">${lines}</p>`;
}
function renderFeatureCard(cardHtml, { theme, title, body, imageUrl = '' }) {
const tag = extractTagLabel(cardHtml);
return [
`<section style="${WX.feature};background:${theme};overflow:hidden">`,
imageUrl
? `<img src="${imageUrl}" alt="${stripInlineText(title)}" style="display:block;width:calc(100% + 28px);max-width:none;height:auto;margin:-14px -14px 12px" />`
: '',
tag ? `<p style="${WX.featureTag}">${tag}</p>` : '',
`<p style="${WX.featureTitle}"><strong>${title}</strong></p>`,
body ? `<p style="${WX.featureBody}">${body}</p>` : '',
'</section>',
].filter(Boolean).join('');
}
function renderListCard({ title, body, index, imageUrl = '' }) {
const thumb = imageUrl
? `<img src="${imageUrl}" alt="${stripInlineText(title)}" style="flex-shrink:0;width:92px;height:69px;object-fit:cover;border-radius:8px;display:block" />`
: '';
const textBlock = [
`<p style="${WX.listTitle}"><span style="color:${BRAND.red}">${formatCardIndex(index)}</span> <strong>${title}</strong></p>`,
body ? `<p style="${WX.listBody}">${body}</p>` : '',
].filter(Boolean).join('');
if (thumb) {
return [
`<section style="${WX.listWrap};display:flex;gap:10px;align-items:flex-start">`,
thumb,
`<div style="flex:1;min-width:0">${textBlock}</div>`,
'</section>',
].join('');
}
return [
`<section style="${WX.listWrap}">`,
textBlock,
'</section>',
].join('');
}
/** news002:左侧 72×54 缩略图 + 右侧标题正文,与静态页 .card/.lead-card 一致。 */
function renderNews002CompactCard({
title,
body,
tag = '',
index = 0,
imageUrl = '',
isLead = false,
}) {
const thumbBlock = imageUrl
? [
`<span style="${WX.compactThumb}">`,
`<img src="${imageUrl}" alt="${stripInlineText(title)}" style="${WX.compactThumbImg}" />`,
'</span>',
].join('')
: '';
const titleStyle = isLead ? WX.compactLeadTitle : WX.listTitle;
const titleHtml = isLead
? `<p style="${titleStyle}"><strong>${title}</strong></p>`
: `<p style="${titleStyle}"><span style="color:${BRAND.red}">${formatCardIndex(index)}</span> <strong>${title}</strong></p>`;
return [
`<section style="${WX.compactWrap}">`,
thumbBlock,
`<span style="${thumbBlock ? WX.compactBody : 'display:block'}">`,
tag ? `<p style="${WX.compactTag}">${tag}</p>` : '',
titleHtml,
body ? `<p style="${WX.listBody}">${body}</p>` : '',
'</span>',
'</section>',
].filter(Boolean).join('');
}
function renderCard(cardHtml, {
maxBodyChars = null,
index = 0,
imageUrlMap = new Map(),
news002Mode = false,
} = {}) {
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, { news002Mode });
const rankBody = theme ? '' : renderRankListBody(rawBody);
if (rankBody) {
return [
`<section style="${WX.rankWrap}">`,
`<p style="${WX.rankTitle}"><strong>${stripInlineText(title)}</strong></p>`,
rankBody,
'</section>',
].join('');
}
const body = maxBodyChars
? truncatePlainText(rawBody.replace(/<[^>]+>/g, ''), maxBodyChars)
: rawBody;
const imageUrl = extractMappedCardImage(cardHtml, imageUrlMap);
if (news002Mode) {
return renderNews002CompactCard({
title,
body,
tag: extractTagLabel(cardHtml),
index,
imageUrl,
isLead: /\blead-card\b/u.test(cardHtml),
});
}
if (theme) {
return renderFeatureCard(cardHtml, {
theme,
title,
body,
imageUrl,
});
}
return renderListCard({
title,
body,
index,
imageUrl,
});
}
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="${WX.section}"><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 extractMappedCardImage(cardHtml, imageUrlMap = new Map()) {
const src = String(cardHtml ?? '').match(
/<div\b[^>]*class="[^"]*\bmedia\b[^"]*"[^>]*>[\s\S]*?<img\b[^>]*src=["']([^"']+)["']/i,
)?.[1] ?? '';
const mapped = resolveMappedImageUrl(src, imageUrlMap);
return mapped && !isWeakNewsMorningImageUrl(mapped) && !isWeakNewsMorningImageUrl(src) ? mapped : '';
}
function extractHeroSubtitle(html) {
// news001 用 .subtitle,news002 用 .lede。
const source = String(html);
return stripInlineText(
source.match(/<div class="subtitle"[^>]*>([\s\S]*?)<\/div>/i)?.[1]
?? source.match(/<div class="lede"[^>]*>([\s\S]*?)<\/div>/i)?.[1]
?? '',
);
}
function extractHeroDateLine(html) {
// news001 用 .date-badge,news002 用 .masthead .meta。
const source = String(html);
return stripInlineText(
source.match(/<div class="date-badge"[^>]*>([\s\S]*?)<\/div>/i)?.[1]
?? source.match(/<div class="meta"[^>]*>([\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="${WX.digest}">`,
`<p style="${WX.digestLabel}">今日导读</p>`,
`<p style="${WX.digestBody}">${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 = extractHeroDateLine(html);
const subtitle = includeSubtitle ? truncatePlainText(extractHeroSubtitle(html), 150) : '';
return [
`<section style="${WX.hero}">`,
`<p style="${WX.heroTitle}"><strong>${title.replace(/^[📰\s]+/u, '')}</strong></p>`,
dateBadge ? `<p style="${WX.heroMeta}">${dateBadge}</p>` : '',
subtitle ? `<p style="${WX.heroSub}">${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) {
const fromLegacy = normalizeWeatherCityLabel(
extractInnerHtml(cardHtml, /<span class="w-city">([\s\S]*?)<\/span>/i),
);
if (fromLegacy) return fromLegacy;
return normalizeWeatherCityLabel(
extractInnerHtml(cardHtml, /<div class="wc">([\s\S]*?)<\/div>/i),
);
}
function extractWeatherCondAndTemp(cardHtml) {
const cond = stripInlineText(extractInnerHtml(cardHtml, /<span class="w-cond">([\s\S]*?)<\/span>/i))
|| stripInlineText(extractInnerHtml(cardHtml, /<div class="we">([\s\S]*?)<\/div>/i));
const temp = stripInlineText(extractInnerHtml(cardHtml, /<span class="w-temp">([\s\S]*?)<\/span>/i))
|| stripInlineText(extractInnerHtml(cardHtml, /<div class="wt">([\s\S]*?)<\/div>/i));
return { cond, temp };
}
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 = extractWeatherCityName(cardHtml)
|| stripInlineText(extractInnerHtml(cardHtml, /<span class="w-city">([\s\S]*?)<\/span>/i));
const { cond, temp } = extractWeatherCondAndTemp(cardHtml);
if (!city) return '';
const detail = [cond, temp && temp.length <= 12 ? 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)
|| extractInnerHtml(todayBlock, /<div class="c">([\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))
|| stripInlineText(extractInnerHtml(todayBlock, /<div class="d">([\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',
}));
} else {
const summary = stripInlineText(extractInnerHtml(todayBlock ?? '', /<div class="d">([\s\S]*?)<\/div>/i));
if (summary) {
parts.push(
`<section style="margin:0 0 11px;background:${BRAND.tintBlue};border-left:3px solid ${BRAND.navy};border-radius:10px;padding:12px 13px">`,
`<p style="margin:0;font-size:13px;color:${BRAND.navySoft};line-height:1.8">${truncatePlainText(summary, maxDetailChars)}</p>`,
'</section>',
);
}
}
return parts.filter(Boolean).join('');
}
function renderHistorySection(sectionHtml, { maxItems = 3, maxBodyChars = 72 } = {}) {
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
const items = extractHistoryItemBlocks(sectionHtml)
.slice(0, maxItems)
.map((item) => {
const year = stripInlineText(extractInnerHtml(item, /<span class="hy"[^>]*>([\s\S]*?)<\/span>/i));
const event = truncatePlainText(
stripInlineText(extractInnerHtml(item, /<span class="he"[^>]*>([\s\S]*?)<\/span>/i)),
maxBodyChars,
);
if (!year || !event) return '';
return [
`<section style="margin:0 0 8px;background:${BRAND.tintRed};border-radius:10px;padding:11px 12px">`,
`<p style="margin:0;font-size:14px;color:${BRAND.ink};line-height:1.65"><strong style="color:${BRAND.red}">${year}</strong> · ${event}</p>`,
'</section>',
].join('');
})
.filter(Boolean);
if (items.length === 0) return '';
return [renderSectionTitle(title, 'history'), ...items].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)
.filter((label) => !/^(🔥|𝕏\s*)?热搜/u.test(label) && !/热门事件/u.test(label));
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(),
news002Mode = false,
} = {},
) {
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,
'',
);
// news001 用 div.card / div.highlight-box;news002 还会用 a.lead-card 做头条卡。
const tokens = body
.split(/(?=<figure class="section-figure"|<div class="(?:card|highlight-box)|<a class="lead-card)/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;
}
const isLeadCard = /^<a class="lead-card/i.test(token);
if (!isLeadCard && !/^<div class="(?:card|highlight-box)/i.test(token)) continue;
if (maxCards != null && cardCount >= maxCards) continue;
const cardChunk = token.replace(/^(?:<div class="(?:card|highlight-box)|<a class="lead-card)(?:\s|")/i, '');
const cardHtml = `<div class="card ${isLeadCard ? 'lead-card ' : ''}${cardChunk}`;
// 序号只给列表条目,头条卡与榜单卡靠自身样式区分,不占号。
const rendered = renderCard(cardHtml, {
maxBodyChars,
index: listIndex,
imageUrlMap,
news002Mode,
});
if (rendered) {
parts.push(rendered);
cardCount += 1;
if (classifyCard(cardHtml, { news002Mode }) === 'list') listIndex += 1;
}
}
if (cardCount === 0) return '';
return parts.join('');
}
const DEFAULT_SECTION_CARD_LIMITS = {
headlines: 7,
world: 5,
domestic: 5,
google: 3,
x: 3,
asia_pacific: 3,
commodities: 3,
ai_chips: 3,
policy: 3,
universities: 3,
global_markets: 3,
community: 3,
science: 3,
culture: 3,
startup: 3,
crypto: 2,
geopolitics: 3,
local_life: 3,
official_feeds: 3,
finance: 5,
tech: 5,
sports: 3,
technews: 2,
knowledge: 3,
};
const DEFAULT_CARD_BODY_CHARS = 132;
const MIN_CARDS_TO_KEEP = 16;
const BASE_TRIM_FLAGS = Object.freeze({
skipHistory: false,
skipKnowledge: false,
skipWeather: false,
skipQuickLinks: true,
skipPageFooter: true,
});
function mergeSectionCardLimits(fullCounts = {}, caps = DEFAULT_SECTION_CARD_LIMITS) {
const merged = { ...caps };
for (const [id, count] of Object.entries(fullCounts)) {
const cap = caps[id];
merged[id] = cap == null ? count : Math.min(count, cap);
}
return merged;
}
/** 每档栏目容量,从「较全量」到「精简」。 */
const CARD_LIMIT_TIERS = [
DEFAULT_SECTION_CARD_LIMITS,
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, knowledge: 3 },
{ headlines: 4, world: 3, domestic: 3, google: 1, finance: 2, tech: 2, sports: 2, technews: 1, knowledge: 2 },
];
/** 每档卡片摘要字数,标题信息已足够时优先压缩摘要来换取更多条目。 */
const BODY_CHAR_TIERS = [DEFAULT_CARD_BODY_CHARS, 120, 108, 96];
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.7,
culture: 1.65,
community: 1.62,
crypto: 1.6,
local_life: 1.58,
startup: 1.55,
science: 1.5,
sports: 1.45,
x: 1.38,
google: 1.35,
official_feeds: 1.33,
asia_pacific: 1.3,
commodities: 1.28,
universities: 1.25,
ai_chips: 1.2,
geopolitics: 1.15,
policy: 1.1,
global_markets: 1.08,
technews: 1.05,
tech: 1,
finance: 0.95,
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 === 'hot') return true;
if (section.id === 'history') return trimProfile?.skipHistory === true;
if (section.id === 'knowledge' && trimProfile?.skipKnowledge === true) return true;
if (trimProfile?.skipSectionIds?.includes(section.id)) return true;
if (/近7天新闻早报/u.test(section.title)) return true;
if (/历史上的今天/u.test(section.title) && trimProfile?.skipHistory !== false) return true;
return false;
}
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 12px` 开头(见 WX.listWrap / WX.feature / WX.rankWrap),
* 便于按整卡统计与裁剪。调整 margin 时必须同步这里。
*/
const INLINE_CARD_SECTION_RE = /<section style="margin:0 0 12px[^"]*">[\s\S]*?<strong>[\s\S]*?<\/section>/g;
const INLINE_SECTION_TITLE_PREFIX = '<p style="margin:20px 0 10px;padding-bottom:6px;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 news002Mode = isNews002Html(html);
const digest = renderTodayDigest(html);
const isNews002 = /class="[^"]*\bmasthead\b/i.test(String(html ?? ''));
const bodyParts = [renderHero(html, imageUrlMap, { includeSubtitle: !digest })];
if (digest) bodyParts.push(digest);
const contentParts = [];
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;
}
continue;
}
if (section.id === 'history') {
const rendered = renderHistorySection(section.html);
if (rendered) {
contentParts.push(rendered);
renderedSectionCount += 1;
}
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,
news002Mode,
});
if (rendered) {
contentParts.push(rendered);
renderedSectionCount += 1;
}
}
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 cappedCounts = mergeSectionCardLimits(fullCounts, DEFAULT_SECTION_CARD_LIMITS);
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 tierCaps = CARD_LIMIT_TIERS[tier] ?? DEFAULT_SECTION_CARD_LIMITS;
const sectionCardLimits = mergeSectionCardLimits(fullCounts, tierCaps ?? cappedCounts);
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,
x: 1,
asia_pacific: 1,
finance: 2,
tech: 2,
ai_chips: 1,
global_markets: 1,
sports: 1,
technews: 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(
8000,
MAX_WECHAT_CONTENT_CHARS - fixedLayoutLengthResolved - 120,
);
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 ?? '';
const maxBodyChars = includeFixedLayout
? Math.max(6000, MAX_WECHAT_CONTENT_CHARS - fixedLayoutLengthResolved)
: bodyBudget;
if (content.length > maxBodyChars) {
content = trimWechatInlineBodyByRemovingCards(content, maxBodyChars, {
minCards: MIN_CARDS_TO_KEEP,
});
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 = {
isNews002Html,
renderCard,
renderNews002CompactCard,
renderHero,
renderTodayDigest,
renderBriefStats,
renderWeatherSection,
renderHistorySection,
selectTouristWeatherCards,
isPopularTouristCityName,
renderKnowledgeSection,
renderQuickLinks,
renderGenericSection,
renderSectionFigure,
renderSectionTitle,
renderCreatePortalCTA,
buildWechatInlineContent,
splitSections,
trimWechatInlineBodyByRemovingCards,
countInlineCards,
TRIM_PROFILES,
MAX_WECHAT_CONTENT_CHARS,
MAX_WECHAT_CONTENT_TARGET_CHARS,
};