feat: 初始化画个标仓库 - AI驱动设计系统(BYOK模式) - 224源/371504图标+25海报源+130技能

This commit is contained in:
sgp
2026-10-03 21:44:18 +08:00
commit 9e222877ea
497 changed files with 43611 additions and 0 deletions
+594
View File
@@ -0,0 +1,594 @@
/**
* 海报素材构建脚本:从 GitHub / npm 下载开源海报设计资源,解析生成内置海报素材库(离线)
* 产物:
* - js/data/poster-builtin.js 轻量索引(sources 元数据 + 分类索引,无全量内容)
* - js/data/posters/{id}.js 每源一个数据文件(HGB_PosterSource_{id} = {items:[...]})
* - js/data/posters/{id}/files/* 位图/字体等二进制原始文件(浏览器按 file 引用直读)
* 用法:node scripts/build-posters.js
*
* 与 build-icons.js 的差异:
* - 素材为全彩/位图/模板,保留原始宽高比(不归一化到 24 视口)
* - 支持 SVG / PNG / JPG / 字体 / 配色 JSON 多格式
* - 每素材带三维归类:type(资源类型)+ category/categoryCn(应用场景)+ workspaces(工作区)
* - 完整性质检:数量 + sha256 字节 + 分辨率参数 + 格式魔数 + SVG 内容校验
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const vm = require('vm');
/* ================= 数据源 ================= */
/* 来源清单统一维护在 js/data/poster-sources.js(构建脚本与「海报素材」页面共用) */
const SOURCES = require('../js/data/poster-sources.js');
/* ================= 网络 ================= */
function fetchText(url, redirects) {
redirects = redirects || 0;
return new Promise((resolve, reject) => {
const req = https.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (redirects >= 3) return reject(new Error('redirect loop ' + url));
let next = res.headers.location;
if (next.startsWith('/')) next = 'https://unpkg.com' + next;
return resolve(fetchText(next, redirects + 1));
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`${res.statusCode} ${url}`));
}
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(new Error('timeout ' + url)); });
});
}
function fetchBuffer(url, redirects) {
redirects = redirects || 0;
return new Promise((resolve, reject) => {
const req = https.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (redirects >= 3) return reject(new Error('redirect loop ' + url));
let next = res.headers.location;
if (next.startsWith('/')) next = 'https://unpkg.com' + next;
return resolve(fetchBuffer(next, redirects + 1));
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`${res.statusCode} ${url}`));
}
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks)));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(new Error('timeout ' + url)); });
});
}
/** 简易并发池 */
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
/* ================= 完整性工具 ================= */
function sha256(buf) {
return crypto.createHash('sha256').update(buf).digest('hex');
}
/** 构建脚本改动版本(加入缓存 hash,代码变更后自动重下) */
const BUILD_REV = '2'; // v2: 修复 twemoji/heropatterns 排除正则失效、字体应用场景误分类、新增 open-color 源
/** 常见非素材配置文件(即使扩展名命中 ext 也剔除) */
const NON_ASSETS = new Set([
'package.json', 'package-lock.json', 'yarn.lock', 'composer.json', 'bower.json',
'tsconfig.json', 'jsconfig.json', 'deno.json', 'biome.json',
'.eslintrc.json', '.prettierrc.json', '.stylelintrc.json', '.nycrc.json',
'swagger.json', 'openapi.json'
]);
/** 剔除非素材文件(包配置/脚手架),返回新数组 */
function dropNonAssets(files) {
return files.filter(p => {
const base = path.basename(p).toLowerCase();
return !NON_ASSETS.has(base) && !base.startsWith('.'); // .eslintrc.json 等点文件
});
}
/* ================= 应用场景分类(classifyPoster) ================= */
const POSTER_CATEGORY_RULES = [
{ cat: 'marketing', cn: '营销促销', words: ['sale', 'promo', 'discount', 'coupon', 'deal', 'marketing', 'offer', 'price', 'shopping', 'promotion', 'voucher', 'banner', 'ad', 'launch'] },
{ cat: 'activity', cn: '活动庆典', words: ['event', 'party', 'celebration', 'festival', 'ceremony', 'gala', 'anniversary', 'opening', 'grand', 'party-popper', 'confetti', 'ribbon', 'balloon', 'carnival', 'expo', 'conference'] },
{ cat: 'recruitment', cn: '招聘招生', words: ['job', 'hiring', 'recruit', 'career', 'staff', 'talent', 'apply', 'position', 'enroll', 'admission', 'recruitment', 'wanted', 'join-us', 'vacancy'] },
{ cat: 'food', cn: '餐饮美食', words: ['food', 'restaurant', 'coffee', 'cake', 'pizza', 'burger', 'drink', 'menu', 'chef', 'cuisine', 'catering', 'dinner', 'lunch', 'breakfast', 'tea', 'beverage', 'snack', 'dessert', 'bakery', 'barbecue', 'hotpot', 'sushi'] },
{ cat: 'ecommerce', cn: '电商带货', words: ['ecommerce', 'shop', 'store', 'product', 'online', 'mall', 'buy', 'cart', 'order', 'flash', 'seckill', 'live', 'taobao', 'jd', 'goods', 'retail', 'merch'] },
{ cat: 'education', cn: '教育培训', words: ['education', 'school', 'course', 'class', 'learn', 'study', 'training', 'tutor', 'lesson', 'academy', 'campus', 'student', 'teacher', 'workshop', 'seminar', 'exam', 'book', 'graduation'] },
{ cat: 'health', cn: '医疗健康', words: ['health', 'medical', 'hospital', 'clinic', 'doctor', 'dental', 'pharmacy', 'care', 'fitness', 'yoga', 'spa', 'beauty', 'wellness', 'nurse', 'surgery', 'therapy'] },
{ cat: 'tech', cn: '科技数码', words: ['tech', 'digital', 'ai', 'robot', 'software', 'app', 'code', 'data', 'cloud', 'gadget', 'startup', 'innovation', 'internet', 'smart', 'electronic', 'cyber', 'computer', 'mobile', 'it-'] },
{ cat: 'travel', cn: '旅游出行', words: ['travel', 'tour', 'trip', 'tourism', 'hotel', 'flight', 'airline', 'vacation', 'holiday', 'destination', 'resort', 'journey', 'adventure', 'booking', 'scenery', 'landmark'] },
{ cat: 'realestate', cn: '房地产', words: ['realestate', 'real-estate', 'house', 'property', 'apartment', 'villa', 'home', 'estate', 'housing', 'rent', 'sale-house', 'building', 'architecture', 'interior', 'construction'] },
{ cat: 'festival', cn: '节日节气', words: ['festival', 'christmas', 'new-year', 'spring-festival', 'mid-autumn', 'national-day', 'halloween', 'thanksgiving', 'easter', 'valentine', 'lantern', 'dragon-boat', 'qixi', 'double-eleven', 'mothers-day', 'fathers-day'] },
{ cat: 'charity', cn: '公益宣传', words: ['charity', 'donate', 'volunteer', 'public', 'environment', 'green', 'recycle', 'welfare', 'community', 'nonprofit', 'awareness', 'campaign', 'fundraise'] },
{ cat: 'notice', cn: '通知公告', words: ['notice', 'announce', 'bulletin', 'notice-board', 'announcement', 'info', 'alert', 'warning', 'tips', 'guide', 'instruction'] },
{ cat: 'music', cn: '音乐演出', words: ['music', 'concert', 'live-show', 'performance', 'stage', 'band', 'singer', 'dj', 'festival-music', 'opera', 'theater', 'dance', 'ticket', 'gig', 'show'] },
{ cat: 'sport', cn: '体育运动', words: ['sport', 'fitness', 'gym', 'football', 'basketball', 'running', 'marathon', 'race', 'yoga', 'match', 'game', 'athlete', 'training', 'competition', 'champion', 'outdoor'] },
{ cat: 'business', cn: '商务会议', words: ['business', 'meeting', 'conference', 'summit', 'forum', 'corporate', 'finance', 'investment', 'annual', 'report', 'presentation', 'brand', 'enterprise', 'b2b', 'office'] },
{ cat: 'baby', cn: '母婴亲子', words: ['baby', 'kids', 'child', 'parent', 'family', 'mother', 'father', 'toy', 'nursery', 'maternal', 'infant', 'children', 'play', 'story'] },
{ cat: 'others', cn: '其他', words: [] }
];
/** 素材名 → {cat, cn};未命中返回"其他" */
function classifyPoster(name) {
const n = String(name || '').toLowerCase();
for (const r of POSTER_CATEGORY_RULES) {
if (r.words.some(w => n.includes(w))) return { cat: r.cat, cn: r.cn };
}
return { cat: 'others', cn: '其他' };
}
/* ================= 分辨率 / 格式解析 ================= */
/** SVG:解析 viewBox 或 width/height,非法返回 null */
function parseSvgMeta(svgStr) {
const svgTag = (svgStr.match(/<svg[^>]*>/i) || [])[0] || '';
const vb = (svgTag.match(/viewBox\s*=\s*["']([^"']+)["']/i) || [])[1];
if (vb) {
const p = vb.trim().split(/[\s,]+/).map(Number);
if (p.length === 4 && p.every(n => Number.isFinite(n)) && p[2] > 0 && p[3] > 0) {
return { width: p[2], height: p[3], viewBox: p.join(' ') };
}
}
const w = parseFloat((svgTag.match(/width\s*=\s*["']([\d.]+)/i) || [])[1]);
const h = parseFloat((svgTag.match(/height\s*=\s*["']([\d.]+)/i) || [])[1]);
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
return { width: w, height: h, viewBox: `0 0 ${w} ${h}` };
}
return null;
}
/** PNG:解析 IHDR 头取宽高;非 PNG 返回 null */
function parsePngSize(buf) {
if (buf.length < 24) return null;
const sig = buf.slice(0, 8);
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (!sig.equals(PNG_SIG)) return null;
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
return (width > 0 && height > 0) ? { width, height } : null;
}
/** JPG:扫描 SOF 段取宽高;非 JPG 返回 null */
function parseJpgSize(buf) {
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null; // SOI
let i = 2;
while (i + 9 < buf.length) {
if (buf[i] !== 0xff) { i++; continue; }
const marker = buf[i + 1];
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
const height = buf.readUInt16BE(i + 5);
const width = buf.readUInt16BE(i + 7);
return (width > 0 && height > 0) ? { width, height } : null;
}
const len = buf.readUInt16BE(i + 2);
if (len < 2) return null;
i += 2 + len;
}
return null;
}
/* ================= SVG 内容校验 ================= */
/** 剔除注释并压缩空白 */
function stripSvgBody(svgStr) {
const m = svgStr.match(/<svg[^>]*>([\s\S]*?)<\/svg>/i);
if (!m) return '';
return m[1].replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim();
}
/** 是否含可渲染几何(path/circle/rect/text 等) */
function hasGeometry(body) {
return /<(?:path|circle|rect|polygon|polyline|line|ellipse|text|g)\b/i.test(body);
}
/** 剔除悬空引用(url(#id)/href="#id" 无对应定义 → 渲染透明) */
function hasDanglingRef(body) {
const ids = new Set();
const idRe = /\bid\s*=\s*["']([^"']+)["']/g;
let m;
while ((m = idRe.exec(body))) ids.add(m[1]);
const refRe = /(?:url\(|href\s*=\s*["'])#([^"')]+)/g;
while ((m = refRe.exec(body))) {
const ref = m[1].trim();
if (ref && !ids.has(ref)) return true;
}
return false;
}
/* ================= GitHub 仓库目录(jsDelivr gh 代理) ================= */
async function downloadGhFiles(src) {
const items = [];
let ok = 0, fail = 0;
const repo = `${src.gh}@${src.ghBranch || 'master'}`;
const tree = JSON.parse(await fetchText(`https://data.jsdelivr.com/v1/packages/gh/${repo}`));
const exts = (Array.isArray(src.ext) ? src.ext : (src.ext ? String(src.ext).split(',') : ['.svg']))
.map(e => e.trim().toLowerCase());
const files = [];
(function walk(list, prefix) {
for (const f of list) {
if (f.type === 'directory') walk(f.files || [], prefix + f.name + '/');
else if (exts.some(e => f.name.toLowerCase().endsWith(e))) files.push(prefix + f.name);
}
})(tree.files || [], '');
// dirs 支持 '.' 表示全目录;maxItems 用于超大源限量收录(如表情包全量过大)
const allowed = (src.dirs || []).map(d => d === '.' || d === '' ? '' : d.replace(/\/$/, '') + '/');
let picked = files.filter(p => allowed.some(d => d === '' || p.startsWith(d)));
picked = dropNonAssets(picked);
if (src.exclude) {
const re = new RegExp(src.exclude);
picked = picked.filter(p => !re.test(p));
}
const cap = Number(src.maxItems) > 0 ? Math.min(Number(src.maxItems), picked.length) : picked.length;
picked = picked.slice(0, cap);
const results = await mapLimit(picked, 16, async (file) => {
try {
const buf = await fetchBuffer(`https://cdn.jsdelivr.net/gh/${repo}/${file}`);
return { file, buf };
} catch (e) { return null; }
});
for (const r of results) {
if (!r) { fail++; continue; }
const name = path.basename(r.file, path.extname(r.file));
let file = r.file, buf = r.buf;
// 专用转换(如 hero-patterns:JS 函数模板 → 静态 SVG)
if (src.transform === 'heropatterns') {
const svg = transformHeroPatterns(r.buf);
if (!svg) { fail++; continue; }
file = r.file.replace(/\.js$/i, '.svg');
buf = Buffer.from(svg, 'utf8');
}
const c = classifyPoster(name);
const it = await buildItem(src, name, file, buf, c);
if (it) { items.push(it); ok++; } else fail++;
}
return { items, ok, fail };
}
/* ================= npm 目录枚举(jsDelivr API,替代已失效的 unpkg ?meta 端点) ================= */
async function downloadDirFiles(src) {
const items = [];
let ok = 0, fail = 0;
const exts = (Array.isArray(src.ext) ? src.ext : (src.ext ? String(src.ext).split(',') : ['.svg']))
.map(e => e.trim().toLowerCase());
const pkg = src.npm + (src.npmVersion ? '@' + src.npmVersion : '');
const info = JSON.parse(await fetchText(`https://data.jsdelivr.com/v1/packages/npm/${pkg}`));
const ver = src.npmVersion || (info.tags && info.tags.latest);
if (!ver) throw new Error('无法解析 npm 版本: ' + src.npm);
const tree = JSON.parse(await fetchText(`https://data.jsdelivr.com/v1/packages/npm/${src.npm}@${ver}`));
const files = [];
(function walk(list, prefix) {
for (const f of list) {
if (f.type === 'directory') walk(f.files || [], prefix + f.name + '/');
else if (exts.some(e => f.name.toLowerCase().endsWith(e))) files.push(prefix + f.name);
}
})(tree.files || [], '');
const allowed = (src.dirs || []).map(d => d === '.' || d === '' ? '' : d.replace(/\/$/, '') + '/');
let picked = files.filter(p => allowed.some(d => d === '' || p.startsWith(d)));
picked = dropNonAssets(picked);
if (src.exclude) {
const re = new RegExp(src.exclude);
picked = picked.filter(p => !re.test(p));
}
const cap = Number(src.maxItems) > 0 ? Math.min(Number(src.maxItems), picked.length) : picked.length;
picked = picked.slice(0, cap);
const results = await mapLimit(picked, 16, async (p) => {
try {
const buf = await fetchBuffer(`https://cdn.jsdelivr.net/npm/${src.npm}@${ver}/${p}`);
return { file: p, buf };
} catch (e) { return null; }
});
for (const r of results) {
if (!r) { fail++; continue; }
const name = path.basename(r.file, path.extname(r.file));
const c = classifyPoster(name);
const it = await buildItem(src, name, r.file, r.buf, c);
if (it) { items.push(it); ok++; } else fail++;
}
return { items, ok, fail };
}
/**
* hero-patterns 专用转换:ES module 函数模板 → 静态 SVG 字符串
* 源码形如:
* import { defaultFill, defaultOpacity } from './_defaults'
* import { unhex } from './_utils'
* export const polkaDots = (fill = defaultFill, opacity = defaultOpacity) =>
* `url('data:image/svg+xml,%3Csvg%20width...%3E')`
* 处理:剥离 import(依赖注入 sandbox)、以默认黑色调用函数、URL 解码出 <svg>…</svg>
*/
function transformHeroPatterns(buf) {
let code = buf.toString('utf8');
code = code.replace(/^import\s+[^\n]*$/gm, ''); // 去掉 import 语句
code = code.replace(/export\s+const\s+/g, 'const '); // export const → const
const m = code.match(/const\s+(\w+)\s*=/);
if (!m) return null;
const sandbox = {
defaultFill: '#000000',
defaultOpacity: 1,
unhex: str => str.substring(str.indexOf('#') + 1)
};
vm.createContext(sandbox);
try {
vm.runInContext(code + `\n;globalThis.__HP_OUT = typeof ${m[1]} === 'function' ? ${m[1]} : null;`, sandbox);
} catch (e) { return null; }
const fn = sandbox.__HP_OUT;
if (typeof fn !== 'function') return null;
let uri = '';
try { uri = String(fn('#000000', 1) || ''); } catch (e) { return null; }
const mm = uri.match(/url\(\s*['"]data:image\/svg\+xml,([^'")]+)['"]\s*\)/i);
if (!mm) return null;
try {
const svg = decodeURIComponent(mm[1]);
return /<svg[\s>]/i.test(svg) ? svg : null;
} catch (e) { return null; }
}
/**
* 把单个下载文件构建为素材条目:
* - SVG:内联归一化 body(保留原始 viewBox 比例)+ 分辨率元数据
* - PNG/JPG:保存二进制到本地 files 目录,记录 file/mime/width/height/sha256/bytes
* - JSON(配色):内联解析后的 JSON
* - 字体:保存二进制 + 引用
* 返回 null 表示数据损坏(格式魔数不匹配 / 分辨率非法 / 空内容)
*/
async function buildItem(src, name, filePath, buf, c) {
const ext = path.extname(filePath).toLowerCase();
// 字体素材不做应用场景推断(避免名称误命中词表,如 LeagueSpartan 含 "spa" → 医疗健康),统一归「字体排版」
if (src.type === 'font') c = { cat: 'font', cn: '字体排版' };
const base = { id: `${src.id}:${name}`, name, type: src.type, category: c.cat, categoryCn: c.cn, workspaces: src.workspaces || ['poster'] };
if (ext === '.svg') {
const svgStr = buf.toString('utf8');
const meta = parseSvgMeta(svgStr);
if (!meta) return null;
const body = stripSvgBody(svgStr);
if (!body || !hasGeometry(body) || hasDanglingRef(body)) return null;
return Object.assign(base, {
svg: body, viewBox: meta.viewBox, width: meta.width, height: meta.height,
bytes: buf.length, sha256: sha256(buf), mime: 'image/svg+xml'
});
}
if (ext === '.png') {
const size = parsePngSize(buf);
if (!size) return null; // 非法 PNG(魔数/头部损坏)
return saveBinary(src, base, filePath, buf, 'image/png', size);
}
if (ext === '.jpg' || ext === '.jpeg') {
const size = parseJpgSize(buf);
if (!size) return null; // 非法 JPG
return saveBinary(src, base, filePath, buf, 'image/jpeg', size);
}
if (ext === '.json') {
try {
const json = JSON.parse(buf.toString('utf8'));
return Object.assign(base, { data: json, bytes: buf.length, sha256: sha256(buf), mime: 'application/json' });
} catch (e) { return null; }
}
// 字体及其它二进制:保存 + 引用
return saveBinary(src, base, filePath, buf, 'application/octet-stream', null);
}
/** 保存二进制到 js/data/posters/{id}/files/,条目只存 file 引用 + 校验元数据 */
function saveBinary(src, base, filePath, buf, mime, size) {
const rel = `posters/${src.id}/files/${path.basename(filePath)}`;
const abs = path.join(process.cwd(), 'js/data', rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, buf);
return Object.assign(base, {
file: rel, mime, bytes: buf.length, sha256: sha256(buf),
width: size ? size.width : null, height: size ? size.height : null
});
}
/* ================= 批次下载完整性校验 ================= */
/**
* 每源下载+处理后执行:检测数据损坏 / 文件缺失 / 格式错误。
* - expected 原始索引素材数(gh/dir 源枚举到的文件数;无法获知时 null)
* - beforeN 处理前素材数
* - afterN 最终内置素材数(剔除损坏/格式错误后)
* - badRes 分辨率非法数量
* 判定:通过率 = afterN/expected,< 80% 判异常。
*/
function verifyBatch(src, expected, beforeN, afterN, badRes) {
const rate = expected > 0 ? afterN / expected : null;
const dropped = beforeN - afterN;
const parts = [];
if (rate != null) {
if (rate < 0.8) parts.push(`⚠️ 通过率 ${(rate * 100).toFixed(1)}%(${afterN}/${expected})异常,疑似下载损坏/缺失`);
else if (dropped > 0) parts.push(`通过率 ${(rate * 100).toFixed(1)}%(${afterN}/${expected}),剔除 ${dropped} 个坏数据`);
else parts.push(`通过率 ${(rate * 100).toFixed(1)}%(${afterN}/${expected})`);
} else {
parts.push(`下载 ${afterN} 个(无官方索引可比对)`);
}
if (badRes > 0) parts.push(`${badRes} 个非法分辨率已剔除`);
const pass = rate == null || rate >= 0.8;
return { pass, msg: parts.join(';') };
}
/* ================= 配置 hash 与缓存 ================= */
function cfgHash(o) {
const s = JSON.stringify(Object.assign({ rev: BUILD_REV }, o));
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
return h.toString(36);
}
function readCachedItems(id, cfg, dir) {
const fp = path.join(dir, id + '.js');
if (!fs.existsSync(fp)) return null;
const txt = fs.readFileSync(fp, 'utf8');
const m = txt.match(/@cfg:([a-z0-9]+)/);
if (!m || m[1] !== cfg) return null;
global.window = global.window || {};
eval(txt);
const data = global.window['HGB_PosterSource_' + id];
if (!data || !Array.isArray(data.items) || !data.items.length) return null;
return data.items;
}
/* ================= 主流程 ================= */
async function main() {
const out = { meta: { generatedAt: new Date().toISOString(), total: 0 }, sources: {}, itemIndex: [] };
let grand = 0;
const verifyReport = [];
let verifyFailCount = 0;
const srcDir = path.join(process.cwd(), 'js/data/posters');
fs.mkdirSync(srcDir, { recursive: true });
for (const src of SOURCES) {
let items = null, fail = 0, usedUrl = '';
let expected = 0, badRes = 0;
const cfg = src;
try {
const cached = readCachedItems(src.id, cfgHash(cfg), srcDir);
if (cached) {
items = cached;
usedUrl = 'https://cdn.jsdelivr.net/gh/' + (src.gh || '');
console.log(`⏩ ${src.name}: 缓存命中(${items.length} 个素材)`);
}
} catch (e) { /* 缓存读取失败不致命 */ }
try {
if (items) {
// 缓存命中:无需下载
} else if (src.format === 'gh-files') {
console.log(`获取 ${src.name}(GitHub 仓库 ${src.gh})…`);
const r = await downloadGhFiles(src);
items = r.items; fail = r.fail; expected = r.ok + r.fail;
usedUrl = 'cdn.jsdelivr.net/gh/' + src.gh;
} else if (src.format === 'dir-files') {
console.log(`获取 ${src.name}(npm 目录 ${src.npm})…`);
const r = await downloadDirFiles(src);
items = r.items; fail = r.fail; expected = r.ok + r.fail;
usedUrl = 'unpkg.com/' + src.npm;
} else {
throw new Error(`不支持的 format: ${src.format}`);
}
} catch (e) {
console.error(`❌ 无法获取 ${src.id}:${e.message},跳过`);
continue;
}
if (!items || !items.length) {
console.error(`❌ ${src.id} 无有效素材,跳过`);
continue;
}
const beforeN = items.length;
// 后处理:剔除空内容 / 无几何 SVG / 悬空引用(SVG 已在上游 buildItem 过滤,此处兜底)
items = items.filter(it => {
if (!it) return false;
if (it.svg && (!it.svg.trim() || !hasGeometry(it.svg))) { badRes++; return false; }
return true;
});
if (items.length < beforeN) console.log(` ↳ 剔除 ${beforeN - items.length} 个坏数据`);
const v = verifyBatch(src, expected, beforeN, items.length, badRes);
verifyReport.push({ name: src.name, type: src.type || 'bg', ...v });
console.log(` [校验] ${v.pass ? '✅' : '⚠️'} ${src.name}:${v.msg}`);
if (!v.pass) verifyFailCount++;
// 1) 每源独立数据文件(运行时按源懒加载)
fs.writeFileSync(path.join(srcDir, src.id + '.js'),
`/* ${src.name}(由 scripts/build-posters.js 生成,勿手动编辑)@cfg:${cfgHash(cfg)} */\n` +
`window['HGB_PosterSource_${src.id}'] = ` + JSON.stringify({ items }) + ';\n', 'utf8');
// 2) 索引条目:元数据 + 前 4 个示例(poster-sources.html 展示),不内嵌全量内容
out.sources[src.id] = {
name: src.name, nameCn: src.nameCn || src.name, license: src.license, home: src.home,
url: usedUrl, count: items.length, type: src.type,
file: 'posters/' + src.id + '.js',
samples: items.slice(0, 4).map(i => i.svg || (i.file || '')),
coordText: src.coordText || '', variants: src.variants || ''
};
// 3) 全量索引(id + 名称 + 应用场景,供海报工作台/AI/筛选使用)
for (const it of items) {
out.itemIndex.push({ id: it.id, name: it.name, category: it.categoryCn, type: it.type });
}
grand += items.length;
console.log(`✅ ${src.name}: ${items.length} 个素材(${src.type},失败 ${fail})`);
}
out.meta.total = grand;
out.meta.sources = Object.keys(out.sources).length;
const output = 'js/data/poster-builtin.js';
const srcNames = SOURCES.map(s => s.name).join(' / ') || '(待收录)';
const js = '/* 内置海报素材索引(由 scripts/build-posters.js 生成,勿手动编辑)\n' +
' * 素材:' + srcNames + ';内容按源拆分到 js/data/posters/ 懒加载\n' +
' * 每素材含三维归类:type(资源类型)+ categoryCn(应用场景)+ workspaces(工作区) */\n' +
'window.HGB_PosterBuiltin = ' + JSON.stringify(out) + ';\n';
fs.writeFileSync(path.join(process.cwd(), output), js, 'utf8');
// ===== 机器可读 manifest(供外部项目/AI 直接读取,非前端必需;字段 = 配置 + 实际内置数) =====
const manifestDir = path.join(process.cwd(), 'js/data/manifest');
fs.mkdirSync(manifestDir, { recursive: true });
const posterMeta = {
kind: 'posters',
total: out.meta.total,
builtAt: out.meta.generatedAt,
guide: 'js/data/manifest/README.md',
idRule: '「源:相对路径」,如 fontsource-inter:latin-400-normal.woff2',
sources: SOURCES
.filter(s => out.sources[s.id])
.map(s => {
const m = out.sources[s.id];
return {
id: s.id, type: s.type || 'bg', name: s.name, nameCn: s.nameCn || s.name,
desc: s.desc || '', license: s.license || '', licenseNote: s.licenseNote || '',
home: s.home || '', github: s.github || '',
format: s.format || '', coordText: s.coordText || '',
variants: s.variants || '', added: s.added || '',
count: m.count, file: m.file,
workspaces: s.workspaces || ['poster']
};
})
};
fs.writeFileSync(path.join(manifestDir, 'posters.json'), JSON.stringify(posterMeta, null, 1), 'utf8');
console.log(` → manifest js/data/manifest/posters.json(${posterMeta.sources.length} 源元数据,供外部/AI 读取)`);
const sizeKB = (fs.statSync(path.join(process.cwd(), output)).size / 1024).toFixed(0);
console.log(`\n📦 完成:共 ${grand} 个海报素材(${Object.keys(out.sources).length} 源)`);
console.log(` → 索引 ${output} (${sizeKB}KB) + 按源拆分 js/data/posters/`);
const passN = verifyReport.length - verifyFailCount;
console.log(`\n🔍 批次下载质量校验:${passN}/${verifyReport.length} 源通过(通过率 = 最终内置数 ÷ 官方索引数,<80% 判异常)`);
const warnList = verifyReport.filter(r => !r.pass);
if (warnList.length) {
console.warn(`⚠️ 以下 ${warnList.length} 个源通过率异常,需人工复核:`);
for (const r of warnList) console.warn(` - ${r.name}:${r.msg}`);
} else if (verifyReport.length) {
console.log('✅ 全部源数量完整、无异常剔除、无非法分辨率格式问题');
} else {
console.log('ℹ️ 暂无素材源(poster-sources.js 为空),请在 M2 逐条核实许可证后追加来源。');
}
}
main().catch(e => { console.error(e); process.exit(1); });