// 从「画个标」共享图标库提取房子/房产相关素材(分层 + 精选) // 用法: node _design-test/extract-house.js const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const SRC_DIR = path.join(ROOT, 'js', 'data', 'sources'); const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'js', 'data', 'manifest', 'icons.json'), 'utf8')); const metaById = {}; (manifest.sources || []).forEach(s => { metaById[s.id] = s; }); // ── 分层词表(按 name 的 token 匹配,不做子串匹配)──────────────── const TIERS = [ { key: 'house', label: '房屋本体', desc: '房子 / 住宅 / 房产 —— 做「甄房」主图形最直接的候选', exact: ['house', 'houses', 'home', 'homes', 'household', 'homestead', 'villa', 'mansion', 'cabin', 'cottage', 'chalet', 'hut', 'bungalow', 'dwelling', 'residence', 'residential', 'townhouse', 'duplex', 'igloo', 'tent', 'houseroom', 'household', 'shelter', 'real-estate', 'realestate', 'estate', 'property', 'properties', 'apartment', 'apartments', 'condo', 'condominium', 'flat', 'housing', 'houselink', 'housekey', 'house-key'] }, { key: 'part', label: '门户构件', desc: '屋顶 / 门窗 / 钥匙 / 院落 —— 可做辅助图形或组合符号', exact: ['roof', 'roofing', 'chimney', 'door', 'doors', 'doorway', 'window', 'windows', 'gate', 'gates', 'fence', 'fencing', 'stairs', 'staircase', 'stair', 'elevator', 'key', 'keys', 'keyhole', 'keycard', 'lock', 'locks', 'padlock', 'garage', 'mailbox', 'balcony', 'porch', 'terrace', 'ladder', 'doorknob', 'doorbell', 'gateway', 'hedge', 'garden'] }, { key: 'building', label: '楼宇建筑', desc: '楼宇 / 公共建筑 / 城市 —— 适合「房产平台」类意象', exact: ['building', 'buildings', 'skyscraper', 'skyscrapers', 'tower', 'towers', 'office', 'hotel', 'motel', 'bank', 'store', 'shop', 'shopfront', 'warehouse', 'factory', 'industry', 'industrial', 'city', 'town', 'urban', 'architecture', 'architectural', 'landmark', 'monument', 'castle', 'church', 'temple', 'mosque', 'school', 'university', 'college', 'hospital', 'library', 'museum', 'stadium', 'bridge', 'barn', 'farm', 'silo', 'windmill', 'lighthouse', 'palace', 'fort', 'fortress', 'pyramid', 'pagoda', 'gatehouse', 'observatory', 'terminal', 'station', 'airport'] }, { key: 'deal', label: '房产交易', desc: '买卖 / 租售 / 中介服务 —— 做辅助标识(打勾、成交、钥匙交接)', exact: ['realtor', 'realty', 'mortgage', 'sold', 'for-sale', 'forsale', 'sale', 'sell', 'rent', 'rental', 'lease', 'agent', 'broker', 'handshake', 'appraisal', 'valuation', 'house-search', 'home-search', 'property-search'] }, { key: 'brand', label: '品牌标志', desc: '房产 / 家居类知名品牌 Logo(仅作参考,有商标风险,勿直接商用)', exact: ['zillow', 'trulia', 'redfin', 'realtor', 'rightmove', 'airbnb', 'booking', 'trivago', 'homeadvisor', 'houzz', 'idealista', 'immobiliare', 'century21', 'remax', 'coldwell', 'keller-williams', 'opendoor', 'offerpad', 'homelight', 'ikea', 'wayfair', 'homedepot', 'lowes', 'leroy-merlin', 'castorama', 'homify', 'nest', 'smart-home', 'matter', 'zigbee'] } ]; const TIER_INDEX = TIERS.map(t => ({ key: t.key, re: new RegExp('^(?:' + t.exact.join('|') + ')$', 'i') })); const NEG_RE = /(^|-)(off|offline|alert|error|forbidden|blocked|cancel|remove|delete|minus|slash|no|not|without|broken|warning|question|unavailable|unlock|open|add|plus|edit|download|upload|share|upload|export|import|send|edit|list|grid|check|search|refresh|sync|cog|settings|gear|service|tools?|repair)\b/i; // 「home-*」「house-*」这类带前缀的语义噪音(智能家居/家务类,与建筑无关) const NOISE_RE = /^(home|house)-(automation|assistant|assistantd|automation|wifi|pod|speaker|audio|appliance|appliances|theater|theatre|workout|button|sensor|monitor|screen|security|enterprise|assistant|hub|bridge|energy|battery|charging|charging-station|filming|sound|video|camera|lightning|lightning-bolt|thermometer|thermostat|music|movie|content|connect|network|server|storage|data|dns|domain|page|pages|work|study|education|learning|teaching|doctor|hospital|clinic|care|nurse|service|services|device|devices|interface|account|accounts|bank|banking|credit|loan|insurance|tax|budget|invoice|wallet|money|finance|business|office|work|cooking|cook|meal|food|chef|recipe|cleaning|clean|laundry|chores|chore|garden|plant|plants|watering|irrigation|pet|baby|kids|child|elderly|health|fitness|gym|yoga|sleep|snooze|reminder|schedule|calendar|clock|time|timer|alarm|doorbell|doorbell|lock|locker|key|keyboard|link|links|tilt|tilted|indicator|realtime|runtime|runtime)$/i; function tierOf(name) { const norm = name.toLowerCase().replace(/_/g, '-'); if (NOISE_RE.test(norm)) return null; for (const t of TIER_INDEX) if (t.re.test(norm)) return t.key; const parts = norm.split(/[-_ ]+/).filter(Boolean); for (const p of parts) { for (const t of TIER_INDEX) if (t.re.test(p)) return t.key; } return null; } const RE_NAME = /"name":"((?:[^"\\]|\\.)*)"/; const RE_SVG = /"svg":"((?:[^"\\]|\\.)*)"/; const RE_ID = /"id":"((?:[^"\\]|\\.)*)"/; const RE_TYPE = /"type":"((?:[^"\\]|\\.)*)"/; const RE_CAT = /"categoryCn":"((?:[^"\\]|\\.)*)"/; const files = fs.readdirSync(SRC_DIR).filter(f => f.endsWith('.js') && !f.startsWith('dicebear-')); const raw_list = []; const seenSvg = new Map(); for (const f of files) { const id = f.replace(/\.js$/, ''); const meta = metaById[id] || {}; if (meta.workspaces && meta.workspaces.length && !meta.workspaces.includes('logo') && !meta.workspaces.includes('avatar')) continue; const raw = fs.readFileSync(path.join(SRC_DIR, f), 'utf8'); const MARK = '{"id":"'; let pos = 0; while (true) { const start = raw.indexOf(MARK, pos); if (start === -1) break; let end = raw.indexOf('},{"id":"', start); if (end === -1) end = raw.indexOf(']}', start); if (end === -1) end = raw.length; const objStr = raw.slice(start, end); pos = end; const nm = RE_NAME.exec(objStr); if (!nm) continue; const name = nm[1]; const tier = tierOf(name); if (!tier) continue; const sm = RE_SVG.exec(objStr); if (!sm || sm[1].length < 20) continue; const svg = sm[1]; if (seenSvg.has(svg)) continue; const im = RE_ID.exec(objStr); seenSvg.set(svg, 1); raw_list.push({ id: im ? im[1] : '', name: name, tier: tier, source: id, sourceName: meta.nameCn || meta.name || id, type: (RE_TYPE.exec(objStr) || [, ''])[1] || meta.type || 'icon', license: meta.license || '', categoryCn: (RE_CAT.exec(objStr) || [, ''])[1], svg: svg.replace(/\\"/g, '"').replace(/\\\\/g, '\\') }); } } // ── 精选:同名图标最多保留 N 个不同来源(保证风格可选、又不爆炸)────── const CORE = new Set(['house', 'home', 'building', 'real-estate', 'estate', 'property', 'apartment', 'villa', 'cottage', 'cabin', 'mansion', 'townhouse', 'residence', 'residential', 'housing', 'flat', 'condo', 'roof', 'door', 'window', 'gate', 'key', 'keyhole', 'lock', 'garage', 'mailbox', 'fence', 'stairs', 'office', 'hotel', 'bank', 'school', 'hospital', 'warehouse', 'factory', 'shop', 'store', 'city', 'tower', 'skyscraper', 'castle', 'church', 'landmark', 'architecture']); const MAX_PER_NAME_CORE = 16; const MAX_PER_NAME_OTHER = 4; // 精选硬上限(保证风格多样又不过量) const TIER_CAP = { house: 560, part: 320, building: 360, deal: 90, brand: 170 }; const PER_SOURCE_CAP = 52; // 常见主流图标集优先级(做 LOGO 时通常最干净的几何线性风格) const PREFERRED = ['lucide', 'lucide-lab', 'tabler', 'phosphor', 'material-symbols', 'material-symbols-light', 'heroicons', 'mdi', 'mdi-light', 'remix', 'solar', 'mingcute', 'iconamoon', 'iconoir', 'hugeicons', 'carbon', 'fluent', 'basil', 'uil', 'icon-park-outline', 'icon-park', 'feather', 'ionicons', 'octicon', 'boxicons', 'bxs', 'bx', 'bootstrap', 'fa7-solid', 'fa7-regular', 'fluent-mdl2', 'antd', 'tdesign', 'semi-icons', 'line-md', 'gravity-ui', 'mage', 'clarity', 'jam', 'proicons', 'prime', 'cuida', 'pixelarticons', 'lets-icons', 'unjs', 'typicons', 'fontisto', 'foundation', 'devicon', 'streamline', 'gis', 'maki', 'gameicons', 'openmoji', 'noto', 'twemoji', 'emojione', 'fluent-emoji', 'simple-icons', 'cib', 'bxl', 'fa7-brands', 'logos', 'selfhst', 'svgl']; const PRANK = Object.fromEntries(PREFERRED.map((s, i) => [s, i])); const byName = new Map(); for (const it of raw_list) { if (NEG_RE.test(it.name)) continue; const k = it.tier + '|' + it.name.toLowerCase(); if (!byName.has(k)) byName.set(k, []); byName.get(k).push(it); } const staged = []; for (const [k, arr] of byName) { const name = k.split('|')[1]; const max = CORE.has(name) ? MAX_PER_NAME_CORE : MAX_PER_NAME_OTHER; // 优先主流图标集 -> 单色线性 -> id 短 arr.sort((a, b) => { const ap = PRANK[a.source] === undefined ? 999 : PRANK[a.source]; const bp = PRANK[b.source] === undefined ? 999 : PRANK[b.source]; if (ap !== bp) return ap - bp; if ((a.type !== 'icon') !== (b.type !== 'icon')) return (a.type !== 'icon') - (b.type !== 'icon'); return a.id.length - b.id.length; }); staged.push(...arr.slice(0, max)); } // 排序优先级:核心名精确命中 > 名字短 > 来源稳定 staged.sort((a, b) => { const an = a.name.toLowerCase(), bn = b.name.toLowerCase(); const ac = CORE.has(an) ? 0 : 1, bc = CORE.has(bn) ? 0 : 1; if (ac !== bc) return ac - bc; if (an.length !== bn.length) return an.length - bn.length; return a.source.localeCompare(b.source); }); const picked = []; const tierCount = {}; const srcCount = {}; for (const it of staged) { const cap = TIER_CAP[it.tier] || 200; if ((tierCount[it.tier] || 0) >= cap) continue; if ((srcCount[it.source] || 0) >= PER_SOURCE_CAP) continue; tierCount[it.tier] = (tierCount[it.tier] || 0) + 1; srcCount[it.source] = (srcCount[it.source] || 0) + 1; picked.push(it); } // 详情字段瘦身 const slim = picked.map(it => ({ id: it.id, name: it.name, tier: it.tier, source: it.source, sourceName: it.sourceName, type: it.type, license: it.license, categoryCn: it.categoryCn, svg: it.svg })); const stat = {}; slim.forEach(i => stat[i.tier] = (stat[i.tier] || 0) + 1); const srcStat = {}; slim.forEach(i => srcStat[i.source] = (srcStat[i.source] || 0) + 1); const outFile = path.join(__dirname, 'house-icons.json'); fs.writeFileSync(outFile, JSON.stringify({ total: slim.length, tiers: TIERS, list: slim })); console.log('原始命中(svg 去重后):', raw_list.length); console.log('精选后:', slim.length, JSON.stringify(stat)); console.log('命中源数:', Object.keys(srcStat).length); console.log('输出:', outFile, (fs.statSync(outFile).size / 1024).toFixed(0) + 'KB');