2026-09-10 16:43:25 +08:00
|
|
|
|
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';
|
2026-09-11 12:32:13 +08:00
|
|
|
|
import { extractMindspaceCoverPath } from './wechat/verify/generated-thumbnail.mjs';
|
2026-09-10 16:43:25 +08:00
|
|
|
|
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,
|
2026-09-11 12:32:13 +08:00
|
|
|
|
uploadWechatArticleContentImage,
|
2026-09-10 16:43:25 +08:00
|
|
|
|
uploadWechatPermanentThumb,
|
|
|
|
|
|
} from './wechat-news-morning-draft.mjs';
|
2026-09-11 12:32:13 +08:00
|
|
|
|
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 };
|
2026-09-10 16:43:25 +08:00
|
|
|
|
|
|
|
|
|
|
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, '<')
|
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
|
.replace(/"/g, '"');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 12:32:13 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 16:43:25 +08:00
|
|
|
|
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>`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-09-11 12:32:13 +08:00
|
|
|
|
appendFallbackMindspaceArticleSections(sections, html, {
|
|
|
|
|
|
skipTexts: new Set([title, summary].filter(Boolean)),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (sections.length === (summary ? 1 : 0)) {
|
2026-09-10 16:43:25 +08:00
|
|
|
|
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 });
|
|
|
|
|
|
}
|
2026-09-11 12:32:13 +08:00
|
|
|
|
if (isNewsHotspotFormat(html)) {
|
|
|
|
|
|
return convertNewsPageHtmlToWechatArticle(html, { publicUrl });
|
|
|
|
|
|
}
|
|
|
|
|
|
if (resolveWechatDraftProfile(html) === 'mindspace-page') {
|
|
|
|
|
|
return convertRichMindSpacePageHtmlToWechatArticle(html, { pageTitle, pageSummary });
|
2026-09-10 16:43:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
2026-09-11 12:32:13 +08:00
|
|
|
|
const parts = String(workspaceRelativePath)
|
|
|
|
|
|
.replace(/^\/+/, '')
|
|
|
|
|
|
.split('/')
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
const baseParts = parts[0]?.toLowerCase() === PUBLIC_ZONE_DIR
|
|
|
|
|
|
? parts
|
|
|
|
|
|
: [PUBLIC_ZONE_DIR, ...parts];
|
2026-09-10 16:43:25 +08:00
|
|
|
|
const htmlPath = path.join(
|
|
|
|
|
|
h5Root,
|
|
|
|
|
|
PUBLISH_ROOT_DIR,
|
|
|
|
|
|
String(userId),
|
2026-09-11 12:32:13 +08:00
|
|
|
|
...baseParts,
|
2026-09-10 16:43:25 +08:00
|
|
|
|
);
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 12:32:13 +08:00
|
|
|
|
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 } = {}) {
|
2026-09-10 16:43:25 +08:00
|
|
|
|
if (!thumbPath) {
|
2026-09-11 12:32:13 +08:00
|
|
|
|
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);
|
2026-09-10 16:43:25 +08:00
|
|
|
|
}
|
2026-09-11 12:32:13 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-09-10 16:43:25 +08:00
|
|
|
|
}
|
2026-09-11 12:32:13 +08:00
|
|
|
|
return '';
|
2026-09-10 16:43:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function createMindSpaceWechatPageDraftService(
|
|
|
|
|
|
pool,
|
|
|
|
|
|
{
|
|
|
|
|
|
getMindSpacePages = () => null,
|
2026-09-11 12:32:13 +08:00
|
|
|
|
getMindSpacePublications = () => null,
|
2026-09-10 16:43:25 +08:00
|
|
|
|
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);
|
2026-09-11 12:32:13 +08:00
|
|
|
|
const workspaceRelativePath = resolvePageWorkspaceRelativePath(page)
|
|
|
|
|
|
?? page.workspaceRelativePath
|
|
|
|
|
|
?? '';
|
|
|
|
|
|
const publications = getMindSpacePublications?.();
|
|
|
|
|
|
const publication = publications?.getCurrent
|
|
|
|
|
|
? await publications.getCurrent(userId, pageId).catch(() => null)
|
|
|
|
|
|
: null;
|
2026-09-10 16:43:25 +08:00
|
|
|
|
const publicBaseUrl = resolvePublicBaseUrl(env);
|
2026-09-11 12:32:13 +08:00
|
|
|
|
const publicUrl = resolvePagePublicUrl(page, {
|
|
|
|
|
|
userId,
|
|
|
|
|
|
workspaceRelativePath,
|
|
|
|
|
|
publication,
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
});
|
2026-09-10 16:43:25 +08:00
|
|
|
|
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);
|
2026-09-11 12:32:13 +08:00
|
|
|
|
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 ?? '',
|
2026-09-10 16:43:25 +08:00
|
|
|
|
h5Root,
|
|
|
|
|
|
userId,
|
2026-09-11 12:32:13 +08:00
|
|
|
|
workspaceRelativePath: preview.workspaceRelativePath,
|
|
|
|
|
|
wechatFetch,
|
|
|
|
|
|
memindLibRoot,
|
|
|
|
|
|
});
|
|
|
|
|
|
const { article, thumbResolved } = bundle;
|
|
|
|
|
|
const thumbBuffer = await loadThumbBuffer(thumbResolved.path, { wechatFetch });
|
2026-09-10 16:43:25 +08:00
|
|
|
|
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,
|
2026-09-11 12:32:13 +08:00
|
|
|
|
standardVersion: bundle.standardVersion,
|
|
|
|
|
|
coverSource: thumbResolved.source,
|
2026-09-10 16:43:25 +08:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-09-10 17:32:10 +08:00
|
|
|
|
throw Object.assign(new Error(message), {
|
|
|
|
|
|
code: error?.code ?? 'wechat_draft_push_failed',
|
|
|
|
|
|
run,
|
|
|
|
|
|
});
|
2026-09-10 16:43:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-11 12:32:13 +08:00
|
|
|
|
convertRichMindSpacePageHtmlToWechatArticle,
|
|
|
|
|
|
collectPageImageCandidates,
|
|
|
|
|
|
isNewsHotspotFormat,
|
|
|
|
|
|
resolveWechatDraftThumbPath,
|
|
|
|
|
|
resolveHeroImagePath,
|
2026-09-10 16:43:25 +08:00
|
|
|
|
};
|