# -*- coding: utf-8 -*- """ 夹紧缸数据清洗脚本 v2(Univer 真实数模 -> OnebotCatalog 目录数据) 最终方案:11 个系列 + 5 维参数(缸径/臂位置/臂类型/臂样式/传感器) 用法: PYTHONUTF8=1 python gen-clamp-series.py --dry-run # 只分析打印,不写文件 PYTHONUTF8=1 python gen-clamp-series.py # 正式生成 """ import os, re, sys, shutil, collections, json SRC = r'X:/新开发产品-Research and Development/1_产品3D数模(发客户仿真)/1-夹紧缸' DST = r'D:/开发/OnebotCatalog/onebot-data/catalog' BORES = ['25缸', '32缸', '40缸', '50缸', '63缸', '80缸'] DRY = '--dry-run' in sys.argv LEGAL_BORES = [80, 63, 50, 40, 32, 25] # 系列前缀映射(旧码 -> 官方 UC 前缀码;UFL/UHL 单独系列) PREFIX_MAP = [ ('UCBP', 'UCBP'), ('UBP', 'UCBP'), ('UCBM', 'UCBM'), ('UBM', 'UCBM'), ('UCBT', 'UCBT'), ('UBT', 'UCBT'), ('UCBQ', 'UCBQ'), ('UBQ', 'UCBQ'), ('UCBH', 'UCBH'), ('UBH', 'UCBH'), ('UCDP', 'UCDP'), ('UDP', 'UCDP'), ('UCIM', 'UCIM'), ('IBM', 'UCIM'), ('UCIP', 'UCIP'), ('IBP', 'UCIP'), ('UCNM', 'UCNM'), ('UNM', 'UCNM'), ('UCNP', 'UCNP'), ('UNP', 'UCNP'), ('UFL', 'UFL'), ('UHL', 'UFL'), ] SERIES_NAME = { 'UCBP': ('UCBP 标准气动夹紧缸', 'UCBP Standard Pneumatic Power Clamp', 'ucbp jiajin clamping standard'), 'UCBM': ('UCBM 带手杆气动夹紧缸', 'UCBM Pneumatic Power Clamp with Hand Lever', 'ucbm jiajin lever'), 'UCBT': ('UCBT 带中央手杆气动夹紧缸', 'UCBT Pneumatic Power Clamp with Central Hand Lever', 'ucbt jiajin central lever'), 'UCBQ': ('UCBQ 带手杆补偿夹紧缸', 'UCBQ Power Clamp with Hand Lever & Compensation', 'ucbq jiajin compensation'), 'UCBH': ('UCBH 夹紧缸 Ø63/Ø22', 'UCBH Power Clamp Ø63/Ø22', 'ucbh jiajin'), 'UCDP': ('UCDP 双夹臂气动夹紧缸', 'UCDP Double Arm Pneumatic Power Clamp', 'ucdp jiajin double arm'), 'UCIM': ('UCIM 可互换带手杆夹紧缸', 'UCIM Interchangeable Power Clamp with Hand Lever', 'ucim jiajin interchangeable'), 'UCIP': ('UCIP 可互换气动夹紧缸', 'UCIP Interchangeable Pneumatic Power Clamp', 'ucip jiajin interchangeable'), 'UCNM': ('UCNM NAAMS 带手杆夹紧缸', 'UCNM NAAMS Power Clamp with Hand Lever', 'ucnm naams jiajin'), 'UCNP': ('UCNP NAAMS 气动夹紧缸', 'UCNP NAAMS Pneumatic Power Clamp', 'ucnp naams jiajin'), 'UFL': ('UFL/UHL 停产气动夹紧缸', 'UFL/UHL Legacy Pneumatic Power Clamp (Discontinued)', 'ufl uhl jiajin legacy'), } POS_DISPLAY = {'V': '90°', 'O': '180°', 'N': '无臂', '': '不适用'} TYPE_DISPLAY = {'C': '中央 15mm', 'D': '右 15mm', 'S': '左 15mm', 'E': '中央 45mm', 'F': '右 45mm', 'L': '左 45mm', 'N': '无臂', '': '不适用'} STYLE_DISPLAY = {'A': '铝臂', 'S': '钢臂', 'N': '无臂', '': '不适用'} SENSOR_DISPLAY = {'K': '电子 PNP M12', 'N': '无传感器', 'Y': '电子 PNP 白LED', 'D': '气动传感器', '': '不适用'} # 杂音标记(这些出现说明文件是测试/副本,归一化时优先丢弃) NOISE = re.compile(r'20\d{12,14}|PROVA|_ALLCATPART|ALLCATPART|-\d{2}-\d{2}-\d{4}') def _extract_bore(s): for b in LEGAL_BORES: bs = str(b) if s.startswith(bs): return b, s[len(bs):] return None def normalize(name): """归一化 -> (model_code, series, bore, pos, type, style, sensor) 或 None""" raw = os.path.splitext(name)[0] # 排除含非 ASCII(中文夹臂/大孔等) if not all(ord(c) < 128 for c in raw): return None base = raw.upper() # 先剥离 "_step"/"_1" 副本后缀(必须在去下划线之前,否则 _ 已被抹掉匹配不到) base = re.sub(r'[_\s]*_?STEP$', '', base) # UCBM40OESK0_step -> UCBM40OESK0 base = re.sub(r'_1$', '', base) base = base.replace(' ', '').replace('_', '') # 记录是否含杂音(用于去重优先级) has_noise = bool(NOISE.search(base)) base = NOISE.sub('', base) base = base.replace('-S', '\x01') # 钢臂标记占位(保护 -S 不被后续清理) base = base.replace('-', '') # 去连字符 base = base.replace('\x01', '-S') # 恢复 -S if not base: return None # 系列识别 series = rest = None for pfx, code in PREFIX_MAP: if base.startswith(pfx): series, rest = code, base[len(pfx):] break if series is None or rest == '': return None # UFL/UHL 特殊缸径:UFL450->50, UHL463->63 if series == 'UFL': if rest.startswith('450'): bore, tail = 50, rest[3:] elif rest.startswith('463'): bore, tail = 63, rest[3:] else: return None else: r = _extract_bore(rest) if r is None: return None bore, tail = r # 解析 pos/type/style/sensor pos = type_ = style = sensor = '' i = 0 if i < len(tail) and tail[i] in 'VON': pos, i = tail[i], i + 1 if i < len(tail) and tail[i] in 'CDSEFLN': type_, i = tail[i], i + 1 if i < len(tail) and tail[i] in 'ASN': style, i = tail[i], i + 1 if i < len(tail) and tail[i] in 'KNYD': sensor, i = tail[i], i + 1 # 旧命名 -S 后缀 => 钢臂 if tail.endswith('-S') and style == '': style = 'S' return (base, series, bore, pos, type_, style, sensor, has_noise) def collect(): search_roots = [os.path.join(SRC, b) for b in BORES] + [SRC] found = collections.defaultdict(list) for root in search_roots: for dirpath, dirs, files in os.walk(root): for f in files: if f.lower().endswith(('.stp', '.step')): found[f].append(os.path.join(dirpath, f)) # 候选:model -> 最佳源文件 best = {} for fname, paths in found.items(): r = normalize(fname) if r is None: continue model, series, bore, pos, type_, style, sensor, has_noise = r cur = best.get(model) # 优先级:干净名 > 杂音名;同优先级取 mtime 新 better = (cur is None or (cur['has_noise'] and not has_noise) or (cur['has_noise'] == has_noise and os.path.getmtime(paths[0]) > os.path.getmtime(cur['src']))) if better: best[model] = dict(model=model, series=series, bore=bore, pos=pos, type=type_, style=style, sensor=sensor, src=paths[0], has_noise=has_noise) return best def main(): models = collect() by_series = collections.defaultdict(list) for m in models.values(): by_series[m['series']].append(m) print('=== 归一化去重后型号总数:', len(models), '===') for series in sorted(by_series, key=lambda s: -len(by_series[s])): ms = by_series[series] bs = sorted(set(m['bore'] for m in ms)) print(f'{series:6s} 型号 {len(ms):4d} 缸径 {bs}') if DRY: print() for series in sorted(by_series): ms = sorted(by_series[series], key=lambda m: (m['bore'], m['model'])) print(f'\n## {series} ({len(ms)} 个)') for m in ms: code = f"pos={m['pos'] or '-'} type={m['type'] or '-'} style={m['style'] or '-'} sensor={m['sensor'] or '-'}" print(f" {m['model']:30s} bore={m['bore']:<3} {code}") return # 1) CSV csv_dir = os.path.join(DST, 'csv') for series, ms in by_series.items(): ms = sorted(ms, key=lambda m: (m['bore'], m['model'])) rows = ['model_code,bore,pos,type,style,sensor,step_file'] for m in ms: rows.append(f"{m['model']},{m['bore']},{m['pos']},{m['type']},{m['style']},{m['sensor']},{m['model']}.step") with open(os.path.join(csv_dir, series + '.csv'), 'w', encoding='utf-8-sig', newline='') as fp: fp.write('\r\n'.join(rows) + '\r\n') print(f'写 CSV: {series}.csv ({len(ms)} 行)') # 2) meta.json series_dir = os.path.join(DST, 'series') for series, ms in by_series.items(): bores = sorted(set(m['bore'] for m in ms)) # 枚举维度: 空串 '' 是合法值(表示该维度"无/未指定"), 必须保留在 values 里 # (参考 KC 的 magnet values=['', 'M']) —— 否则 DomainValid 会把 "" 判非法, # AllowedOptions 过滤后下拉全空。仅当除空串外无真值时, 才剔除该维度。 poss = [''] + sorted({m['pos'] for m in ms if m['pos']}) types = [''] + sorted({m['type'] for m in ms if m['type']}) styles = [''] + sorted({m['style'] for m in ms if m['style']}) sensors = [''] + sorted({m['sensor'] for m in ms if m['sensor']}) poss = poss if len(poss) > 1 else [] types = types if len(types) > 1 else [] styles = styles if len(styles) > 1 else [] sensors = sensors if len(sensors) > 1 else [] name_zh, name_en, kw = SERIES_NAME[series] params = [ {"code": "bore", "nameZh": "缸径", "nameEn": "Bore", "keywords": "bore gangjing", "unit": "mm", "type": "number", "values": bores, "display": {}}, {"code": "pos", "nameZh": "臂位置", "nameEn": "Arm Position", "keywords": "pos arm position weizhi", "unit": "", "type": "enum", "values": poss, "display": {k: POS_DISPLAY.get(k, k) for k in poss}}, {"code": "type", "nameZh": "臂类型", "nameEn": "Arm Type", "keywords": "type arm leixing", "unit": "", "type": "enum", "values": types, "display": {k: TYPE_DISPLAY.get(k, k) for k in types}}, {"code": "style", "nameZh": "臂样式", "nameEn": "Arm Style", "keywords": "style arm yangshi", "unit": "", "type": "enum", "values": styles, "display": {k: STYLE_DISPLAY.get(k, k) for k in styles}}, {"code": "sensor", "nameZh": "传感器", "nameEn": "Sensor", "keywords": "sensor chuanganqi", "unit": "", "type": "enum", "values": sensors, "display": {k: SENSOR_DISPLAY.get(k, k) for k in sensors}}, ] # 值域为空的参数维度直接剔除(避免 UI 出现无值下拉;该维度在本系列数据里不存在) params = [p for p in params if p['values']] meta = { "series": { "code": series, "nameZh": name_zh, "nameEn": name_en, "keywords": kw, "parameters": params, "rules": [], "naming": {"stepNameTemplate": "{model}.step"}, "attachments": [], "variants": [] }, "attachments": [ {"kind": "manual", "lang": "", "source": f"manual/{series}/p01.png", "model": None}, {"kind": "datasheet", "lang": "", "source": f"manual/{series}/{series}_manual.pdf", "model": None}, ] } with open(os.path.join(series_dir, series + '.meta.json'), 'w', encoding='utf-8') as fp: json.dump(meta, fp, ensure_ascii=False, indent=1) print(f'写 meta: {series}.meta.json bore={bores} pos={poss} type={types} style={styles} sensor={sensors}') # 3) 复制 STEP step_dir = os.path.join(DST, 'step') copied = 0 for m in models.values(): dst_file = os.path.join(step_dir, m['model'] + '.step') if not os.path.exists(dst_file): shutil.copy2(m['src'], dst_file) copied += 1 print(f'复制 STEP: {copied} 个(共 {len(models)} 型号)') # 4) 更新 catalog.json:CLM 分类 series 数组 cat_path = os.path.join(DST, 'catalog.json') with open(cat_path, 'r', encoding='utf-8-sig') as fp: cat = json.load(fp) series_order = ['UCBP', 'UCBM', 'UCBT', 'UCBQ', 'UCBH', 'UCDP', 'UCIM', 'UCIP', 'UCNM', 'UCNP', 'UFL'] for c in cat['categories']: if c['code'] == 'CLM': c['series'] = series_order with open(cat_path, 'w', encoding='utf-8-sig') as fp: json.dump(cat, fp, ensure_ascii=False, indent=1) print('更新 catalog.json: CLM.series =', series_order) if __name__ == '__main__': main()