/** * 海报素材构建脚本:从 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(/]*>/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(/]*>([\s\S]*?)<\/svg>/i); if (!m) return ''; return m[1].replace(//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 解码出 … */ 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 /]/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); });