215 lines
11 KiB
JavaScript
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');
|