feat(news): add single news-item page generation and early page-ready wait

Scheduled tasks can finish once the expected HTML is on disk and stable,
instead of waiting for Goose Finish. Adds news-item templates and scripts
for generating a single-event page and committing it as a WeChat draft.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-23 10:07:32 +08:00
parent c36558e0f3
commit 028bb18dc0
7 changed files with 1029 additions and 4 deletions
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* 阶段一:Agent 深度分析单条新闻并生成落地页。只生成,不推送。
*
* stdin { userId, newsItem, runId? }
* stdout { ok, pageSlug, pageUrl, relativePath, publishDir, html, check, executor }
*
* 落地页生成后即可点链接查看;后续的预览、编辑、推送都不再需要跑 Agent。
*/
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { executeScheduledTask } from '../scheduled-task-executor.mjs';
import {
buildPublicUrl,
PUBLISH_ROOT_DIR,
resolvePublicBaseUrl,
} from '../user-publish.mjs';
import {
buildNewsItemGenerationTaskSpec,
validateNewsItemHtml,
} from '../news-item-templates.mjs';
import {
bootstrapAgentContext,
loadPushEnvironment,
readStdinPayload,
runScript,
} from './news-item-wechat-context.mjs';
function slugify(value) {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'news-item';
}
function buildGenerationTask(userId, newsItem, env, now = Date.now()) {
const date = new Date(now);
const mmdd = `${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`;
const slug = `news-item-${slugify(newsItem.canonicalTitle || newsItem.title)}-${mmdd}`;
const { style, taskSpec } = buildNewsItemGenerationTaskSpec(newsItem, env);
return {
id: `news-item-${slug}`,
userId,
title: `新闻深度稿:${newsItem.canonicalTitle || newsItem.title}`,
taskSpec: [
taskSpec,
'',
`输出文件名必须是 public/${slug}.html(可覆盖同名旧文件)。`,
`data-style 必须设为 ${style}`,
].join('\n'),
recurrence: 'once',
timezone: 'Asia/Shanghai',
notifyChannel: 'web',
expectedSlug: slug,
expectedRelativePath: `public/${slug}.html`,
layoutStyle: style,
isExpectedPageReady: (html) => validateNewsItemHtml(html, { style }).ok,
};
}
function resolveTimeoutMs(env) {
return Math.max(
5 * 60_000,
Number(env.MEMIND_NEWS_ITEM_GENERATE_TIMEOUT_MS ?? 20 * 60_000) || 20 * 60_000,
);
}
async function main() {
const env = loadPushEnvironment(process.env);
const payload = readStdinPayload();
const userId = String(payload.userId ?? '').trim();
const newsItem = payload.newsItem ?? {};
if (!userId) throw new Error('缺少 userId');
const services = await bootstrapAgentContext(env);
try {
const task = buildGenerationTask(userId, newsItem, env);
const execution = await executeScheduledTask(task, {
userAuth: services.userAuth,
tkmindProxy: services.tkmindProxy,
agentRunGateway: services.agentRunGateway,
cursorExecutorPolicyService: services.cursorExecutorPolicyService,
sessionSnapshotService: services.sessionSnapshotService,
pool: services.pool,
h5Root: services.h5Root,
timeoutMs: resolveTimeoutMs(env),
logger: console,
});
const publishDir = path.join(services.h5Root, PUBLISH_ROOT_DIR, userId);
const relativePath = execution.readyPaths?.[0]
?? (fs.existsSync(path.join(publishDir, task.expectedRelativePath))
? task.expectedRelativePath
: null);
if (!relativePath) {
throw new Error('Agent 未生成 public/*.html 页面');
}
const localPath = path.join(publishDir, relativePath);
if (!fs.existsSync(localPath)) throw new Error(`生成页面不存在:${relativePath}`);
const html = fs.readFileSync(localPath, 'utf8');
const check = validateNewsItemHtml(html, { style: task.layoutStyle });
if (!check.ok) {
throw Object.assign(
new Error(`生成页面不达标:${check.issues.join('')}`),
{ code: 'news_item_page_substandard' },
);
}
return {
pageSlug: path.basename(relativePath, '.html'),
pageUrl: buildPublicUrl(resolvePublicBaseUrl(env), userId, relativePath),
relativePath,
publishDir,
html,
check,
layoutStyle: task.layoutStyle,
executor: execution.executor ?? null,
};
} finally {
await services.close();
}
}
runScript(main);
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* 阶段三:把用户确认过的正文原样提交到公众号草稿箱。
*
* 这里刻意不做任何 HTML 转换——content 就是预览页显示的那一份,由 news-engine 的
* 块渲染器产出。任何在这里的二次加工都会破坏「预览即草稿」。
*
* stdin { userId, title, author?, digest, content, contentSourceUrl, thumb: { url?, localPath? } }
* stdout { ok, draftMediaId }
*/
import fs from 'node:fs';
import process from 'node:process';
import {
addWechatDraftArticle,
uploadWechatPermanentThumb,
} from '../wechat-news-morning-draft.mjs';
import {
createLightContext,
loadPushEnvironment,
readStdinPayload,
runScript,
} from './news-item-wechat-context.mjs';
const PLACEHOLDER_SVG = '<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>';
async function loadThumbBuffer(thumb) {
const sharp = (await import('sharp')).default;
let raw = null;
const localPath = String(thumb?.localPath ?? '').trim();
const url = String(thumb?.url ?? '').trim();
if (localPath && fs.existsSync(localPath)) {
raw = fs.readFileSync(localPath);
} else if (/^https?:\/\//i.test(url)) {
const response = await fetch(url, { signal: AbortSignal.timeout(20_000) });
if (response.ok) raw = Buffer.from(await response.arrayBuffer());
}
if (!raw) return sharp(Buffer.from(PLACEHOLDER_SVG)).png().toBuffer();
return sharp(raw).resize(900, 900, { fit: 'cover' }).png().toBuffer();
}
async function main() {
const env = loadPushEnvironment(process.env);
const payload = readStdinPayload();
const userId = String(payload.userId ?? '').trim();
const title = String(payload.title ?? '').trim();
const content = String(payload.content ?? '').trim();
if (!userId) throw new Error('缺少 userId');
if (!title) throw new Error('缺少草稿标题');
if (!content) throw new Error('缺少草稿正文');
const context = createLightContext(env);
try {
const { credentials, accessToken } = await context.resolveWechat(userId);
const { wechatFetch } = context;
const thumbMediaId = await uploadWechatPermanentThumb(
accessToken,
await loadThumbBuffer(payload.thumb),
{ wechatFetch },
);
const draft = await addWechatDraftArticle(accessToken, {
title: title.slice(0, 64),
author: String(payload.author ?? '').trim() || credentials.author || 'TKMind',
digest: String(payload.digest ?? '').slice(0, 120),
content,
content_source_url: String(payload.contentSourceUrl ?? '').trim() || undefined,
thumb_media_id: thumbMediaId,
need_open_comment: 0,
only_fans_can_comment: 0,
}, { wechatFetch });
return { draftMediaId: draft.draftMediaId, thumbMediaId };
} finally {
await context.close();
}
}
runScript(main);
+220
View File
@@ -0,0 +1,220 @@
/**
* 单条新闻 → 公众号草稿流程的共享上下文。
*
* 流程被拆成四个独立入口(生成 / 传图 / 生成配图 / 提交草稿),只有「生成」需要
* 完整的 Portal + Agent bootstrap;其余三个只要数据库连接和公众号凭证,因此这里
* 提供两级上下文,避免预览和推送也背上几十秒的 bootstrap 成本。
*/
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { createDbPool, isDatabaseConfigured } from '../db.mjs';
import { createMindSpaceWechatMpConfigService, fetchWechatMpAccessToken } from '../mindspace-wechat-mp-config.mjs';
import { resolveMindSpaceAnalyticsConfig } from '../mindspace-analytics.mjs';
import {
resolveMindSpaceServerRuntimeOptions,
resolvePortalH5Root,
} from '../mindspace-runtime-config.mjs';
import { startWorkspaceAssetSyncWatcher } from '../mindspace-workspace-sync.mjs';
import { startWorkspaceThumbnailWatcher } from '../mindspace-workspace-thumbnails.mjs';
import { bootstrapPortalAgentServices } from '../server/portal-agent-services-bootstrap.mjs';
import { bootstrapPortalAuthServices } from '../server/portal-auth-services-bootstrap.mjs';
import { bootstrapPortalDomainServices } from '../server/portal-domain-services-bootstrap.mjs';
import { bootstrapPortalGatewayServices } from '../server/portal-gateway-services-bootstrap.mjs';
import { bootstrapPortalMemorySessionServices } from '../server/portal-memory-session-services-bootstrap.mjs';
import { resolveWechatFetch } from '../wechat-egress-fetch.mjs';
import { loadH5Environment } from './load-env.mjs';
import { loadMemindEnvFiles } from './memind-runtime-profile.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
export const CODE_ROOT = path.join(scriptDir, '..');
export function resolvePortalRoot(env = process.env) {
return path.resolve(String(env.MEMIND_PORTAL_H5_ROOT ?? '/Users/john/Project/Memind').trim());
}
/** 读取 stdin 上的 JSON 载荷。四个脚本统一用这种调用约定。 */
export function readStdinPayload() {
const text = fs.readFileSync(0, 'utf8').trim();
if (!text) throw new Error('缺少 stdin JSON 输入');
return JSON.parse(text);
}
/**
* 统一的环境准备:news-engine 用 MEMIND_DATABASE_URLMemind 侧用 DATABASE_URL。
*/
export function loadPushEnvironment(env = process.env) {
loadH5Environment(scriptDir);
loadMemindEnvFiles(resolvePortalRoot(env));
if (!String(env.DATABASE_URL ?? '').trim() && String(env.MEMIND_DATABASE_URL ?? '').trim()) {
env.DATABASE_URL = String(env.MEMIND_DATABASE_URL).trim();
}
env.MEMIND_PORTAL_H5_ROOT = resolvePortalRoot(env);
env.MEMIND_WORKSPACE_MAINTENANCE = env.MEMIND_WORKSPACE_MAINTENANCE ?? '0';
env.MEMIND_PUSH_SKIP_SCHEMA_INIT = env.MEMIND_PUSH_SKIP_SCHEMA_INIT ?? '1';
if (!isDatabaseConfigured()) {
throw new Error('DATABASE_URL / MEMIND_DATABASE_URL 未配置');
}
return env;
}
/**
* 轻量上下文:只要数据库和公众号凭证,用于传图 / 生成配图 / 提交草稿。
*/
export function createLightContext(env = process.env) {
const pool = createDbPool();
const h5Root = resolvePortalH5Root(CODE_ROOT, env);
const wechatFetch = resolveWechatFetch(env);
const wechatMpConfigService = createMindSpaceWechatMpConfigService(pool, { env, wechatFetch });
return {
pool,
h5Root,
wechatFetch,
wechatMpConfigService,
async resolveWechat(userId) {
const credentials = await wechatMpConfigService.getCredentials(userId);
const token = await fetchWechatMpAccessToken(credentials, { wechatFetch });
return { credentials, accessToken: token.accessToken };
},
async close() {
await pool.end().catch(() => {});
},
};
}
function resolveInitSchemaFn(env = process.env) {
const skip = String(env.MEMIND_PUSH_SKIP_SCHEMA_INIT ?? '1').trim().toLowerCase();
if (skip === '1' || skip === 'true' || skip === 'yes') return async () => {};
if (fs.existsSync(path.join(CODE_ROOT, 'schema.sql'))) return undefined;
console.warn('[news-item] schema.sql 不在 memind-lib,跳过 initSchema(假定生产库已由 Portal 初始化)');
return async () => {};
}
/**
* 完整 Agent 上下文:只有页面生成阶段需要。
*/
export async function bootstrapAgentContext(env = process.env) {
const h5Root = resolvePortalH5Root(CODE_ROOT, env);
const usersRoot = env.H5_USERS_ROOT?.trim() || path.join(h5Root, 'users');
const runtime = resolveMindSpaceServerRuntimeOptions(h5Root, env);
const apiTarget = String(env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006').trim();
const apiSecret = env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
const analyticsConfig = resolveMindSpaceAnalyticsConfig();
const pool = createDbPool();
let userAuth = null;
let sessionSnapshotService = null;
const domainServices = await bootstrapPortalDomainServices({
pool,
h5Root,
env,
runtime,
analyticsConfig,
getUserAuth: () => userAuth,
getSessionSnapshotService: () => sessionSnapshotService,
workspaceMaintenanceEnabled: env.MEMIND_WORKSPACE_MAINTENANCE !== '0',
logger: console,
initSchemaFn: resolveInitSchemaFn(env),
});
const authServices = await bootstrapPortalAuthServices({
pool,
h5Root,
usersRoot,
mindSearchConfigService: domainServices.mindSearchConfigService,
env,
logger: console,
});
userAuth = authServices.userAuth;
const agentServices = await bootstrapPortalAgentServices({
pool,
h5Root,
env,
runtime,
apiTarget,
apiTargets: [apiTarget],
apiSecret,
userAuth,
sessionAccess: authServices.sessionAccess,
subscriptionService: authServices.subscriptionService,
mindSpaceRuntimeAdapter: domainServices.mindSpaceRuntimeAdapter,
mindSpaceAssets: domainServices.mindSpaceAssets,
resolveUserIdByDirKey: domainServices.resolveUserIdByDirKey,
workspaceMaintenanceEnabled: env.MEMIND_WORKSPACE_MAINTENANCE !== '0',
startWorkspaceThumbnailWatcher,
startWorkspaceAssetSyncWatcher,
logger: console,
});
const memorySessionServices = await bootstrapPortalMemorySessionServices({
pool,
h5Root,
env,
llmProviderService: agentServices.llmProviderService,
userAuth,
sessionAccess: authServices.sessionAccess,
logger: console,
});
sessionSnapshotService = memorySessionServices.sessionSnapshotService;
const gatewayServices = bootstrapPortalGatewayServices({
pool,
h5Root,
env,
apiTarget,
apiTargets: [apiTarget],
apiSecret,
userAuth,
sessionAccess: authServices.sessionAccess,
sessionStreamStore: memorySessionServices.sessionStreamStore,
llmProviderService: agentServices.llmProviderService,
subscriptionService: authServices.subscriptionService,
billingConfigService: authServices.billingConfigService,
sessionSnapshotService,
conversationMemoryService: memorySessionServices.conversationMemoryService,
memoryV2: memorySessionServices.memoryV2,
systemDisclosurePolicyService: memorySessionServices.systemDisclosurePolicyService,
wechatCursorExecutorPolicyService: memorySessionServices.wechatCursorExecutorPolicyService,
mindSpaceAssets: domainServices.mindSpaceAssets,
directChatService: memorySessionServices.directChatService,
chatIntentRouter: memorySessionServices.chatIntentRouter,
syncUserGeneratedPages: domainServices.mindSpacePageSync
? (userId, options) => domainServices.mindSpacePageSync.syncUserGeneratedPages(userId, options)
: async () => {},
isSessionPageDeliveryActive: () => false,
experienceService: agentServices.experienceService,
});
return {
pool,
h5Root,
userAuth,
tkmindProxy: gatewayServices.tkmindProxy,
agentRunGateway: gatewayServices.agentRunGateway,
cursorExecutorPolicyService: memorySessionServices.wechatCursorExecutorPolicyService,
sessionSnapshotService,
wechatMpConfigService: createMindSpaceWechatMpConfigService(pool, { env }),
async close() {
await pool.end().catch(() => {});
},
};
}
/** 四个脚本统一的 stdout 协议:最后一行是 JSON。 */
export function runScript(main) {
main()
.then((result) => {
console.log(JSON.stringify({ ok: true, ...result }));
})
.catch((error) => {
console.log(JSON.stringify({
ok: false,
error: error instanceof Error ? error.message : String(error),
code: error?.code ?? null,
}));
process.exit(1);
});
}
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env node
/**
* 配图工具:把图片变成微信素材库 URL。预览和草稿共用这批 URL,所以预览里看到的
* 图就是草稿里的图。
*
* stdin 三种模式:
* { mode: 'upload-refs', userId, publishDir, htmlRelativePath, refs: [string] }
* { mode: 'upload-buffer', userId, publishDir, filename, dataBase64 }
* { mode: 'generate', userId, publishDir, prompt, preset: 'hero'|'inline_image' }
*
* stdout { ok, imageMap: { [ref]: { url, localPath } } }
*/
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { randomUUID } from 'node:crypto';
import { createImageMakeClientFromEnv } from '../image-make-client.mjs';
import { uploadWechatArticleContentImage } from '../wechat-news-morning-draft.mjs';
import {
createLightContext,
loadPushEnvironment,
readStdinPayload,
runScript,
} from './news-item-wechat-context.mjs';
const PRESETS = Object.freeze({
hero: { presetId: 'memind_dark_hero', width: 1024, height: 576 },
inline_image: { presetId: 'memind_square_illustration', width: 1024, height: 1024 },
});
function normalizeWechatImageUrl(value) {
return String(value ?? '').trim().replace(/^http:\/\//i, 'https://');
}
/** 与 mindspace-wechat-page-draft.mjs 的 resolveImageFilePath 保持一致。 */
function resolveImageFilePath(ref, publishDir, htmlRelativePath = '') {
const value = String(ref ?? '').trim();
if (!value || /^data:/i.test(value)) return null;
if (/^https?:\/\//i.test(value)) return value;
const clean = value.replace(/[?#].*$/, '');
const htmlDir = htmlRelativePath
? path.dirname(path.join(publishDir, htmlRelativePath))
: publishDir;
const candidates = [
path.resolve(htmlDir, clean.replace(/^\.\//, '')),
path.resolve(path.join(publishDir, 'public'), clean.replace(/^\.\//, '')),
path.resolve(publishDir, clean.replace(/^\.\//, '')),
path.resolve(publishDir, clean.replace(/^\/+/, '')),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
}
async function toPngBuffer(buffer, hint = '') {
if (!/\.(webp|svg|avif)$/i.test(hint)) return buffer;
const sharp = (await import('sharp')).default;
return sharp(buffer).png().toBuffer();
}
async function readSource(source) {
if (/^https?:\/\//i.test(source)) {
const response = await fetch(source, { signal: AbortSignal.timeout(20_000) });
if (!response.ok) throw new Error(`下载配图失败(${response.status}):${source}`);
return { buffer: Buffer.from(await response.arrayBuffer()), localPath: null };
}
return { buffer: fs.readFileSync(source), localPath: source };
}
async function uploadOne(accessToken, source, { filename, wechatFetch }) {
const { buffer, localPath } = await readSource(source);
const png = await toPngBuffer(buffer, source);
const url = normalizeWechatImageUrl(
await uploadWechatArticleContentImage(accessToken, png, {
filename: `${path.basename(filename, path.extname(filename)) || 'image'}.png`,
wechatFetch,
}),
);
if (!url) throw new Error(`微信素材上传未返回地址:${filename}`);
return { url, localPath };
}
function assetsDir(publishDir) {
const dir = path.join(publishDir, 'public', 'assets', 'news-item');
fs.mkdirSync(dir, { recursive: true });
return dir;
}
async function handleUploadRefs({ accessToken, payload, wechatFetch }) {
const publishDir = String(payload.publishDir ?? '').trim();
const htmlRelativePath = String(payload.htmlRelativePath ?? '').trim();
const refs = [...new Set((payload.refs ?? []).map((ref) => String(ref ?? '').trim()).filter(Boolean))];
const imageMap = {};
const failures = [];
for (const ref of refs) {
const source = resolveImageFilePath(ref, publishDir, htmlRelativePath);
if (!source) {
failures.push(`找不到配图文件:${ref}`);
continue;
}
try {
imageMap[ref] = await uploadOne(accessToken, source, { filename: path.basename(ref), wechatFetch });
} catch (error) {
failures.push(`${ref}${error instanceof Error ? error.message : String(error)}`);
}
}
return { imageMap, failures };
}
async function handleUploadBuffer({ accessToken, payload, wechatFetch }) {
const publishDir = String(payload.publishDir ?? '').trim();
const filename = String(payload.filename ?? 'upload.png').trim();
const data = Buffer.from(String(payload.dataBase64 ?? ''), 'base64');
if (!data.length) throw new Error('上传内容为空');
const ext = path.extname(filename).toLowerCase() || '.png';
const localName = `upload-${randomUUID().slice(0, 8)}${ext}`;
const localPath = publishDir ? path.join(assetsDir(publishDir), localName) : null;
if (localPath) fs.writeFileSync(localPath, data);
const png = await toPngBuffer(data, filename);
const url = normalizeWechatImageUrl(
await uploadWechatArticleContentImage(accessToken, png, {
filename: `${path.basename(localName, ext)}.png`,
wechatFetch,
}),
);
if (!url) throw new Error('微信素材上传未返回地址');
return { imageMap: { [localName]: { url, localPath } }, failures: [] };
}
async function handleGenerate({ accessToken, payload, env, wechatFetch }) {
const client = createImageMakeClientFromEnv(env);
if (!client) throw new Error('未配置 IMAGE_MAKE_BASE_URL / IMAGE_MAKE_TOKEN,无法重新生成配图');
const prompt = String(payload.prompt ?? '').trim();
if (!prompt) throw new Error('缺少配图描述');
const preset = PRESETS[String(payload.preset ?? 'inline_image')] ?? PRESETS.inline_image;
const publishDir = String(payload.publishDir ?? '').trim();
const generated = await client.generateImage({
prompt,
presetId: preset.presetId,
width: preset.width,
height: preset.height,
consumerRef: `news-item:${payload.userId ?? ''}`,
});
const localName = `gen-${randomUUID().slice(0, 8)}.webp`;
const localPath = publishDir ? path.join(assetsDir(publishDir), localName) : null;
if (localPath) fs.writeFileSync(localPath, generated.buffer);
const png = await toPngBuffer(generated.buffer, '.webp');
const url = normalizeWechatImageUrl(
await uploadWechatArticleContentImage(accessToken, png, {
filename: `${path.basename(localName, '.webp')}.png`,
wechatFetch,
}),
);
if (!url) throw new Error('微信素材上传未返回地址');
await client.acknowledge(generated.jobId, localName).catch(() => {});
return { imageMap: { [localName]: { url, localPath } }, failures: [] };
}
const HANDLERS = {
'upload-refs': handleUploadRefs,
'upload-buffer': handleUploadBuffer,
generate: handleGenerate,
};
async function main() {
const env = loadPushEnvironment(process.env);
const payload = readStdinPayload();
const userId = String(payload.userId ?? '').trim();
if (!userId) throw new Error('缺少 userId');
const handler = HANDLERS[String(payload.mode ?? 'upload-refs')];
if (!handler) throw new Error(`未知模式:${payload.mode}`);
const context = createLightContext(env);
try {
const { accessToken } = await context.resolveWechat(userId);
return await handler({ accessToken, payload, env, wechatFetch: context.wechatFetch });
} finally {
await context.close();
}
}
runScript(main);