028bb18dc0
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>
185 lines
7.1 KiB
JavaScript
185 lines
7.1 KiB
JavaScript
#!/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);
|