* feat(inspirations): 灵感库 - 统一中文正文入口并校验译文版本 * feat(inspirations): 灵感库 - 发布完整中文提示词并阻止旧译文覆盖 统一 1319 条外部提示词和 329 条画廊引用,保留来源与模板参数;校验全部原文摘要,防止上游更新后误用过期中文。 验证:Bun 1.3.9 全量 2261 项测试通过,前端构建与 lint 通过,目录身份及媒体元数据对比无变化。
572 lines
27 KiB
JavaScript
572 lines
27 KiB
JavaScript
/**
|
||
* 灵感目录导入:把外部提示词源同步成本地目录。
|
||
*
|
||
* 无新增依赖:只用 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);
|
||
});
|