e42417bd6e
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>
364 lines
12 KiB
JavaScript
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, '&')
|
|
.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();
|
|
}
|
|
|
|
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,
|
|
};
|