Files
ttstd-infinite-canvas/web/scripts/import-inspirations.mjs
Medivh 88de4ce422 feat(inspirations): 灵感库 - 将内置英文提示词统一为中文 (#763)
* feat(inspirations): 灵感库 - 统一中文正文入口并校验译文版本

* feat(inspirations): 灵感库 - 发布完整中文提示词并阻止旧译文覆盖

统一 1319 条外部提示词和 329 条画廊引用,保留来源与模板参数;校验全部原文摘要,防止上游更新后误用过期中文。

验证:Bun 1.3.9 全量 2261 项测试通过,前端构建与 lint 通过,目录身份及媒体元数据对比无变化。
2026-10-13 19:12:44 +08:00

572 lines
27 KiB
JavaScript
Raw 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.
/**
* 灵感目录导入:把外部提示词源同步成本地目录。
*
* 无新增依赖:只用 Bun/Node 内置 fetch / fs / path,缩略图用 Bun 自带的 Bun.Image。
* 产物分两层:
* - public/inspirations/*.json 全量目录(每个来源一个文件,按需拉取)
* - public/inspiration-thumbs/<来源>/*.webp 本地压缩缩略图,只给每个来源排在前面的若干条
* - src/lib/inspirations/highlights.ts 打进包里的精选,供首页纵深画廊即时渲染
*
* 用法:
* bun scripts/import-inspirations.mjs [--only seedance|haohaoxue|youmind] [--thumbs 120] [--force]
* bun scripts/import-inspirations.mjs --seedance-input <cases.json> --haohaoxue-input <prompts.html>
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { localizeInspirationEntries } from "./inspiration-localization.mjs";
const WEB_DIR = resolve(import.meta.dir, "..");
const PUBLIC_DIR = join(WEB_DIR, "public", "inspirations");
const SHOT_DIR = join(WEB_DIR, "public");
/** 首页画廊的池子。放 public 而不是打进包:正文很长,进包会让首屏 JS 白背几百 KB。 */
const GALLERY_POOL_FILE = join(PUBLIC_DIR, "gallery-pool.json");
/** 只留各来源的上游版本号,几行字,进包无所谓。 */
const REVISIONS_FILE = join(WEB_DIR, "src", "lib", "inspirations", "source-revisions.ts");
const UA = "Mozilla/5.0 (compatible; yingce-inspiration-import/1.0; +https://github.com/ddcat-ai/open-ai-canvas)";
const SEEDANCE_REPO = "LearnPrompt/awesome-seedance";
const HAOHAOXUE_PAGE = "https://www.haohaoxue.com/prompts/";
/** YouMind 的提示词库,README 里已经是中文,且带分类、配图与作者。 */
const YOUMIND_REPOS = [
{ repo: "YouMind-OpenLab/awesome-nano-banana-pro-prompts", slug: "nano-banana" },
{ repo: "YouMind-OpenLab/awesome-gpt-image-2", slug: "gpt-image-2" },
];
/** 卡片描述最多两行,完整正文仍在 prompt 里。 */
const DESCRIPTION_LIMIT = 44;
/**
* 本地缩略图规格。上游封面动辄几百 KB 到几 MB(好好学平均 727KB、YouMind 最大 7.6MB),
* 直接引用的话首页一次要拉 5MB 以上,低带宽服务器上要等很久。
* 统一压到 560px webp,实测 2MB 的原图能压到 55KB,约 37 倍。
*/
const THUMB_WIDTH = 560;
const THUMB_QUALITY = 72;
/** 压完还不到这个大小,基本就是纯色或影片片头白板,放进画廊就是一块空白。 */
const MIN_THUMB_BYTES = 4000;
const THUMB_DIR = "inspiration-thumbs";
/**
* 上游封面不可用的条目:封面本身是影片片头白板,画面上只有一个白底和 "MUDA / FILM 01",
* 放在画廊里就是一块空白。清单按 slug 写死,重新同步不会把它带回来。
*/
const EXCLUDED_SEEDANCE_SLUGS = new Set(["seedance-make-a-modern-slick-and-punchy-video-for-a-modern-startup-that-works-on-infere-07ba4673539c"]);
/**
* 上游模板分类的中文标签。id 来自 awesome-seedance 的 data/case-taxonomy.json,
* 它只给 id 不给名字,所以译名维护在这里;出现新 id 时会告警并在页面上回落成 id 本身。
*/
const SEEDANCE_CATEGORY_LABELS = {
"handheld-ugc-vlog": "手持 UGC Vlog",
"anime-style-lock": "动漫风格锁定",
"meme-comedy": "梗图喜剧",
"combat-choreography": "打斗编排",
"storyboard-grid-to-video": "分镜表转视频",
"character-reference-lock": "角色一致性",
"time-freeze-rewind": "时间冻结与倒放",
"cinematic-narrative-short": "电影感叙事短片",
"car-vehicle": "汽车与载具",
"sports-extreme": "运动极限",
"travel-city-walk": "旅行城市漫步",
"process-transformation-montage": "过程变身蒙太奇",
"ugc-creator-review": "达人测评",
"fashion-lookbook": "时尚造型册",
"pet-animal": "宠物与动物",
"3d-cartoon": "3D 卡通",
"timeline-shot-script": "时间轴分镜脚本",
"product-commercial-shotlist": "产品广告分镜",
"stop-motion-cadence": "定格动画节奏",
"epic-fantasy-scifi": "史诗奇幻科幻",
"dialogue-performance-beats": "对白表演节拍",
"music-beat-sync-mv": "音乐卡点 MV",
"horror-suspense": "恐怖悬疑",
"pov-continuous-take": "POV 长镜头",
"game-ui-livestream": "游戏 UI 直播",
"food-asmr": "美食 ASMR",
"retro-found-footage": "复古伪纪录",
};
/** 好好学 AI 的分类码译名,取自站点「分类」筛选器上的中文标签。 */
const HAOHAOXUE_CATEGORY_LABELS = {
poster: "海报设计",
portrait: "人像摄影",
illustration: "插画创作",
product: "产品拍摄",
anime: "动漫风格",
"3d": "3D 渲染",
japan: "日式美学",
brand: "品牌设计",
fashion: "时尚编辑",
social: "社交媒体",
other: "其他",
};
function parseArgs(argv) {
const options = { only: "", thumbs: 120, force: false, seedanceInput: "", haohaoxueInput: "" };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--only") options.only = argv[++index];
else if (arg === "--thumbs") options.thumbs = Number(argv[++index]);
else if (arg === "--seedance-input") options.seedanceInput = argv[++index];
else if (arg === "--haohaoxue-input") options.haohaoxueInput = argv[++index];
else if (arg === "--force") options.force = true;
else throw new Error(`未知参数: ${arg}`);
}
const sourceNames = SOURCE_PIPELINES.map((pipeline) => pipeline.name);
if (options.only && !sourceNames.includes(options.only)) throw new Error(`--only 只支持 ${sourceNames.join(" / ")},收到: ${options.only}`);
if (!Number.isInteger(options.thumbs) || options.thumbs <= 0) throw new Error(`--thumbs 必须是正整数,收到: ${options.thumbs}`);
return options;
}
const CJK = /[㐀-䶿一-鿿]/;
function hasCjk(value) {
return CJK.test(value ?? "");
}
function clip(value, limit) {
const text = (value ?? "").replace(/\s+/g, " ").trim();
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
}
function sanitizeFileName(value) {
return String(value)
.replace(/[^a-zA-Z0-9._-]/g, "-")
.slice(0, 90);
}
async function fetchText(url, { accept } = {}) {
const response = await fetch(url, { headers: { "User-Agent": UA, ...(accept ? { Accept: accept } : {}) } });
if (!response.ok) throw new Error(`请求失败 ${url}: HTTP ${response.status}`);
return response.text();
}
async function downloadImage(url, target) {
const response = await fetch(url, { headers: { "User-Agent": UA, Referer: new URL(url).origin } });
if (!response.ok) throw new Error(`下载封面失败 ${url}: HTTP ${response.status}`);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, Buffer.from(await response.arrayBuffer()));
}
// ── awesome-seedance ────────────────────────────────────────────────────────
async function resolveSeedanceRevision() {
const response = await fetch(`https://api.github.com/repos/${SEEDANCE_REPO}/commits/main`, {
headers: { Accept: "application/vnd.github.sha", "User-Agent": UA },
});
if (!response.ok) throw new Error(`取 seedance revision 失败: HTTP ${response.status}`);
return (await response.text()).trim();
}
async function loadSeedance(options) {
if (options.seedanceInput) {
const text = readFileSync(resolve(options.seedanceInput), "utf8");
const parsed = JSON.parse(text);
return { revision: "local", raw: Array.isArray(parsed) ? parsed : parsed.cases };
}
const revision = await resolveSeedanceRevision();
const text = await fetchText(`https://raw.githubusercontent.com/${SEEDANCE_REPO}/${revision}/data/cases.json`);
const parsed = JSON.parse(text);
const taxonomy = JSON.parse(await fetchText(`https://raw.githubusercontent.com/${SEEDANCE_REPO}/${revision}/data/case-taxonomy.json`));
const raw = Array.isArray(parsed) ? parsed : parsed.cases;
if (!Array.isArray(raw)) throw new Error("seedance cases.json 结构不符合预期");
for (const item of raw) {
const template = taxonomy?.assignments?.[item.slug];
if (template) item.templateId = template;
}
return { revision, raw };
}
/**
* 只收中文标题的条目;中文界面下英文标题读起来是噪音。
*/
/**
* 卡片的描述行。上游只有约四分之一条目的 summary 是中文,其余是英文原文,
* 而且不少英文摘要里混着 "Created on Seedance 2.5 Prompt: …" 这类样板文字,
* 塞进中文卡片是噪音;不拿标题顶上是因为标题和描述重复看着像渲染出错。
* 所以退一步用上游分类的译名——同样是中文,同样说得清这条是什么,
* 只是讲的是"哪一类"而不是"这一条讲了什么"。连分类都没有的少数条目留空。
*/
function seedanceDescription(item, category) {
if (hasCjk(item.summary)) return clip(item.summary, DESCRIPTION_LIMIT);
return SEEDANCE_CATEGORY_LABELS[category] ?? "";
}
function buildSeedanceEntries(raw) {
const unknown = new Set();
const entries = raw
.filter((item) => item.slug && item.posterUrl && item.promptFull && hasCjk(item.title) && !EXCLUDED_SEEDANCE_SLUGS.has(item.slug))
.sort((left, right) => (right.heatScore ?? 0) - (left.heatScore ?? 0))
.map((item) => {
const category = item.templateId || "";
if (category && !SEEDANCE_CATEGORY_LABELS[category]) unknown.add(category);
return {
id: `seedance:${item.slug}`,
title: item.title,
description: seedanceDescription(item, category),
image: item.posterUrl,
mode: "video",
prompt: item.promptFull,
...(category ? { category } : {}),
...(Array.isArray(item.tags) && item.tags.length ? { tags: item.tags.slice(0, 6) } : {}),
sourceId: "seedance",
...(item.creator ? { credit: item.creator } : {}),
...(item.sourceUrl ? { sourceUrl: item.sourceUrl } : {}),
heat: item.heatScore ?? 0,
};
});
if (unknown.size) console.warn(`⚠ seedance 出现未译分类 id(页面会回落显示 id): ${[...unknown].join(", ")}`);
return entries;
}
// ── 好好学 AI 提示词库 ──────────────────────────────────────────────────────
/** 站点是 SSR 的,提示词正文就内嵌在页面的 JSON 块里,不用跑浏览器。 */
function parseHaohaoxueHtml(html) {
for (const block of html.matchAll(/<script[^>]*type="application\/json"[^>]*>([\s\S]*?)<\/script>/g)) {
let parsed;
try {
parsed = JSON.parse(block[1]);
} catch {
continue;
}
const found = [];
const walk = (node) => {
if (Array.isArray(node)) return node.forEach(walk);
if (node && typeof node === "object") {
if (typeof node.prompt === "string" && node.prompt.length > 60) found.push(node);
Object.values(node).forEach(walk);
}
};
walk(parsed);
if (found.length) return found;
}
return [];
}
async function loadHaohaoxue(options) {
const html = options.haohaoxueInput ? readFileSync(resolve(options.haohaoxueInput), "utf8") : await fetchText(HAOHAOXUE_PAGE);
const raw = parseHaohaoxueHtml(html);
if (!raw.length) throw new Error("没有在页面里找到内嵌提示词数据,站点结构可能已变化");
return { revision: new Date().toISOString().slice(0, 10), raw };
}
function buildHaohaoxueEntries(raw) {
const unknown = new Set();
const entries = raw
.filter((item) => item.id && typeof item.prompt === "string" && item.prompt.length > 60)
.map((item) => {
if (item.category && !HAOHAOXUE_CATEGORY_LABELS[item.category]) unknown.add(item.category);
return {
id: `haohaoxue:${item.id}`,
title: (item.title_zh || item.title || item.id).trim(),
description: clip(item.description_zh || item.title || item.id, DESCRIPTION_LIMIT),
image: `https://www.haohaoxue.com/images/prompts/${item.id}.webp`,
// 这个源全是图片生成提示词,模型字段也都是图片模型。
mode: "image",
prompt: item.prompt,
...(item.prompt_zh ? { promptZh: item.prompt_zh } : {}),
...(item.category ? { category: item.category } : {}),
...(Array.isArray(item.tags) && item.tags.length ? { tags: item.tags.slice(0, 6) } : {}),
...(item.ratio ? { ratio: item.ratio } : {}),
sourceId: "haohaoxue",
...(item.author ? { credit: item.author } : {}),
sourceUrl: HAOHAOXUE_PAGE,
featured: item.featured === true,
updated: typeof item.v === "number" ? item.v : 0,
};
})
.sort((left, right) => Number(right.featured) - Number(left.featured) || right.updated - left.updated)
.map(({ featured, updated, ...entry }) => ({ ...entry, ...(featured ? { featured: true } : {}) }));
if (unknown.size) console.warn(`⚠ 好好学出现未译分类码(页面会回落显示原码): ${[...unknown].join(", ")}`);
return entries;
}
// ── YouMind 提示词库(nano-banana-pro / gpt-image-2) ──────────────────────
async function resolveRepoRevision(repo) {
const response = await fetch(`https://api.github.com/repos/${repo}/commits/main`, {
headers: { Accept: "application/vnd.github.sha", "User-Agent": UA },
});
if (!response.ok) throw new Error(`取 ${repo} revision 失败: HTTP ${response.status}`);
return (await response.text()).trim();
}
/**
* README 是脚本生成的,结构稳定:
* ### No. N: <分类标签> - <标题>
* #### 📖 描述 / #### 📝 提示词(代码块)/ #### 🖼️ 生成图片(img src)/ #### 📌 详情(作者、来源)
* 分类标签先按同一文件里「按分类浏览」的链接表换成稳定的英文 code,
* 这样分类 id 不会随中文译名调整而漂移。
*/
function parseYoumindReadme(markdown, sourceSlug) {
const taxonomy = new Map();
for (const match of markdown.matchAll(/\[([^\]]+)\]\(https:\/\/youmind\.com\/[^)]*\?categories=([a-z0-9-]+)\)/g)) {
taxonomy.set(match[1].trim(), match[2]);
}
const entries = [];
for (const block of markdown.split(/\n### No\. \d+:/).slice(1)) {
const heading = (block.split("\n")[0] ?? "").trim();
const prompt = block.match(/#### 📝 提示词\s*\n+```[a-z]*\s*\n([\s\S]*?)\n```/)?.[1]?.trim();
const image = block.match(/<img src="(https?:[^"]+)"/)?.[1];
if (!heading || !prompt || !image) continue;
// 标题本身可能带 " - ",只有前缀命中已知分类时才按它切开。
const separator = heading.indexOf(" - ");
const prefix = separator > 0 ? heading.slice(0, separator).trim() : "";
const category = taxonomy.get(prefix);
const title = category ? heading.slice(separator + 3).trim() : heading;
if (!title) continue;
const description = block.match(/#### 📖 描述\s*\n+([\s\S]*?)\n+#### 📝 提示词/)?.[1]?.trim() ?? "";
const id = block.match(/youmind\.com\/[^)]*\?id=(\d+)/)?.[1] ?? `${sourceSlug}-${entries.length + 1}`;
const author = block.match(/- \*\*作者:\*\*\s*\[([^\]]+)\]/)?.[1]?.trim();
const sourceUrl = block.match(/- \*\*来源:\*\*\s*\[[^\]]+\]\((https?:[^)]+)\)/)?.[1];
entries.push({
id: `youmind:${id}`,
title,
description: clip(description, DESCRIPTION_LIMIT),
image,
// 两个上游库都是图片模型(Nano Banana Pro / GPT Image 2),所以整源都是图片提示词。
mode: "image",
prompt,
...(category ? { category } : {}),
sourceId: "youmind",
...(author ? { credit: author } : {}),
...(sourceUrl ? { sourceUrl } : {}),
});
}
return { entries, categories: Object.fromEntries([...taxonomy].map(([label, code]) => [code, label])) };
}
async function loadYoumind() {
const revisions = [];
const entries = [];
const categories = {};
for (const { repo, slug } of YOUMIND_REPOS) {
const revision = await resolveRepoRevision(repo);
revisions.push(`${slug}@${revision.slice(0, 8)}`);
const markdown = await fetchText(`https://raw.githubusercontent.com/${repo}/${revision}/README_zh.md`);
const parsed = parseYoumindReadme(markdown, slug);
entries.push(...parsed.entries);
Object.assign(categories, parsed.categories);
console.log(` youmind/${slug.padEnd(12)}: ${parsed.entries.length} 条`);
}
if (!entries.length) throw new Error("YouMind 没有解析出任何条目,README 结构可能已变化");
// 两个库是同一位发布方维护的姊妹库,偶尔会互相转载同一条,按标题去重。
const seen = new Set();
const unique = entries.filter((entry) => (seen.has(entry.title) ? false : (seen.add(entry.title), true)));
return { revision: revisions.join(" + "), entries: unique, categories };
}
// ── 产物 ────────────────────────────────────────────────────────────────────
/** 目录里只留跨源共用的字段,heat/updated 这类排序辅助不落库。 */
function toCatalogEntry({ heat, updated, featured, ...entry }) {
return { ...entry, ...(featured ? { featured: true } : {}) };
}
/** 只留各来源的上游版本号:几行字,跟着包走,供署名与合规展示。 */
function renderSourceRevisions(revisions) {
return `// 由 scripts/import-inspirations.mjs 生成,勿手工编辑。
// 各来源同步时的上游版本号,供灵感页与画廊署名展示。
export const HIGHLIGHT_REVISIONS = {
${Object.entries(revisions)
.map(([name, value]) => ` ${name}: ${JSON.stringify(value)},`)
.join("\n")}
};
`;
}
/**
* 首页画廊的池子,落成静态 JSON 由页面启动时拉取。
* 连正文一起打进包的话,几百条提示词正文就压在首屏 JS 上,
* 而这份数据只服务画廊一处,不值当。
*
* 只收描述非空的条目:画廊卡片是"行动点 / 标题 / 描述"三行固定版式,
* 描述空着的卡片第三行会缺一块。这些条目仍留在全量目录里,灵感页照常展示。
*/
function renderGalleryPool(revisions, highlights) {
const entries = Object.values(highlights)
.flat()
.filter((entry) => entry.description)
.map((entry) => ({ ...toCatalogEntry(entry), featured: true }));
return JSON.stringify({ generatedAt: new Date().toISOString(), revisions, entries });
}
/**
* 下载上游封面并压成本地缩略图,返回可写进目录的相对路径。
* 返回 skipped 表示这张封面不可用(下载失败除外——那是抛错),调用方应把该条移出画廊池。
*/
async function buildThumb(source, entry, name, force) {
const relative = `/${THUMB_DIR}/${source}/${name}.webp`;
const target = join(SHOT_DIR, THUMB_DIR, source, `${name}.webp`);
if (existsSync(target) && !force) {
return statSync(target).size < MIN_THUMB_BYTES ? { skipped: true } : { relative, downloaded: false };
}
const response = await fetch(entry.image, { headers: { "User-Agent": UA, Referer: new URL(entry.image).origin } });
if (!response.ok) throw new Error(`下载封面失败 ${entry.image}: HTTP ${response.status}`);
const resized = await new Bun.Image(Buffer.from(await response.arrayBuffer())).resize(THUMB_WIDTH).webp({ quality: THUMB_QUALITY }).bytes();
if (resized.length < MIN_THUMB_BYTES) {
console.warn(` ⚠ 封面像纯色/白板,跳过: ${entry.id}(压后 ${resized.length}B)`);
return { skipped: true };
}
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, resized);
return { relative, downloaded: true };
}
/** 删掉不再被引用的缩略图,避免换了一批之后旧文件永远留在仓库里。 */
function pruneOrphanThumbs(source, keepNames) {
const target = join(SHOT_DIR, THUMB_DIR, source);
if (!existsSync(target)) return 0;
let removed = 0;
for (const file of readdirSync(target)) {
if (keepNames.has(file.replace(/\.webp$/, ""))) continue;
unlinkSync(join(target, file));
removed += 1;
}
return removed;
}
/**
* 各来源的加载与条目构造。加新来源时在这里加一项,main 只负责编排与落盘。
*/
const SOURCE_PIPELINES = [
{
name: "seedance",
file: "seedance.json",
coverName: (entry) => sanitizeFileName(entry.id.replace(/^seedance:/, "")),
load: async (options) => {
const { revision, raw } = await loadSeedance(options);
return { revision, note: `上游 ${raw.length} 条`, entries: buildSeedanceEntries(raw), categories: SEEDANCE_CATEGORY_LABELS };
},
},
{
name: "haohaoxue",
file: "haohaoxue.json",
coverName: (entry) => sanitizeFileName(entry.id.replace(/^haohaoxue:/, "")),
load: async (options) => {
const { revision, raw } = await loadHaohaoxue(options);
return { revision, note: `页面内嵌 ${raw.length} 条`, entries: buildHaohaoxueEntries(raw), categories: HAOHAOXUE_CATEGORY_LABELS };
},
},
{
name: "youmind",
file: "youmind.json",
coverName: (entry) => sanitizeFileName(entry.id.replace(/^youmind:/, "")),
load: async () => {
const { revision, entries, categories } = await loadYoumind();
return { revision, note: "两个姊妹库", entries, categories };
},
},
];
/**
* 等距抽 count 条,而不是取前 count 条。
* 上游 README 是按分类成段排的,取前 N 条会让画廊池子全是开头那几个分类;
* 等距铺开才能横跨全部分类,抽出来的图才不重样。
*/
function strideSample(list, count) {
if (list.length <= count) return [...list];
if (count === 1) return [list[0]];
const picked = [];
const seen = new Set();
for (let index = 0; index < count; index += 1) {
const at = Math.round((index * (list.length - 1)) / (count - 1));
if (seen.has(at)) continue;
seen.add(at);
picked.push(list[at]);
}
return picked;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const selected = SOURCE_PIPELINES.filter((pipeline) => !options.only || options.only === pipeline.name);
if (!selected.length) throw new Error(`--only 不认识的来源: ${options.only}`);
const translations = JSON.parse(readFileSync(join(WEB_DIR, "scripts", "data", "inspiration-prompts-zh.json"), "utf8"));
const localizedSources = new Map();
// 先检查所有选中来源的中文覆盖,再下载封面或写目录,避免半批更新后才发现缺译。
for (const pipeline of selected) {
const loaded = await pipeline.load(options);
localizedSources.set(pipeline.name, { ...loaded, entries: localizeInspirationEntries(loaded.entries, translations) });
}
mkdirSync(PUBLIC_DIR, { recursive: true });
const revisions = {};
const counts = {};
const categoriesBySource = {};
const highlights = {};
let downloaded = 0;
let skippedThumbs = 0;
let pruned = 0;
for (const pipeline of SOURCE_PIPELINES) {
if (!selected.includes(pipeline)) {
// 只同步其中一部分来源时,其余来源沿用上次落盘的 revision 与条目数,
// 免得 manifest 把没动过的来源写成空值。
const existing = JSON.parse(readFileSync(join(PUBLIC_DIR, pipeline.file), "utf8"));
revisions[pipeline.name] = existing.revision;
counts[pipeline.name] = existing.count;
categoriesBySource[pipeline.name] = existing.categories ?? {};
highlights[pipeline.name] = [];
continue;
}
const loaded = localizedSources.get(pipeline.name);
const { entries } = loaded;
if (!entries.length) throw new Error(`${pipeline.name} 没有解析出任何条目`);
revisions[pipeline.name] = loaded.revision;
counts[pipeline.name] = entries.length;
categoriesBySource[pipeline.name] = loaded.categories ?? {};
// 只给一部分条目生成本地缩略图:全量一千多条都压一遍会让仓库和同步时间都失控,
// 其余继续引用上游 CDN,由灵感页按需拉取。
const thumbed = [];
const thumbNames = new Set();
for (const entry of strideSample(entries, options.thumbs)) {
const name = pipeline.coverName(entry);
const result = await buildThumb(pipeline.name, entry, name, options.force);
if (result.skipped) {
skippedThumbs += 1;
continue;
}
// 直接把 image 指向本地缩略图:目录 JSON 和精选模块都从这里取值。
entry.image = result.relative;
thumbNames.add(name);
thumbed.push(entry);
if (result.downloaded) downloaded += 1;
}
pruned += pruneOrphanThumbs(pipeline.name, thumbNames);
highlights[pipeline.name] = thumbed;
writeFileSync(join(PUBLIC_DIR, pipeline.file), JSON.stringify({ source: pipeline.name, revision: loaded.revision, count: entries.length, categories: loaded.categories ?? {}, entries: entries.map(toCatalogEntry) }), "utf8");
console.log(`${pipeline.name.padEnd(12)}: ${loaded.note} → 入库 ${entries.length} 条,本地缩略图 ${thumbed.length} 张`);
}
writeFileSync(REVISIONS_FILE, renderSourceRevisions(revisions), "utf8");
writeFileSync(GALLERY_POOL_FILE, renderGalleryPool(revisions, highlights), "utf8");
const manifest = {
generatedAt: new Date().toISOString(),
sources: Object.fromEntries(Object.entries(revisions).map(([name, revision]) => [name, { revision, count: counts[name], categories: categoriesBySource[name] ?? {} }])),
};
writeFileSync(join(PUBLIC_DIR, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
console.log(`新下载缩略图: ${downloaded}${skippedThumbs ? `,跳过不可用封面 ${skippedThumbs} 张` : ""}`);
console.log(`清理孤儿文件: ${pruned}`);
console.log(`画廊池 : ${GALLERY_POOL_FILE}`);
console.log(`版本号模块 : ${REVISIONS_FILE}`);
console.log(`全量目录 : ${PUBLIC_DIR}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});