Files
memind/mindspace-wechat-page-draft.mjs
T

1081 lines
38 KiB
JavaScript
Raw Normal View History

import fs from 'node:fs';
import path from 'node:path';
import { fetch as undiciFetch } from 'undici';
import sharp from 'sharp';
import {
buildPublicUrl,
PUBLISH_ROOT_DIR,
PUBLIC_ZONE_DIR,
resolvePublicBaseUrl,
} from './user-publish.mjs';
import { extractMindspaceCoverPath } from './wechat/verify/generated-thumbnail.mjs';
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
import { readWorkspacePublishHtml } from './mindspace-workspace-path.mjs';
import { resolvePageWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs';
import { fetchWechatMpAccessToken } from './mindspace-wechat-mp-config.mjs';
import {
addWechatDraftArticle,
buildDailyNewsWechatDraftArticleForPush,
convertNewsPageHtmlToWechatArticle,
isDailyNewsFormat,
uploadWechatArticleContentImage,
uploadWechatPermanentThumb,
} from './wechat-news-morning-draft.mjs';
import { finalizeWechatDraftArticleForPush } from './wechat-draft-article-layout.mjs';
import {
verifyWechatDraftCoverResolution,
verifyWechatDraftPublicationContent,
WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION,
} from './wechat-draft-publication-standard.mjs';
export { WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION };
const RUNS_TABLE = 'h5_user_wechat_page_draft_runs';
const DEFAULT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function extractMetaDescription(html) {
const match = String(html ?? '').match(
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
);
return match?.[1]?.trim() ?? '';
}
function stripHtml(value) {
return String(value ?? '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function inlineHtmlToWechatText(value) {
return String(value ?? '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, '<span style="color:#d98f4a;font-style:normal;">$1</span>')
.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, '<strong>$1</strong>')
.replace(/<b[^>]*>([\s\S]*?)<\/b>/gi, '<strong>$1</strong>')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function stripLegacyReadOriginalFooter(content) {
return String(content ?? '')
.replace(/<p[^>]*>\s*阅读原文[::][\s\S]*?<\/p>/gi, '')
.trim();
}
export function isNewsHotspotFormat(html) {
const source = String(html ?? '');
if (isDailyNewsFormat(source)) return false;
if (/news-hotspot|今日新闻热点/u.test(source)) return true;
if (!/class="card"/.test(source)) return false;
return /<h2[^>]*>/.test(source)
&& (/<span class="tag"/.test(source) || /<div class="k"/.test(source));
}
function normalizeImageRef(value) {
const ref = String(value ?? '').trim();
if (!ref || /^data:/i.test(ref)) return '';
return ref;
}
function resolveImageFilePath(imageRef, publishDir, htmlRelativePath = '') {
const ref = normalizeImageRef(imageRef);
if (!ref) return null;
if (/^https?:\/\//i.test(ref)) return ref;
const htmlDir = htmlRelativePath
? path.dirname(path.join(publishDir, htmlRelativePath))
: publishDir;
const publicDir = path.join(publishDir, PUBLIC_ZONE_DIR);
const candidates = [
path.resolve(htmlDir, ref.replace(/^\.\//, '')),
path.resolve(publicDir, ref.replace(/^\.\//, '')),
path.resolve(publishDir, ref.replace(/^\.\//, '')),
path.resolve(publishDir, ref.replace(/^\/+/, '')),
];
for (const candidate of candidates) {
if (/^https?:\/\//i.test(candidate)) return candidate;
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
function normalizeWechatImageUrl(imageUrl) {
return String(imageUrl ?? '').trim().replace(/^http:\/\//i, 'https://');
}
function renderWechatImage(imageUrl, alt = '') {
const safeUrl = normalizeWechatImageUrl(imageUrl);
if (!safeUrl) return '';
return [
'<section style="margin:0 0 16px;text-align:center;">',
`<img src="${safeUrl}" alt="${escapeHtml(alt)}" style="width:100%;max-width:100%;display:block;margin:0 auto;border-radius:10px;" />`,
'</section>',
].join('');
}
function resolveSidecarThumbnailPaths(publishDir, htmlRelativePath) {
if (!publishDir || !htmlRelativePath) return [];
const htmlPath = path.join(publishDir, htmlRelativePath);
const pngPath = htmlPath.replace(/\.html$/i, '.thumbnail.png');
const svgPath = htmlPath.replace(/\.html$/i, '.thumbnail.svg');
if (fs.existsSync(pngPath)) return [pngPath];
if (fs.existsSync(svgPath)) return [svgPath];
return [];
}
function collectPageImageCandidatesFromHtml(html, { publishDir = '', htmlRelativePath = '' } = {}) {
const source = String(html ?? '');
const refs = [];
const coverPath = extractMindspaceCoverPath(source);
if (coverPath) refs.push(coverPath);
for (const match of source.matchAll(/background-image:\s*url\(["']?([^"')]+)["']?\)/gi)) {
refs.push(match[1]);
}
for (const match of source.matchAll(/<img[^>]+src=["']([^"']+)["']/gi)) {
refs.push(match[1]);
}
const seen = new Set();
const resolved = [];
for (const ref of refs) {
const normalized = normalizeImageRef(ref);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
resolved.push({
ref: normalized,
absPath: resolveImageFilePath(normalized, publishDir, htmlRelativePath),
});
}
return resolved;
}
export function collectPageImageCandidates(html, { publishDir = '', htmlRelativePath = '' } = {}) {
const resolved = collectPageImageCandidatesFromHtml(html, { publishDir, htmlRelativePath });
if (
htmlRelativePath
&& publishDir
&& !resolveHeroImagePath(html, { publishDir, htmlRelativePath })
) {
const seen = new Set(resolved.map((item) => item.ref));
for (const sidecarPath of resolveSidecarThumbnailPaths(publishDir, htmlRelativePath)) {
const ref = path.basename(sidecarPath);
if (seen.has(ref)) continue;
seen.add(ref);
resolved.push({ ref, absPath: sidecarPath });
}
}
return resolved;
}
function countBodyImages(html) {
return collectPageImageCandidates(html).length;
}
function extractBodyHtml(source) {
const match = String(source ?? '').match(/<body[^>]*>([\s\S]*?)<\/body>/i);
return match ? match[1] : String(source ?? '');
}
function stripMindspaceDeliveryChrome(source) {
let body = extractBodyHtml(source);
body = body.replace(/<style id="mindspace-public-share-style">[\s\S]*?<\/script>\s*/i, '');
body = body.replace(/<script[\s\S]*?<\/script>/gi, '');
body = body.replace(/<style[\s\S]*?<\/style>/gi, '');
body = body.replace(/<link[^>]*>/gi, '');
body = body.replace(/<div[^>]*data-mindspace-wechat-dialog[\s\S]*?<\/div>\s*/gi, '');
body = body.replace(/<div[^>]*data-mindspace-public-share[\s\S]*?<\/div>\s*/gi, '');
body = body.replace(/<small[^>]*aria-live="polite"[^>]*>[\s\S]*?<\/small>/gi, '');
return body.trim();
}
function isMindspaceBoilerplateText(text) {
const normalized = stripHtml(text);
if (!normalized) return true;
if (/^(TKMind · 智趣|AI 配图服务暂时不可用)$/u.test(normalized)) return true;
if (/^(取消|确认推送|知道了|关闭|保存长图|公开分享|公众号|发布 Plaza)$/u.test(normalized)) return true;
return false;
}
function resolveMindspaceMainContentHtml(body) {
const main = body.match(/<main[^>]*>([\s\S]*?)<\/main>/i)?.[1];
if (main) return main;
const article = body.match(/<article[^>]*>([\s\S]*?)<\/article>/i)?.[1];
if (article) return article;
const page = body.match(/<div class="page"[^>]*>([\s\S]*?)<\/div>/i)?.[1];
if (page) return page;
const content = body.match(/<div class="content"[^>]*>([\s\S]*?)<\/div>/i)?.[1];
if (content) return content;
return body;
}
function appendParagraphsFromHtml(html, blocks, seen) {
for (const match of String(html ?? '').matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)) {
const text = stripHtml(match[1]);
if (!text || isMindspaceBoilerplateText(text) || seen.has(text)) continue;
seen.add(text);
blocks.push(
`<p style="margin:0 0 16px;font-size:16px;line-height:1.9;color:#333;text-align:justify;">${escapeHtml(text)}</p>`,
);
}
}
function appendListsFromHtml(html, blocks, seen) {
for (const match of String(html ?? '').matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)) {
const text = stripHtml(match[1]);
if (!text || isMindspaceBoilerplateText(text) || seen.has(text)) continue;
seen.add(text);
blocks.push(
`<p style="margin:0 0 10px;font-size:15px;line-height:1.8;color:#444;padding-left:12px;">• ${escapeHtml(text)}</p>`,
);
}
}
function appendBlockquotesFromHtml(html, blocks, seen) {
for (const match of String(html ?? '').matchAll(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi)) {
const text = stripHtml(match[1]);
if (!text || isMindspaceBoilerplateText(text) || seen.has(text)) continue;
seen.add(text);
blocks.push(
`<blockquote style="margin:0 0 16px;padding:12px 16px;border-left:3px solid #d98f4a;background:#faf7f1;color:#2a1a12;font-size:15px;line-height:1.8;">${escapeHtml(text)}</blockquote>`,
);
}
}
function appendFallbackMindspaceArticleSections(sections, source, { skipTexts = new Set() } = {}) {
let body = stripMindspaceDeliveryChrome(source);
body = body
.replace(/<header class="hero"[^>]*>[\s\S]*?<\/header>/gi, '')
.replace(/<div class="hero"[^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<div class="title-block"[^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<div class="divider"[^>]*>\s*<\/div>/gi, '')
.replace(/<div class="tags"[^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '');
const contentHtml = resolveMindspaceMainContentHtml(body);
const seen = new Set([...skipTexts].map((text) => stripHtml(text)).filter(Boolean));
const blocks = [];
for (const match of contentHtml.matchAll(/<h2[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=<h2|<h3|$)/gi)) {
const heading = stripHtml(match[1]);
if (!heading || seen.has(heading)) continue;
seen.add(heading);
blocks.push(
`<p style="margin:0 0 10px;font-size:17px;font-weight:700;color:#2a1a12;">${escapeHtml(heading)}</p>`,
);
appendParagraphsFromHtml(match[2], blocks, seen);
appendListsFromHtml(match[2], blocks, seen);
}
for (const match of contentHtml.matchAll(/<h3[^>]*>([\s\S]*?)<\/h3>([\s\S]*?)(?=<h[23]|$)/gi)) {
const heading = stripHtml(match[1]);
if (!heading || seen.has(heading)) continue;
seen.add(heading);
blocks.push(
`<p style="margin:0 0 8px;font-size:15px;font-weight:700;color:#444;">${escapeHtml(heading)}</p>`,
);
appendParagraphsFromHtml(match[2], blocks, seen);
appendListsFromHtml(match[2], blocks, seen);
}
appendParagraphsFromHtml(contentHtml, blocks, seen);
appendListsFromHtml(contentHtml, blocks, seen);
appendBlockquotesFromHtml(contentHtml, blocks, seen);
if (blocks.length === 0) return;
sections.push('<section style="margin:0 0 20px;">');
sections.push(...blocks);
sections.push('</section>');
}
export function convertRichMindSpacePageHtmlToWechatArticle(
html,
{ pageTitle = '', pageSummary = '', imageUrlMap = new Map() } = {},
) {
const source = String(html ?? '');
const title = (extractPageTitle(source) || pageTitle || 'TKMind 页面').slice(0, 64);
const digest = (extractMetaDescription(source) || pageSummary || title).slice(0, 120);
const sections = [];
const heroTitle = stripHtml(
source.match(/<header class="hero"[^>]*>[\s\S]*?<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1]
?? source.match(/<article[^>]*>[\s\S]*?<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1]
?? '',
);
const heroLead = inlineHtmlToWechatText(
source.match(/<header class="hero"[^>]*>[\s\S]*?<p class="lead"[^>]*>([\s\S]*?)<\/p>/i)?.[1]
?? source.match(/<p class="pitch-sub"[^>]*>([\s\S]*?)<\/p>/i)?.[1]
?? '',
);
const titleBlockTitle = stripHtml(
source.match(/<div class="title-block"[^>]*>[\s\S]*?<h1[^>]*class="title"[^>]*>([\s\S]*?)<\/h1>/i)?.[1]
?? source.match(/<h1[^>]*class="title"[^>]*>([\s\S]*?)<\/h1>/i)?.[1]
?? '',
);
const titleBlockSubtitle = stripHtml(
source.match(/<div class="title-block"[^>]*>[\s\S]*?<p[^>]*class="subtitle"[^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? '',
);
const displayTitle = heroTitle || titleBlockTitle || title;
const displayLead = heroLead || titleBlockSubtitle;
const coverRef = extractMindspaceCoverPath(source);
const sidecarThumbRef = [...imageUrlMap.keys()].find((ref) => /\.thumbnail\.(png|svg)$/i.test(ref)) ?? '';
const heroImageUrl = normalizeWechatImageUrl(
imageUrlMap.get(coverRef)
?? (sidecarThumbRef ? imageUrlMap.get(sidecarThumbRef) : undefined)
?? imageUrlMap.values().next().value
?? '',
);
if (heroImageUrl) {
sections.push(renderWechatImage(heroImageUrl, displayTitle));
}
sections.push([
'<section style="text-align:center;padding:20px 14px 16px;background:linear-gradient(135deg,#2a1a12,#4a3020);color:#fff;border-radius:12px;margin:0 0 16px;">',
`<p style="margin:0 0 8px;font-size:22px;font-weight:700;line-height:1.35;">${escapeHtml(displayTitle)}</p>`,
displayLead
? `<p style="margin:0;font-size:14px;opacity:.88;line-height:1.8;">${displayLead.replace(/\n/g, '<br/>')}</p>`
: '',
'</section>',
].filter(Boolean).join(''));
const summary = stripHtml(pageSummary);
if (summary && summary !== stripHtml(displayLead) && summary !== displayTitle) {
sections.push(
`<p style="margin:0 0 16px;font-size:14px;color:#666;line-height:1.8;">${escapeHtml(summary)}</p>`,
);
}
const proseBlocks = [...source.matchAll(/<div class="prose"[^>]*>([\s\S]*?)<\/div>/gi)];
if (proseBlocks.length > 0) {
sections.push('<section style="margin:0 0 20px;">');
for (const [, block] of proseBlocks) {
for (const [, paragraph] of block.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)) {
const text = stripHtml(paragraph);
if (!text) continue;
sections.push(
`<p style="margin:0 0 16px;font-size:16px;line-height:2;color:#5c3a1e;text-align:justify;">${escapeHtml(text)}</p>`,
);
}
}
sections.push('</section>');
}
const tagBlocks = [...source.matchAll(/<div class="tags"[^>]*>([\s\S]*?)<\/div>/gi)];
if (tagBlocks.length > 0) {
const tags = tagBlocks.flatMap(([, block]) =>
[...block.matchAll(/<span class="tag"[^>]*>([\s\S]*?)<\/span>/gi)]
.map(([, tag]) => stripHtml(tag))
.filter(Boolean),
);
if (tags.length > 0) {
sections.push(
`<p style="margin:0 0 20px;font-size:13px;line-height:1.8;color:#8b6b4a;text-align:center;">${tags.map((tag) => escapeHtml(tag)).join(' · ')}</p>`,
);
}
}
const stanzaBlocks = [...source.matchAll(/<div class="stanza"[^>]*>([\s\S]*?)<\/div>/gi)];
if (stanzaBlocks.length > 0) {
sections.push('<section style="margin:0 0 20px;">');
sections.push('<p style="margin:0 0 12px;font-size:13px;font-weight:600;color:#888;letter-spacing:.2em;">正文</p>');
for (const [, block] of stanzaBlocks) {
const num = stripHtml(block.match(/<span class="num"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '');
const poem = inlineHtmlToWechatText(block.match(/<p[^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? '');
if (!poem) continue;
sections.push([
'<div style="margin:0 0 18px;padding:14px 12px;background:#faf7f1;border-radius:10px;">',
num ? `<p style="margin:0 0 8px;font-size:11px;color:#d98f4a;letter-spacing:.24em;">${escapeHtml(num)}</p>` : '',
`<p style="margin:0;font-size:16px;line-height:2;color:#2a1a12;white-space:pre-wrap;">${poem.replace(/\n/g, '<br/>')}</p>`,
'</div>',
].filter(Boolean).join(''));
}
sections.push('</section>');
}
const finalText = inlineHtmlToWechatText(
source.match(/<div class="final"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '',
);
if (finalText) {
sections.push(
`<p style="margin:0 0 20px;font-size:15px;line-height:1.9;color:#666;text-align:center;">${finalText.replace(/\n/g, '<br/>')}</p>`,
);
}
const cardBlocks = [...source.matchAll(/<div class="card"[^>]*>([\s\S]*?)<\/div>/gi)];
const imageryCards = cardBlocks.filter(([, block]) =>
/<span class="glyph"/.test(block) || /<h3[^>]*>/.test(block),
);
if (imageryCards.length > 0) {
sections.push('<section style="margin:0 0 20px;">');
sections.push('<p style="margin:0 0 12px;font-size:13px;font-weight:600;color:#888;letter-spacing:.2em;">意象</p>');
for (const [, block] of imageryCards) {
const glyph = stripHtml(block.match(/<span class="glyph"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '');
const cardTitle = stripHtml(block.match(/<h3[^>]*>([\s\S]*?)<\/h3>/i)?.[1] ?? '');
const cardBody = stripHtml(block.match(/<p[^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? '');
sections.push([
'<div style="margin:0 0 14px;padding:16px;background:#fff;border:1px solid #eee;border-radius:10px;">',
glyph ? `<p style="margin:0 0 8px;font-size:24px;color:#d98f4a;">${escapeHtml(glyph)}</p>` : '',
cardTitle ? `<p style="margin:0 0 8px;font-size:15px;font-weight:700;color:#2a1a12;">${escapeHtml(cardTitle)}</p>` : '',
cardBody ? `<p style="margin:0;font-size:14px;line-height:1.8;color:#666;">${escapeHtml(cardBody)}</p>` : '',
'</div>',
].filter(Boolean).join(''));
}
sections.push('</section>');
}
const noteBlock = source.match(/<section class="note"[^>]*>([\s\S]*?)<\/section>/i)?.[1] ?? '';
if (noteBlock) {
sections.push('<section style="margin:0 0 8px;">');
sections.push('<p style="margin:0 0 12px;font-size:13px;font-weight:600;color:#888;letter-spacing:.2em;">创作手记</p>');
const noteTitle = stripHtml(noteBlock.match(/<h2[^>]*>([\s\S]*?)<\/h2>/i)?.[1] ?? '');
if (noteTitle) {
sections.push(`<p style="margin:0 0 12px;font-size:16px;font-weight:700;color:#2a1a12;">${escapeHtml(noteTitle)}</p>`);
}
for (const [, paragraph] of noteBlock.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)) {
const text = stripHtml(paragraph);
if (!text) continue;
sections.push(`<p style="margin:0 0 12px;font-size:14px;line-height:1.9;color:#555;">${escapeHtml(text)}</p>`);
}
const quote = stripHtml(noteBlock.match(/<div class="quote"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
if (quote) {
sections.push(
`<blockquote style="margin:0;padding:12px 16px;border-left:3px solid #d98f4a;background:#faf7f1;color:#2a1a12;font-size:15px;line-height:1.8;">${escapeHtml(quote)}</blockquote>`,
);
}
sections.push('</section>');
}
const specializedContentExtracted =
proseBlocks.length > 0
|| stanzaBlocks.length > 0
|| Boolean(finalText)
|| imageryCards.length > 0
|| Boolean(noteBlock);
if (!specializedContentExtracted) {
appendFallbackMindspaceArticleSections(sections, source, {
skipTexts: new Set([displayTitle, displayLead, summary].filter(Boolean)),
});
}
for (const match of source.matchAll(/<img[^>]+src=["']([^"']+)["']/gi)) {
const ref = normalizeImageRef(match[1]);
const uploaded = normalizeWechatImageUrl(imageUrlMap.get(ref));
if (uploaded && uploaded !== heroImageUrl) {
sections.push(renderWechatImage(uploaded));
}
}
return {
title: displayTitle.slice(0, 64),
digest,
content: sections.join('\n'),
imageCount: imageUrlMap.size,
};
}
export function resolveWechatDraftProfile(html) {
const source = String(html ?? '');
if (isDailyNewsFormat(source)) return 'daily-news';
if (isNewsHotspotFormat(source)) return 'news-hotspot';
if (/name=["']mindspace-cover["']/i.test(source)) return 'mindspace-page';
if (/data-mindspace-page-tag=["']platform-brand["']/i.test(source)) return 'mindspace-page';
if (/class="stanza"/.test(source) || /class="hero"/.test(source)) return 'mindspace-page';
return 'generic';
}
export function validatePageForWechatDraft(
html,
{ pageTitle = '', pageSummary = '', publicUrl = '' } = {},
) {
const source = String(html ?? '').trim();
const profile = resolveWechatDraftProfile(source);
const warnings = [];
const blockers = [];
if (!source) {
blockers.push('页面 HTML 为空,无法推送草稿。');
}
if (!stripHtml(pageTitle || extractPageTitle(source))) {
blockers.push('缺少页面标题。');
}
if (!publicUrl) {
warnings.push('页面尚未公开或缺少阅读原文链接,草稿中将省略「阅读原文」。');
}
if (countBodyImages(source) === 0 && profile === 'mindspace-page') {
warnings.push('页面未检测到可上传图片,草稿将以文字为主。');
}
if (profile === 'mindspace-page') {
warnings.push('MindSpace 页面结构转换后视觉可能与原页略有差异。');
}
return {
ok: blockers.length === 0,
profile,
warnings,
blockers,
};
}
async function uploadPageImagesForWechat(
accessToken,
html,
{ publishDir = '', htmlRelativePath = '', wechatFetch = undiciFetch } = {},
) {
const imageUrlMap = new Map();
const candidates = collectPageImageCandidates(html, { publishDir, htmlRelativePath });
for (const { ref, absPath } of candidates) {
if (!absPath || imageUrlMap.has(ref)) continue;
if (/^https?:\/\//i.test(absPath)) {
imageUrlMap.set(ref, absPath);
continue;
}
let buffer = fs.readFileSync(absPath);
if (/\.(webp|svg)$/i.test(absPath)) {
buffer = await sharp(buffer).png().toBuffer();
}
const uploaded = normalizeWechatImageUrl(
await uploadWechatArticleContentImage(
accessToken,
buffer,
{ wechatFetch, filename: `${path.basename(absPath).replace(/\.(webp|svg)$/i, '')}.png` },
),
);
imageUrlMap.set(ref, uploaded);
}
return imageUrlMap;
}
export async function buildMindSpacePageWechatDraftArticleForPush({
html,
publicUrl = '',
pageTitle = '',
pageSummary = '',
accessToken = null,
publishDir = '',
htmlRelativePath = '',
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
} = {}) {
const imageUrlMap = accessToken
? await uploadPageImagesForWechat(accessToken, html, { publishDir, htmlRelativePath, wechatFetch })
: new Map();
const profile = resolveWechatDraftProfile(html);
let article;
if (profile === 'news-hotspot') {
article = convertNewsPageHtmlToWechatArticle(html, { publicUrl });
article.content = stripLegacyReadOriginalFooter(article.content);
} else if (profile === 'mindspace-page') {
article = convertRichMindSpacePageHtmlToWechatArticle(html, {
pageTitle,
pageSummary,
imageUrlMap,
});
} else {
article = convertGenericPageHtmlToWechatArticle(html, {
pageTitle,
pageSummary,
});
article.content = stripLegacyReadOriginalFooter(article.content);
}
const finalized = await finalizeWechatDraftArticleForPush({
content: article.content,
publicUrl,
accessToken,
wechatFetch,
memindLibRoot,
});
return {
...article,
content: finalized.content,
contentSourceUrl: publicUrl || undefined,
uploadedImageCount: imageUrlMap.size,
};
}
export async function buildWechatDraftPublicationBundleForPush({
html,
publicUrl = '',
pageTitle = '',
pageSummary = '',
accessToken = null,
publishDir = '',
htmlRelativePath = '',
h5Root = process.cwd(),
userId = '',
workspaceRelativePath = htmlRelativePath,
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
} = {}) {
const profile = resolveWechatDraftProfile(html);
const article = isDailyNewsFormat(html)
? await buildDailyNewsWechatDraftArticleForPush({
html,
publicUrl,
accessToken,
wechatFetch,
memindLibRoot,
publishDir,
})
: await buildMindSpacePageWechatDraftArticleForPush({
html,
publicUrl,
pageTitle,
pageSummary,
accessToken,
publishDir,
htmlRelativePath,
wechatFetch,
memindLibRoot,
});
const thumbResolved = resolveWechatDraftThumbPath(html, {
h5Root,
userId,
workspaceRelativePath,
publishDir,
});
const contentCheck = verifyWechatDraftPublicationContent(article.content, { publicUrl });
const coverCheck = verifyWechatDraftCoverResolution(thumbResolved);
if (!contentCheck.ok || !coverCheck.ok) {
throw Object.assign(
new Error(`草稿不符合推送标准 ${WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION}`),
{
code: 'wechat_draft_standard_violation',
contentIssues: contentCheck.issues,
coverIssues: coverCheck.issues,
},
);
}
return {
article,
thumbResolved,
profile,
standardVersion: WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION,
};
}
export function convertGenericPageHtmlToWechatArticle(
html,
{ publicUrl = '', pageTitle = '', pageSummary = '' } = {},
) {
const title = (extractPageTitle(html) || pageTitle || 'TKMind 页面').slice(0, 64);
const digest = (extractMetaDescription(html) || pageSummary || title).slice(0, 120);
const sections = [];
const summary = stripHtml(pageSummary);
if (summary) {
sections.push(
`<p style="margin:0 0 16px;font-size:14px;color:#666;line-height:1.8;">${escapeHtml(summary)}</p>`,
);
}
appendFallbackMindspaceArticleSections(sections, html, {
skipTexts: new Set([title, summary].filter(Boolean)),
});
if (sections.length === (summary ? 1 : 0)) {
sections.push(
`<p style="margin:0;font-size:15px;line-height:1.8;color:#333;">${escapeHtml(title)}</p>`,
);
}
return {
title,
digest,
content: sections.join('\n').slice(0, 20000),
contentSourceUrl: publicUrl || undefined,
};
}
export function convertPageHtmlToWechatArticle(
html,
{ publicUrl = '', pageTitle = '', pageSummary = '' } = {},
) {
if (isDailyNewsFormat(html)) {
return convertNewsPageHtmlToWechatArticle(html, { publicUrl });
}
if (isNewsHotspotFormat(html)) {
return convertNewsPageHtmlToWechatArticle(html, { publicUrl });
}
if (resolveWechatDraftProfile(html) === 'mindspace-page') {
return convertRichMindSpacePageHtmlToWechatArticle(html, { pageTitle, pageSummary });
}
return convertGenericPageHtmlToWechatArticle(html, { publicUrl, pageTitle, pageSummary });
}
async function ensureRunsTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${RUNS_TABLE} (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
page_id CHAR(36) NOT NULL,
status VARCHAR(16) NOT NULL,
page_title VARCHAR(255) NULL,
page_url VARCHAR(512) NULL,
draft_media_id VARCHAR(128) NULL,
error_message TEXT NULL,
triggered_by CHAR(36) NULL,
created_at BIGINT NOT NULL,
KEY idx_wechat_page_draft_user (user_id, created_at),
KEY idx_wechat_page_draft_page (page_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
function cryptoRandomId() {
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function resolveThumbPath(h5Root, userId, workspaceRelativePath) {
if (!h5Root || !userId || !workspaceRelativePath) return null;
const parts = String(workspaceRelativePath)
.replace(/^\/+/, '')
.split('/')
.filter(Boolean);
const baseParts = parts[0]?.toLowerCase() === PUBLIC_ZONE_DIR
? parts
: [PUBLIC_ZONE_DIR, ...parts];
const htmlPath = path.join(
h5Root,
PUBLISH_ROOT_DIR,
String(userId),
...baseParts,
);
const pngPath = htmlPath.replace(/\.html$/i, '.thumbnail.png');
if (fs.existsSync(pngPath)) return pngPath;
const svgPath = htmlPath.replace(/\.html$/i, '.thumbnail.svg');
if (fs.existsSync(svgPath)) return svgPath;
return null;
}
function isWechatDraftDecorativeImageRef(ref = '') {
return /mp-follow-qrcode|tkmind-icon|\/brand\//i.test(String(ref ?? ''));
}
export function resolveHeroImagePath(html, { publishDir = '', htmlRelativePath = '' } = {}) {
const coverRef = extractMindspaceCoverPath(html);
if (coverRef && !/\.svg$/i.test(coverRef)) {
const coverPath = resolveImageFilePath(coverRef, publishDir, htmlRelativePath);
if (coverPath && !/\.svg$/i.test(coverPath)) {
return coverPath;
}
}
const candidates = collectPageImageCandidatesFromHtml(html, { publishDir, htmlRelativePath });
for (const { ref, absPath } of candidates) {
if (!absPath || /\.svg$/i.test(absPath) || isWechatDraftDecorativeImageRef(ref)) {
continue;
}
return absPath;
}
return null;
}
function resolveRasterThumbSidecarPath(h5Root, userId, workspaceRelativePath) {
const sidecarPath = resolveThumbPath(h5Root, userId, workspaceRelativePath);
if (sidecarPath && /\.(png|svg)$/i.test(sidecarPath)) {
return sidecarPath;
}
return null;
}
export function resolveWechatDraftThumbPath(
html,
{ h5Root, userId, workspaceRelativePath, publishDir } = {},
) {
const heroPath = resolveHeroImagePath(html, {
publishDir,
htmlRelativePath: workspaceRelativePath,
});
if (heroPath) {
return { source: 'hero', path: heroPath };
}
const sidecarPath = resolveRasterThumbSidecarPath(h5Root, userId, workspaceRelativePath);
if (sidecarPath) {
return { source: 'thumbnail-sidecar', path: sidecarPath };
}
return { source: 'placeholder', path: null };
}
async function loadThumbBuffer(thumbPath, { wechatFetch = undiciFetch } = {}) {
if (!thumbPath) {
return sharp(
Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="900" height="900"><rect width="900" height="900" fill="#1a1a2e"/><text x="50%" y="50%" fill="#fff" font-size="42" text-anchor="middle" dominant-baseline="middle">TKMind</text></svg>',
),
)
.png()
.toBuffer();
}
let raw;
if (/^https?:\/\//i.test(thumbPath)) {
const response = await wechatFetch(thumbPath);
if (!response.ok) {
throw new Error(`封面图片下载失败: ${thumbPath}`);
}
raw = Buffer.from(await response.arrayBuffer());
} else {
raw = fs.readFileSync(thumbPath);
}
return sharp(raw).resize(900, 900, { fit: 'cover' }).png().toBuffer();
}
export function resolvePagePublicUrl(
page,
{
userId = '',
workspaceRelativePath = '',
publication = null,
publicBaseUrl = '',
} = {},
) {
const resolved = String(
publication?.publicUrl
?? publication?.public_url
?? page?.publication?.publicUrl
?? page?.publicationUrl
?? page?.workspacePublicUrl
?? '',
).trim();
if (resolved) return resolved;
const relativePath = String(
workspaceRelativePath
?? page?.workspaceRelativePath
?? resolvePageWorkspaceRelativePath(page)
?? '',
).trim();
if (relativePath && userId && publicBaseUrl) {
return buildPublicUrl(publicBaseUrl, userId, relativePath);
}
return '';
}
export function createMindSpaceWechatPageDraftService(
pool,
{
getMindSpacePages = () => null,
getMindSpacePublications = () => null,
getWechatMpConfig = () => null,
h5Root = process.cwd(),
memindLibRoot = h5Root,
env = process.env,
tokenUrl = env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL,
wechatFetch = undiciFetch,
} = {},
) {
let ensurePromise = null;
const accessTokenCache = new Map();
async function ensureReady() {
if (!ensurePromise) {
ensurePromise = ensureRunsTable(pool);
}
await ensurePromise;
}
async function getAccessToken(userId) {
const configService = getWechatMpConfig?.();
if (!configService) throw new Error('公众号配置服务未启用');
const credentials = await configService.getCredentials(userId);
if (!credentials?.appId || !credentials?.appSecret) {
throw Object.assign(new Error('请先在 M 配置中填写公众号 AppID 和 AppSecret'), {
code: 'wechat_mp_not_configured',
});
}
const cacheKey = `${userId}:${credentials.appId}`;
const cached = accessTokenCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now() + 60_000) {
return { accessToken: cached.token, credentials };
}
const token = await fetchWechatMpAccessToken(credentials, { tokenUrl, wechatFetch });
accessTokenCache.set(cacheKey, {
token: token.accessToken,
expiresAt: Date.now() + token.expiresIn * 1000,
});
return { accessToken: token.accessToken, credentials };
}
async function recordRun(payload) {
await ensureReady();
const id = cryptoRandomId();
const now = Date.now();
await pool.query(
`INSERT INTO ${RUNS_TABLE}
(id, user_id, page_id, status, page_title, page_url, draft_media_id, error_message, triggered_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
payload.userId,
payload.pageId,
payload.status,
payload.pageTitle ?? null,
payload.pageUrl ?? null,
payload.draftMediaId ?? null,
payload.errorMessage ?? null,
payload.triggeredBy ?? null,
now,
],
);
return {
id,
status: payload.status,
pageId: payload.pageId,
pageTitle: payload.pageTitle ?? null,
pageUrl: payload.pageUrl ?? null,
draftMediaId: payload.draftMediaId ?? null,
errorMessage: payload.errorMessage ?? null,
triggeredBy: payload.triggeredBy ?? null,
createdAt: now,
};
}
async function resolvePageHtml(userId, pageId) {
const pages = getMindSpacePages?.();
if (!pages?.renderPreview || !pages?.getPage) {
throw new Error('MindSpace 页面服务未启用');
}
const page = await pages.getPage(userId, pageId);
const { html } = await pages.renderPreview(userId, pageId);
const workspaceRelativePath = resolvePageWorkspaceRelativePath(page)
?? page.workspaceRelativePath
?? '';
const publications = getMindSpacePublications?.();
const publication = publications?.getCurrent
? await publications.getCurrent(userId, pageId).catch(() => null)
: null;
const publicBaseUrl = resolvePublicBaseUrl(env);
const publicUrl = resolvePagePublicUrl(page, {
userId,
workspaceRelativePath,
publication,
publicBaseUrl,
});
let sourceHtml = html;
if (workspaceRelativePath) {
const workspaceHtml = await readWorkspacePublishHtml(h5Root, userId, workspaceRelativePath);
if (workspaceHtml?.trim()) {
sourceHtml = workspaceHtml;
}
}
return {
page,
html: sourceHtml,
publicUrl,
workspaceRelativePath,
};
}
return {
async pushPageDraft(userId, pageId, { triggeredBy = null } = {}) {
let preview = null;
try {
preview = await resolvePageHtml(userId, pageId);
const { accessToken, credentials } = await getAccessToken(userId);
const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId));
const bundle = await buildWechatDraftPublicationBundleForPush({
html: preview.html,
publicUrl: preview.publicUrl,
pageTitle: preview.page.title,
pageSummary: preview.page.summary,
accessToken,
publishDir,
htmlRelativePath: preview.workspaceRelativePath ?? '',
h5Root,
userId,
workspaceRelativePath: preview.workspaceRelativePath,
wechatFetch,
memindLibRoot,
});
const { article, thumbResolved } = bundle;
const thumbBuffer = await loadThumbBuffer(thumbResolved.path, { wechatFetch });
const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumbBuffer, { wechatFetch });
const draft = await addWechatDraftArticle(
accessToken,
{
title: article.title,
author: credentials.author || 'TKMind',
digest: article.digest,
content: article.content,
content_source_url: article.contentSourceUrl,
thumb_media_id: thumbMediaId,
need_open_comment: 0,
only_fans_can_comment: 0,
},
{ wechatFetch },
);
const run = await recordRun({
userId,
pageId,
status: 'success',
pageTitle: preview.page.title,
pageUrl: preview.publicUrl,
draftMediaId: draft.draftMediaId,
triggeredBy,
});
return {
ok: true,
draftMediaId: draft.draftMediaId,
pageTitle: preview.page.title,
pageUrl: preview.publicUrl,
standardVersion: bundle.standardVersion,
coverSource: thumbResolved.source,
run,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const run = await recordRun({
userId,
pageId,
status: 'failed',
pageTitle: preview?.page?.title ?? null,
pageUrl: preview?.publicUrl ?? null,
errorMessage: message,
triggeredBy,
});
throw Object.assign(new Error(message), {
code: error?.code ?? 'wechat_draft_push_failed',
run,
});
}
},
async listRuns(userId, { pageId = null, limit = 20 } = {}) {
await ensureReady();
const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100);
const params = [userId];
let sql = `
SELECT id, page_id AS pageId, status, page_title AS pageTitle, page_url AS pageUrl,
draft_media_id AS draftMediaId, error_message AS errorMessage,
triggered_by AS triggeredBy, created_at AS createdAt
FROM ${RUNS_TABLE}
WHERE user_id = ?`;
if (pageId) {
sql += ' AND page_id = ?';
params.push(pageId);
}
sql += ' ORDER BY created_at DESC LIMIT ?';
params.push(safeLimit);
const [rows] = await pool.query(sql, params);
return rows.map((row) => ({
...row,
createdAt: Number(row.createdAt ?? 0),
}));
},
};
}
export const mindspaceWechatPageDraftInternals = {
convertGenericPageHtmlToWechatArticle,
convertPageHtmlToWechatArticle,
convertRichMindSpacePageHtmlToWechatArticle,
collectPageImageCandidates,
isNewsHotspotFormat,
resolveWechatDraftThumbPath,
resolveHeroImagePath,
};