Files
memind/mindspace-wechat-page-draft.mjs
T
john e42417bd6e feat(wechat): add subscribe morning reminder, plaza welcome, and LLM fallback
Enable daily morning greeting on reply 1 (with custom time, modify, and cancel),
random greeting delivery, M发现 in subscribe welcome, and optional LLM parsing when
rules miss. Also fix WeChat MP draft/config error passthrough and add Tang E2E scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 17:32:10 +08:00

364 lines
12 KiB
JavaScript

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, '&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();
}
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(/<body[^>]*>([\s\S]*?)<\/body>/i);
let body = bodyMatch ? bodyMatch[1] : String(html ?? '');
body = body
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<link[^>]*>/gi, '')
.trim();
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>`,
);
}
if (body) {
sections.push(`<div style="font-size:15px;line-height:1.8;color:#333;">${body}</div>`);
} else {
sections.push(
`<p style="margin:0;font-size:15px;line-height:1.8;color:#333;">${escapeHtml(title)}</p>`,
);
}
if (publicUrl) {
sections.push(
`<p style="margin:16px 0 0;font-size:13px;color:#888;">阅读原文:<a href="${publicUrl}">${escapeHtml(publicUrl)}</a></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 });
}
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(
'<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>',
);
}
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,
};