feat(wechat): refine daily news draft layout and follow QR block

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-10 09:40:51 +08:00
parent 84b38f397e
commit eaba8542a8
4 changed files with 181 additions and 17 deletions
+116 -15
View File
@@ -2,6 +2,7 @@ import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
const MAX_WECHAT_CONTENT_CHARS = 20000;
const MAX_WECHAT_CONTENT_TARGET_CHARS = 19600;
const DEFAULT_PORTAL_URL = 'https://m.tkmind.cn';
const CARD_VARIANTS = {
'highlight-box': {
@@ -128,7 +129,7 @@ function renderHero(html) {
const dateBadge = sanitizeInlineHtml(String(html).match(/<div class="date-badge"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
const subtitle = sanitizeInlineHtml(String(html).match(/<div class="subtitle"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
return [
'<section style="text-align:center;padding:24px 14px;background:linear-gradient(135deg,#b71c1c,#d32f2f 50%,#0d47a1);color:#fff;margin:0 0 14px;border-radius:12px;">',
'<section style="text-align:center;padding:24px 14px;background:linear-gradient(135deg,#b71c1c,#d32f2f 50%,#0d47a1);color:#fff;margin:0 0 12px;border-radius:12px;">',
`<p style="margin:0 0 10px;font-size:24px;font-weight:700;line-height:1.35;">${title}</p>`,
dateBadge ? `<p style="margin:0 0 8px;font-size:14px;opacity:.92;">${dateBadge}</p>` : '',
subtitle ? `<p style="margin:0;font-size:14px;opacity:.88;line-height:1.7;">${subtitle}</p>` : '',
@@ -136,6 +137,39 @@ function renderHero(html) {
].filter(Boolean).join('');
}
function renderReadOriginalTop(publicUrl) {
if (!publicUrl) return '';
return [
'<section style="margin:0 0 16px;text-align:center;">',
`<a href="${publicUrl}" style="display:inline-block;padding:11px 22px;background:linear-gradient(135deg,#b71c1c,#d32f2f);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:999px;letter-spacing:.02em;box-shadow:0 3px 10px rgba(183,28,28,.22);">📖 点击阅读原文</a>`,
'<p style="margin:8px 0 0;font-size:12px;color:#a0aec0;line-height:1.6;">查看完整排版、图片与更多栏目</p>',
'</section>',
].join('');
}
function renderCreatePortalCTA(portalUrl = DEFAULT_PORTAL_URL) {
return `<p style="margin:18px 0 0;font-size:14px;color:#718096;text-align:center;line-height:1.7;">✨ 一起创作 → <a href="${portalUrl}" style="color:#e53935;font-weight:600;text-decoration:underline;">点我</a></p>`;
}
function renderFollowMpSection(qrcodeImageUrl = '') {
if (qrcodeImageUrl) {
return [
'<section style="text-align:center;padding:22px 16px 18px;margin:18px 0 8px;background:linear-gradient(180deg,#fff8f8,#fff);border:1px solid #ffcdd2;border-radius:12px;">',
'<p style="margin:0 0 6px;font-size:16px;font-weight:700;color:#b71c1c;line-height:1.5;">📣 关注 TKMind 服务号</p>',
'<p style="margin:0 0 14px;font-size:13px;color:#666;line-height:1.7;">长按扫描关注,每日早报不错过</p>',
`<img src="${qrcodeImageUrl}" alt="TKMind 服务号二维码" style="width:168px;height:168px;display:block;margin:0 auto;border-radius:10px;border:1px solid #eee;" />`,
'<p style="margin:12px 0 0;font-size:12px;color:#a0aec0;">长按扫描关注</p>',
'</section>',
].join('');
}
return [
'<section style="text-align:center;padding:20px 16px;margin:18px 0 8px;background:#fafafa;border:1px dashed #e0e0e0;border-radius:12px;">',
'<p style="margin:0 0 6px;font-size:15px;font-weight:600;color:#333;">📣 关注 TKMind 服务号</p>',
'<p style="margin:0;font-size:13px;color:#888;line-height:1.7;">推送草稿时将嵌入公众号二维码</p>',
'</section>',
].join('');
}
function renderWeatherSection(sectionHtml) {
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
const summary = extractInnerHtml(sectionHtml, /<div class="weather-today"[^>]*>([\s\S]*?)<\/div>/i);
@@ -253,9 +287,23 @@ function resolveSectionCardLimit(section, sectionCardLimits) {
return sectionCardLimits[section.id] ?? 3;
}
function buildWechatInlineContent(html, { publicUrl = '', trimProfile = TRIM_PROFILES[0] } = {}) {
function estimateFixedLayoutLength({ publicUrl = '', qrcodeImageUrl = '', portalUrl = DEFAULT_PORTAL_URL } = {}) {
return (
(publicUrl ? renderReadOriginalTop(publicUrl).length : 0)
+ renderFollowMpSection(qrcodeImageUrl).length
+ renderCreatePortalCTA(portalUrl).length
);
}
function buildWechatInlineContent(html, {
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = DEFAULT_PORTAL_URL,
trimProfile = TRIM_PROFILES[0],
includeFixedLayout = true,
} = {}) {
const sections = splitSections(html);
const parts = [renderHero(html)];
const bodyParts = [renderHero(html)];
for (const section of sections) {
if (trimProfile.skipHistory && shouldSkipSection(section, trimProfile)) {
@@ -266,26 +314,27 @@ function buildWechatInlineContent(html, { publicUrl = '', trimProfile = TRIM_PRO
}
if (section.id === 'weather') {
if (!trimProfile.skipWeather) {
parts.push(renderWeatherSection(section.html));
bodyParts.push(renderWeatherSection(section.html));
}
continue;
}
parts.push(renderGenericSection(section.html, {
bodyParts.push(renderGenericSection(section.html, {
maxCards: resolveSectionCardLimit(section, trimProfile.sectionCardLimits),
maxBodyChars: trimProfile.maxBodyChars,
}));
}
parts.push(renderFooter(html));
bodyParts.push(renderFooter(html));
if (publicUrl) {
parts.push(
`<p style="margin:16px 0 0;font-size:13px;color:#718096;text-align:center;line-height:1.7;">✨ 一起创作 → <a href="${publicUrl}" style="color:#e53935;text-decoration:underline;">点击阅读原文</a></p>`,
);
const ordered = [...bodyParts];
if (includeFixedLayout) {
if (publicUrl) ordered.splice(1, 0, renderReadOriginalTop(publicUrl));
ordered.push(renderFollowMpSection(qrcodeImageUrl));
ordered.push(renderCreatePortalCTA(portalUrl));
}
return {
content: parts.join(''),
content: ordered.join(''),
sectionCount: sections.length,
renderedSectionCount: sections.filter((section) => {
if (trimProfile.skipHistory && shouldSkipSection(section, trimProfile)) return false;
@@ -305,11 +354,12 @@ function shouldSkipSection(section, trimProfile) {
if (!section) return true;
if (['history', 'knowledge'].includes(section.id)) return true;
if (trimProfile?.skipSectionIds?.includes(section.id)) return true;
return /历史上的今天/u.test(section.title);
return /历史上的今天|近7天新闻早报/u.test(section.title);
}
function renderGenericSection(sectionHtml, { maxCards = null, maxBodyChars = null } = {}) {
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
if (/近7天新闻早报/u.test(stripInlineText(title))) return '';
const cards = sectionHtml.split(/<div class="card(?:\s|")/i).slice(1);
const parts = [renderSectionTitle(title)];
const limit = maxCards == null ? cards.length : Math.min(maxCards, cards.length);
@@ -342,7 +392,11 @@ function renderFooter(html) {
].join('');
}
export function convertDailyNewsHtmlToWechatInlineArticle(html, { publicUrl = '' } = {}) {
export function convertDailyNewsHtmlToWechatInlineArticle(html, {
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = DEFAULT_PORTAL_URL,
} = {}) {
const title = (extractPageTitle(html) || '每日新闻早报').slice(0, 32);
const digest = (extractMetaDescription(html) || title).slice(0, 120);
let built = null;
@@ -350,15 +404,62 @@ export function convertDailyNewsHtmlToWechatInlineArticle(html, { publicUrl = ''
let usedFallback = false;
const allProfiles = [...TRIM_PROFILES, ...buildFallbackTrimProfiles()];
const fixedLayoutLength = estimateFixedLayoutLength({ publicUrl, qrcodeImageUrl, portalUrl });
const trimTargetChars = Math.max(12000, MAX_WECHAT_CONTENT_CHARS - fixedLayoutLength - 600);
for (const [index, trimProfile] of allProfiles.entries()) {
built = buildWechatInlineContent(html, { publicUrl, trimProfile });
built = buildWechatInlineContent(html, {
publicUrl,
qrcodeImageUrl,
portalUrl,
trimProfile,
includeFixedLayout: false,
});
trimLevel = index;
usedFallback = index >= TRIM_PROFILES.length;
if (built.content.length <= MAX_WECHAT_CONTENT_TARGET_CHARS) {
if (built.content.length <= trimTargetChars) {
break;
}
}
let selectedProfile = allProfiles[trimLevel] ?? allProfiles[allProfiles.length - 1];
if (built) {
built = buildWechatInlineContent(html, {
publicUrl,
qrcodeImageUrl,
portalUrl,
trimProfile: selectedProfile,
includeFixedLayout: true,
});
while (built.content.length > MAX_WECHAT_CONTENT_CHARS && (selectedProfile.maxBodyChars ?? 120) > 20) {
selectedProfile = {
...selectedProfile,
skipWeather: true,
maxBodyChars: Math.max(20, (selectedProfile.maxBodyChars ?? 64) - 8),
sectionCardLimits: {
...(selectedProfile.sectionCardLimits ?? {}),
headlines: Math.max(2, selectedProfile.sectionCardLimits?.headlines ?? 3),
world: Math.min(2, selectedProfile.sectionCardLimits?.world ?? 2),
domestic: Math.min(2, selectedProfile.sectionCardLimits?.domestic ?? 2),
google: 1,
finance: 2,
tech: 2,
sports: 1,
technews: 0,
hot: 0,
},
};
built = buildWechatInlineContent(html, {
publicUrl,
qrcodeImageUrl,
portalUrl,
trimProfile: selectedProfile,
includeFixedLayout: true,
});
usedFallback = true;
}
}
if (!built || built.content.length > MAX_WECHAT_CONTENT_CHARS) {
throw new Error(
`微信正文超过 ${MAX_WECHAT_CONTENT_CHARS} 字符(当前 ${built?.content.length ?? 0}),请缩短 HTML 页面后重试`,
+62 -2
View File
@@ -17,6 +17,7 @@ const RUNS_TABLE = 'h5_wechat_news_draft_runs';
const DEFAULT_WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
const DEFAULT_DRAFT_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/draft/add';
const DEFAULT_MATERIAL_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/material/add_material';
const DEFAULT_UPLOAD_IMG_URL = 'https://api.weixin.qq.com/cgi-bin/media/uploadimg';
const MAX_THUMB_BYTES = 64 * 1024;
export const NEWS_MORNING_TEMPLATE_VERSION = '2026-09-10';
@@ -211,11 +212,46 @@ export function extractDailyNewsArticleMeta(html) {
return { title: title.slice(0, 64), digest };
}
export function resolveMpFollowQrcodePath(memindLibRoot = process.cwd()) {
const candidates = [
path.join(memindLibRoot, 'wechat/assets/mp-follow-qrcode.png'),
path.join(process.cwd(), 'wechat/assets/mp-follow-qrcode.png'),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
}
export async function uploadWechatArticleContentImage(
accessToken,
imageBuffer,
{ wechatFetch = undiciFetch, uploadUrl = DEFAULT_UPLOAD_IMG_URL, filename = 'content.png' } = {},
) {
if (!accessToken) throw new Error('缺少微信 access_token');
if (!Buffer.isBuffer(imageBuffer) || imageBuffer.length === 0) {
throw new Error('正文图片为空');
}
const form = new FormData();
form.append(
'media',
new Blob([imageBuffer], { type: 'image/png' }),
filename,
);
const endpoint = new URL(uploadUrl);
endpoint.searchParams.set('access_token', accessToken);
const payload = await readJsonResponse(
await wechatFetch(endpoint.toString(), { method: 'POST', body: form }),
);
if (Number(payload?.errcode ?? 0) !== 0 || !String(payload?.url ?? '').trim()) {
throw new Error(payload?.errmsg || '上传微信正文图片失败');
}
return String(payload.url);
}
export function buildDailyNewsWechatDraftArticle({
html,
publicUrl,
qrcodeImageUrl = '',
} = {}) {
const article = convertDailyNewsHtmlToWechatInlineArticle(html, { publicUrl });
const article = convertDailyNewsHtmlToWechatInlineArticle(html, { publicUrl, qrcodeImageUrl });
return {
...article,
cardCount: extractCardArticles(html).length,
@@ -223,6 +259,27 @@ export function buildDailyNewsWechatDraftArticle({
};
}
export async function buildDailyNewsWechatDraftArticleForPush({
html,
publicUrl,
accessToken = null,
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
} = {}) {
let qrcodeImageUrl = '';
if (accessToken) {
const qrcodePath = resolveMpFollowQrcodePath(memindLibRoot);
if (qrcodePath) {
qrcodeImageUrl = await uploadWechatArticleContentImage(
accessToken,
fs.readFileSync(qrcodePath),
{ wechatFetch, filename: 'mp-follow-qrcode.png' },
);
}
}
return buildDailyNewsWechatDraftArticle({ html, publicUrl, qrcodeImageUrl });
}
export function convertNewsPageHtmlToWechatArticle(html, { publicUrl = '' } = {}) {
const dailyNews = isDailyNewsFormat(html);
if (dailyNews) {
@@ -700,9 +757,12 @@ export function createWechatNewsMorningDraftService(
const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumbBuffer, { wechatFetch });
const html = fs.readFileSync(page.localPath, 'utf8');
const article = isDailyNewsFormat(html)
? buildDailyNewsWechatDraftArticle({
? await buildDailyNewsWechatDraftArticleForPush({
html,
publicUrl: preview.page.publicUrl,
accessToken,
wechatFetch,
memindLibRoot,
})
: preview.article;
const draft = await addWechatDraftArticle(
+3
View File
@@ -91,6 +91,9 @@ test('convertNewsPageHtmlToWechatArticle converts daily-news to inline html', ()
assert.match(article.content, /每日新闻早报/u);
assert.match(article.content, /示例热点/u);
assert.match(article.content, /点击阅读原文/u);
assert.match(article.content, /一起创作/u);
assert.match(article.content, /点我/u);
assert.doesNotMatch(article.content, /近7天新闻早报/u);
});
test('buildDailyNewsWechatHtmlContent preserves style and body markup', () => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB