Files
memind/scripts/news-item-wechat-images.mjs
john 028bb18dc0 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>
2026-09-23 10:07:32 +08:00

185 lines
7.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);