Files

215 lines
11 KiB
JavaScript

// 从「画个标」共享图标库提取房子/房产相关素材(分层 + 精选)
// 用法: 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');