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:
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 单条新闻落地页的模板契约。
|
||||
*
|
||||
* 这份契约同时约束两端:Agent 按它生成 HTML,news-engine 的块解析器按它把 HTML
|
||||
* 拆成可编辑的块。类名是两端唯一的接口,改动必须同步 lib/wechat-draft-blocks.mjs。
|
||||
*/
|
||||
|
||||
export const NEWS_ITEM_VERSION = '2026-09-22-v4-human-voice';
|
||||
|
||||
export const NEWS_ITEM_MIN_IMAGES = 6;
|
||||
export const NEWS_ITEM_MIN_SECTIONS = 5;
|
||||
export const NEWS_ITEM_MIN_CHARS = 1200;
|
||||
|
||||
export const NEWS_ITEM_STYLES = Object.freeze({
|
||||
'wechat-feature': {
|
||||
id: 'wechat-feature',
|
||||
label: '公众号深度稿',
|
||||
description: '导语 + 可选过渡 + 5~7 个小节(长短可不一,每节配图)+ 结尾编辑观点',
|
||||
minImages: NEWS_ITEM_MIN_IMAGES,
|
||||
minSections: NEWS_ITEM_MIN_SECTIONS,
|
||||
minChars: NEWS_ITEM_MIN_CHARS,
|
||||
},
|
||||
brief: {
|
||||
id: 'brief',
|
||||
label: '快讯图文',
|
||||
description: '紧凑结构:导语 + 3~4 个小节,适合突发短讯',
|
||||
minImages: 4,
|
||||
minSections: 3,
|
||||
minChars: 700,
|
||||
},
|
||||
});
|
||||
|
||||
const BRIEF_CATEGORIES = new Set(['快讯', '突发', 'breaking']);
|
||||
|
||||
export function resolveNewsItemStyle(newsItem = {}, env = process.env) {
|
||||
const explicit = String(env.MEMIND_NEWS_ITEM_STYLE ?? '').trim();
|
||||
if (explicit && NEWS_ITEM_STYLES[explicit]) return explicit;
|
||||
const requested = String(newsItem.layoutStyle ?? '').trim();
|
||||
if (requested && NEWS_ITEM_STYLES[requested]) return requested;
|
||||
const category = String(newsItem.category ?? '').trim().toLowerCase();
|
||||
if (BRIEF_CATEGORIES.has(category)) return 'brief';
|
||||
return 'wechat-feature';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 结构契约(写进 Agent 提示词,也是块解析器的依据)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STRUCTURE_CONTRACT = `【HTML 结构硬约束|类名不可改,下游要按类名解析】
|
||||
<article class="news-item-article" data-style="{{STYLE}}">
|
||||
<header class="article-hero">
|
||||
<img class="hero-img" src="assets/hero.webp" alt="封面描述">
|
||||
<h1 class="article-title">标题</h1>
|
||||
<p class="article-meta">来源 · 日期</p>
|
||||
</header>
|
||||
<p class="article-lede">导语:80~140 字。从一个具体细节、问题或场景切入,不要以「近日/据报道」起笔。</p>
|
||||
<nav class="article-toc"><ol><li>可选:用一句话预告下文,不要用「本文看点」作标题</li></ol></nav>
|
||||
<section class="article-section">
|
||||
<h2>具体的小节标题(写清本节讲什么)</h2>
|
||||
<p>正文段落</p>
|
||||
<figure><img src="assets/s1.webp" alt="配图描述"><figcaption>图说</figcaption></figure>
|
||||
</section>
|
||||
… 重复 {{MIN_SECTIONS}}~7 个 article-section,段落数可 1~5 段不等 …
|
||||
<section class="article-verdict">
|
||||
<h2>与题材相关的具体问题(禁止用「怎么看」「总结」)</h2>
|
||||
<p>2~3 段编辑判断,可出现「我们认为」「值得留意的是」。</p>
|
||||
</section>
|
||||
</article>`;
|
||||
|
||||
const IMAGE_RULES = `【配图硬约束】
|
||||
1. 至少 {{MIN_IMAGES}} 张真实 <img>:hero 1 张 + 每个 article-section 各 1 张。
|
||||
2. 先 load_skill → image-generation 生成配图,再 load_skill → static-page-publish 落盘。
|
||||
3. hero 用 hero preset;小节配图用 inline_image preset。
|
||||
4. 已有新闻原图可作为 hero 参考,但小节配图必须逐个生成,不允许复用同一张。
|
||||
5. 禁止 SVG 占位图、禁止 data: URI、禁止出现没有配图的 article-section。
|
||||
6. <meta name="mindspace-cover" content="..."> 必须与 hero-img 同源。`;
|
||||
|
||||
const WRITING_RULES = `【写作硬约束】
|
||||
1. 正文纯文字不少于 {{MIN_CHARS}} 字(不含标签与空白)。
|
||||
2. 每个 article-section 1~5 段均可,段落长短错落;不要写成「首先/其次/最后」的要点罗列。
|
||||
3. 小节标题必须具体(写清这一节讲什么),禁止「背景」「影响」「怎么看」「总结」等空标题。
|
||||
4. 只使用 <h1> <h2> <p> <img> <figure> <figcaption> <nav> <ol> <li> <strong> <a> <section> <header> <article>,
|
||||
加粗只用 <strong>,链接只用 <a href>。不要用表格、不要用行内 style 之外的复杂布局。
|
||||
5. 事实必须来自给定来源,不要编造数字、人名和时间。至少引用 1 处来源中的具体数字、时间或原话,用「据…报道」标注。
|
||||
6. 全文保持一位编辑在说话的感觉,可用「我们认为」「值得留意的是」,避免百科全书口吻。`;
|
||||
|
||||
const ANTI_AI_RULES = `【去 AI 味|与结构同等重要】
|
||||
1. 禁止出现:本文看点、一文读懂、深度解读、全面解析、综上所述、值得注意的是、不可否认、
|
||||
随着…不断发展、在当今社会、引发广泛关注、不容忽视、从某种意义上说。
|
||||
2. 禁止以「近日」「近期」「据报道」作为文章第一句。
|
||||
3. article-toc 可以省略;若保留,nav 内不要出现「本文看点」字样,只用自然语言预告。
|
||||
4. article-verdict 的 h2 必须是与新闻相关的具体问题(如「油价还会涨多久?」),不要用「怎么看」。
|
||||
5. 标题要信息量具体,避免「深度解读 XXX」「一文看懂 XXX」。
|
||||
6. 写完后默读一遍:如果像通稿汇总而非编辑评论,就重写导语和结尾。`;
|
||||
|
||||
function fill(template, style) {
|
||||
const spec = NEWS_ITEM_STYLES[style] ?? NEWS_ITEM_STYLES['wechat-feature'];
|
||||
return template
|
||||
.replace(/\{\{STYLE\}\}/g, spec.id)
|
||||
.replace(/\{\{MIN_IMAGES\}\}/g, String(spec.minImages))
|
||||
.replace(/\{\{MIN_SECTIONS\}\}/g, String(spec.minSections))
|
||||
.replace(/\{\{MIN_CHARS\}\}/g, String(spec.minChars));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成给 Agent 的任务描述。
|
||||
*/
|
||||
export function buildNewsItemGenerationTaskSpec(newsItem = {}, env = process.env) {
|
||||
const style = resolveNewsItemStyle(newsItem, env);
|
||||
const spec = NEWS_ITEM_STYLES[style];
|
||||
const title = String(newsItem.canonicalTitle ?? newsItem.title ?? '').trim();
|
||||
const sourceLines = [
|
||||
title ? `标题:${title}` : '',
|
||||
newsItem.category ? `栏目:${newsItem.category}` : '',
|
||||
newsItem.snippet ? `摘要:${newsItem.snippet}` : '',
|
||||
newsItem.url ? `原文链接:${newsItem.url}` : '',
|
||||
newsItem.sourceDomain ? `来源站点:${newsItem.sourceDomain}` : '',
|
||||
newsItem.image ? `新闻原图:${newsItem.image}` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
const voiceLine = String(newsItem.accountVoice ?? '').trim();
|
||||
const voiceRules = voiceLine
|
||||
? [
|
||||
'【本号口吻】',
|
||||
voiceLine,
|
||||
'全文语气、节奏、用词须符合上述定位;仍须遵守下方去 AI 味规则,不得写成通稿汇总。',
|
||||
'',
|
||||
]
|
||||
: [];
|
||||
|
||||
const taskSpec = [
|
||||
`围绕下面这条新闻,写一篇可以直接发公众号的${spec.label}并生成静态页面。`,
|
||||
`版式:${spec.description}`,
|
||||
newsItem.accountLabel ? `发布账号:${newsItem.accountLabel}` : '',
|
||||
'',
|
||||
'【新闻素材】',
|
||||
...sourceLines,
|
||||
'',
|
||||
...voiceRules,
|
||||
fill(STRUCTURE_CONTRACT, style),
|
||||
'',
|
||||
fill(IMAGE_RULES, style),
|
||||
'',
|
||||
fill(WRITING_RULES, style),
|
||||
'',
|
||||
fill(ANTI_AI_RULES, style),
|
||||
'',
|
||||
`【交付校验】写完后自查:article-section ≥ ${spec.minSections} 个、<img> ≥ ${spec.minImages} 张、`,
|
||||
`正文纯文字 ≥ ${spec.minChars} 字。不达标就继续补写补图,不要提前交付。`,
|
||||
`模板版本:${NEWS_ITEM_VERSION}`,
|
||||
].join('\n');
|
||||
|
||||
return { style, spec, taskSpec };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 生成结果校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function countNewsItemImages(html) {
|
||||
const matches = String(html ?? '').match(/<img\b[^>]*>/gi) ?? [];
|
||||
return matches.filter((tag) => {
|
||||
const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1] ?? '';
|
||||
if (!src) return false;
|
||||
if (/^data:/i.test(src)) return false;
|
||||
return !/\.svg(\?|$)/i.test(src);
|
||||
}).length;
|
||||
}
|
||||
|
||||
export function countNewsItemSections(html) {
|
||||
return (String(html ?? '').match(/class=["'][^"']*\barticle-section\b[^"']*["']/gi) ?? []).length;
|
||||
}
|
||||
|
||||
export function countNewsItemTextChars(html) {
|
||||
const scope = /<article\b[^>]*>([\s\S]*?)<\s*\/\s*article\s*>/i.exec(String(html ?? ''))?.[1]
|
||||
?? String(html ?? '');
|
||||
return scope
|
||||
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/&[a-z#0-9]+;/gi, '')
|
||||
.replace(/\s+/g, '')
|
||||
.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成阶段的质量闸门:Agent 交付的落地页是否达标。
|
||||
* 注意这是「生成是否合格」,推送前还有一道基于编辑后块结构的闸门。
|
||||
*/
|
||||
export function validateNewsItemHtml(html, { style = 'wechat-feature' } = {}) {
|
||||
const spec = NEWS_ITEM_STYLES[style] ?? NEWS_ITEM_STYLES['wechat-feature'];
|
||||
const source = String(html ?? '');
|
||||
const imageCount = countNewsItemImages(source);
|
||||
const sectionCount = countNewsItemSections(source);
|
||||
const charCount = countNewsItemTextChars(source);
|
||||
const issues = [];
|
||||
|
||||
if (!source.trim()) issues.push('页面为空');
|
||||
if (!/<h1\b/i.test(source)) issues.push('缺少 <h1> 标题');
|
||||
if (imageCount < spec.minImages) issues.push(`配图不足(${imageCount}/${spec.minImages})`);
|
||||
if (sectionCount < spec.minSections) issues.push(`正文小节不足(${sectionCount}/${spec.minSections})`);
|
||||
if (charCount < spec.minChars) issues.push(`正文字数不足(${charCount}/${spec.minChars})`);
|
||||
if (/<img\b[^>]*src=["'][^"']*\.svg/i.test(source)) issues.push('存在 SVG 占位图');
|
||||
|
||||
return {
|
||||
ok: issues.length === 0,
|
||||
issues,
|
||||
style: spec.id,
|
||||
imageCount,
|
||||
sectionCount,
|
||||
charCount,
|
||||
version: NEWS_ITEM_VERSION,
|
||||
};
|
||||
}
|
||||
+148
-4
@@ -322,6 +322,131 @@ export async function finalizeScheduledTaskPageDelivery({
|
||||
return readyPaths;
|
||||
}
|
||||
|
||||
function scheduledTaskSleep(delayMs) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等 Agent 把预期 HTML 写入磁盘并稳定(大小连续两次不变)。
|
||||
* 用于在 Goose 迟迟不发 Finish 时提前结束生成,避免空等会话收尾。
|
||||
*/
|
||||
export async function waitForExpectedPageFile({
|
||||
publishDir,
|
||||
relativePath,
|
||||
timeoutMs = 15 * 60 * 1000,
|
||||
pollIntervalMs = 2000,
|
||||
stablePolls = 2,
|
||||
minBytes = 100,
|
||||
isPageReady = null,
|
||||
sleepFn = scheduledTaskSleep,
|
||||
} = {}) {
|
||||
const normalized = String(relativePath ?? '').trim().replace(/^\/+/, '');
|
||||
if (!normalized || !publishDir) return null;
|
||||
const filePath = path.join(publishDir, normalized);
|
||||
const deadline = Date.now() + Math.max(1, Number(timeoutMs) || 1);
|
||||
let lastSize = -1;
|
||||
let stable = 0;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const size = fs.statSync(filePath).size;
|
||||
if (size >= minBytes && size === lastSize) {
|
||||
stable += 1;
|
||||
if (stable >= Math.max(1, Number(stablePolls) || 1)) {
|
||||
if (typeof isPageReady === 'function') {
|
||||
const html = fs.readFileSync(filePath, 'utf8');
|
||||
if (!isPageReady(html)) {
|
||||
stable = 0;
|
||||
await sleepFn(Math.max(250, Number(pollIntervalMs) || 2000));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
} else {
|
||||
stable = 0;
|
||||
lastSize = size;
|
||||
}
|
||||
} else {
|
||||
stable = 0;
|
||||
lastSize = -1;
|
||||
}
|
||||
} catch {
|
||||
stable = 0;
|
||||
lastSize = -1;
|
||||
}
|
||||
await sleepFn(Math.max(250, Number(pollIntervalMs) || 2000));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function awaitGooseSessionOrExpectedPage({
|
||||
task,
|
||||
publishDir,
|
||||
tkmindProxy,
|
||||
sessionId,
|
||||
requestId,
|
||||
userMessage,
|
||||
timeoutMs,
|
||||
logger,
|
||||
}) {
|
||||
const sessionWait = tkmindProxy.submitSessionReplyAndAwaitFinishForUser(
|
||||
task.userId,
|
||||
sessionId,
|
||||
requestId,
|
||||
userMessage,
|
||||
{ timeoutMs },
|
||||
);
|
||||
const expectedPath = String(task.expectedRelativePath ?? '').trim();
|
||||
if (!expectedPath || !publishDir) {
|
||||
await sessionWait;
|
||||
return { earlyReadyPath: null };
|
||||
}
|
||||
|
||||
let earlyReadyPath = null;
|
||||
const pageWait = waitForExpectedPageFile({
|
||||
publishDir,
|
||||
relativePath: expectedPath,
|
||||
timeoutMs,
|
||||
isPageReady: typeof task.isExpectedPageReady === 'function'
|
||||
? task.isExpectedPageReady
|
||||
: null,
|
||||
}).then((found) => (found
|
||||
? 'page'
|
||||
: new Promise(() => {})));
|
||||
|
||||
try {
|
||||
const winner = await Promise.race([
|
||||
sessionWait.then(() => 'session'),
|
||||
pageWait,
|
||||
]);
|
||||
if (winner === 'page') {
|
||||
earlyReadyPath = expectedPath;
|
||||
sessionWait.catch((error) => {
|
||||
logger.warn?.('[ScheduledTask] session still open after page materialized; continuing', {
|
||||
taskId: task.id,
|
||||
sessionId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
logger.info?.('[ScheduledTask] proceeding without session Finish; page materialized', {
|
||||
taskId: task.id,
|
||||
sessionId,
|
||||
relativePath: expectedPath,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
pageWait.catch(() => {});
|
||||
sessionWait.catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { earlyReadyPath };
|
||||
}
|
||||
|
||||
export async function awaitScheduledTaskPageDelivery({
|
||||
pool,
|
||||
userId,
|
||||
@@ -689,13 +814,16 @@ export async function executeScheduledTask(task, {
|
||||
requestId,
|
||||
});
|
||||
|
||||
await tkmindProxy.submitSessionReplyAndAwaitFinishForUser(
|
||||
task.userId,
|
||||
const { earlyReadyPath } = await awaitGooseSessionOrExpectedPage({
|
||||
task,
|
||||
publishDir,
|
||||
tkmindProxy,
|
||||
sessionId,
|
||||
requestId,
|
||||
userMessage,
|
||||
{ timeoutMs },
|
||||
);
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
|
||||
messages = await refreshScheduledTaskMessages({
|
||||
userId: task.userId,
|
||||
@@ -703,6 +831,22 @@ export async function executeScheduledTask(task, {
|
||||
tkmindProxy,
|
||||
sessionSnapshotService,
|
||||
});
|
||||
|
||||
if (earlyReadyPath) {
|
||||
let deliveryText = extractScheduledTaskDeliveryText(messages, task);
|
||||
if (task.userId) {
|
||||
const links = [`${resolveScheduledTaskPublicBaseUrl()}/MindSpace/${task.userId}/${earlyReadyPath}`];
|
||||
deliveryText = `${deliveryText}\n\n页面链接:\n${links.join('\n')}`.trim();
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
requestId,
|
||||
deliveryText,
|
||||
messages,
|
||||
readyPaths: [earlyReadyPath],
|
||||
executor: 'goose',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let deliveryText = extractScheduledTaskDeliveryText(messages, task);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resendScheduledTaskWechatForReadyPage,
|
||||
resolveScheduledTaskDeliveryPollIntervalMs,
|
||||
resolveScheduledTaskDeliveryRetryDelaysMs,
|
||||
waitForExpectedPageFile,
|
||||
} from './scheduled-task-executor.mjs';
|
||||
|
||||
test('buildScheduledTaskExecutionPrompt includes task spec and automation marker', () => {
|
||||
@@ -296,3 +297,59 @@ test('resendScheduledTaskWechatForReadyPage skips suspended users', async () =>
|
||||
assert.equal(sent, false);
|
||||
assert.equal(sendCount, 0);
|
||||
});
|
||||
|
||||
test('waitForExpectedPageFile resolves once html file size stabilizes', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-page-'));
|
||||
const publishDir = path.join(root, 'MindSpace', 'user-1');
|
||||
const relativePath = 'public/news-item-demo.html';
|
||||
const filePath = path.join(publishDir, relativePath);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
let polls = 0;
|
||||
const pending = waitForExpectedPageFile({
|
||||
publishDir,
|
||||
relativePath,
|
||||
pollIntervalMs: 20,
|
||||
stablePolls: 2,
|
||||
minBytes: 10,
|
||||
sleepFn: (delayMs) => new Promise((resolve) => {
|
||||
polls += 1;
|
||||
setTimeout(async () => {
|
||||
if (polls === 2) {
|
||||
await fs.writeFile(filePath, '<html>draft</html>', 'utf8');
|
||||
}
|
||||
resolve(delayMs);
|
||||
}, 0);
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(await pending, relativePath);
|
||||
});
|
||||
|
||||
test('waitForExpectedPageFile waits until isPageReady passes', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-page-ready-'));
|
||||
const publishDir = path.join(root, 'MindSpace', 'user-2');
|
||||
const relativePath = 'public/news-item-ready.html';
|
||||
const filePath = path.join(publishDir, relativePath);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
let polls = 0;
|
||||
const pending = waitForExpectedPageFile({
|
||||
publishDir,
|
||||
relativePath,
|
||||
pollIntervalMs: 20,
|
||||
stablePolls: 2,
|
||||
minBytes: 10,
|
||||
isPageReady: (html) => html.includes('DONE'),
|
||||
sleepFn: (delayMs) => new Promise((resolve) => {
|
||||
polls += 1;
|
||||
setTimeout(async () => {
|
||||
const body = polls >= 4 ? '<html>DONE</html>' : '<html>draft</html>';
|
||||
await fs.writeFile(filePath, body, 'utf8');
|
||||
resolve(delayMs);
|
||||
}, 0);
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(await pending, relativePath);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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_URL,Memind 侧用 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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user