dbb16f0678
Co-authored-by: Cursor <cursoragent@cursor.com>
1293 lines
48 KiB
JavaScript
1293 lines
48 KiB
JavaScript
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,
|
||
};
|