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 { 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, uploadWechatPermanentThumb, } from './wechat-news-morning-draft.mjs'; 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, '"'); } function extractMetaDescription(html) { const match = String(html ?? '').match( /]+name=["']description["'][^>]+content=["']([^"']*)["']/i, ); return match?.[1]?.trim() ?? ''; } function stripHtml(value) { return String(value ?? '') .replace(//gi, '\n') .replace(/<[^>]+>/g, '') .replace(/\s+/g, ' ') .trim(); } 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 bodyMatch = String(html ?? '').match(/]*>([\s\S]*?)<\/body>/i); let body = bodyMatch ? bodyMatch[1] : String(html ?? ''); body = body .replace(//gi, '') .replace(//gi, '') .replace(/]*>/gi, '') .trim(); const sections = []; const summary = stripHtml(pageSummary); if (summary) { sections.push( `

${escapeHtml(summary)}

`, ); } if (body) { sections.push(`
${body}
`); } else { sections.push( `

${escapeHtml(title)}

`, ); } if (publicUrl) { sections.push( `

阅读原文:${escapeHtml(publicUrl)}

`, ); } 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 }); } const structured = convertNewsPageHtmlToWechatArticle(html, { publicUrl }); if ((structured.cardCount ?? 0) > 0) { return structured; } 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 htmlPath = path.join( h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR, ...workspaceRelativePath.split('/'), ); 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; } async function loadThumbBuffer(thumbPath) { if (!thumbPath) { return Buffer.from( 'TKMind', ); } const raw = fs.readFileSync(thumbPath); if (/\.svg$/i.test(thumbPath)) { return sharp(raw).png().toBuffer(); } return raw; } export function createMindSpaceWechatPageDraftService( pool, { getMindSpacePages = () => 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); const publicBaseUrl = resolvePublicBaseUrl(env); const publicUrl = page.publication?.publicUrl ?? (workspaceRelativePath ? buildPublicUrl(publicBaseUrl, userId, workspaceRelativePath) : ''); 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 article = isDailyNewsFormat(preview.html) ? await buildDailyNewsWechatDraftArticleForPush({ html: preview.html, publicUrl: preview.publicUrl, accessToken, wechatFetch, memindLibRoot, }) : convertPageHtmlToWechatArticle(preview.html, { publicUrl: preview.publicUrl, pageTitle: preview.page.title, pageSummary: preview.page.summary, }); const thumbPath = resolveThumbPath( h5Root, userId, preview.workspaceRelativePath, ); const thumbBuffer = await loadThumbBuffer(thumbPath); 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, 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, };