feat(create): 创作首页与灵感库 - 纵深画廊、灵感页面、外部提示词池 (#708)
* feat(create): 创作首页与灵感库 - 纵深画廊、灵感页面、外部提示词池 创作首页空态换成直线纵深图片画廊:11 张横版卡片沿 Z 轴前后排队,当前一张 在最前最大,滚轮、左右按钮或自动轮播切换,卡片带玻璃描边和"行动点 / 标题 / 描述"三段信息,跟随输入框的创作类型联动换队。队列是随机取样并记住最近出现过的 条目,走完一轮自动换批,不再反复看到同样那几张。 左侧「创作」下方新增灵感页:1341 条提示词按来源、分类和创作类型筛选, 支持搜索、收藏,以及自建灵感的新建、编辑、删除和 JSON 导入导出。 新增 scripts/import-inspirations.mjs,从 awesome-seedance、好好学 AI 和 YouMind 两个姊妹库同步条目,并为画廊池生成本地 webp 缩略图(560px / q72, 约 35KB 一张)。上游封面平均 400KB~700KB,直接引用一屏 11 张要拉 5MB 以上, 换成本地缩略图后约 400KB。 画廊池改走 public/inspirations/gallery-pool.json 运行时拉取:几百条提示词正文 压在首屏 JS 上是白背的,而这份数据只服务画廊一处。缩略图等距抽取,横跨各来源 的全部分类;只收描述非空的条目,保证卡片三行版式不缺角。 * docs(create): 功能清单与地图 - 补充纵深画廊与灵感库 功能清单新增「创作首页纵深画廊与灵感库」一节,并在功能地图里记下画廊、 灵感页、运行时目录加载和导入脚本各自的代码入口;待测试清单记下浏览器验收结果 与仍未确认的边界(弱网退回、窄屏、文本条目偏少、外部来源署名)。
This commit is contained in:
566
web/scripts/import-inspirations.mjs
Normal file
566
web/scripts/import-inspirations.mjs
Normal file
@@ -0,0 +1,566 @@
|
||||
/**
|
||||
* 灵感目录导入:把外部提示词源同步成本地目录。
|
||||
*
|
||||
* 无新增依赖:只用 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";
|
||||
|
||||
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}`);
|
||||
|
||||
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 = await pipeline.load(options);
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user