Files
memind/mindspace-wechat-page-draft.mjs
T
john d8eaef3fbd feat(wechat): MindSpace public draft push with 143 host defaults
Add public-page WeChat draft button, publication standard conversion for poem/prose/generic pages, and quick push APIs. Default Studio release SSH targets to john@180.159.29.143 with 105 edge upstream via 10.10.0.2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 12:32:13 +08:00

1081 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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, '&amp;')
.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,
};