Files

595 lines
28 KiB
JavaScript
Raw Permalink 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.
/**
* 海报素材构建脚本:从 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); });