Files
OnebotCatalog/onebot-data/tools/gen-pending-series.py
wangruiguo 3c4f236cba v2.0: 4 分类真实数据替换 + 手册彩色重做 + 网页版改版
数据 (定位销/勾销缸/翻转缸/电缸 → Univer 真实数模)
- 新增 19 系列 1124 型号 (PIN 303 / HOK 73 / ROT 741 / ELEC 9)
- 命名规则取自官方图册 CODIFICATION KEY (pdftotext 提取), 非推测
- 解析用「官方值域校验 + 左对齐 DFS 填空」, 可处理省略空位的型号
- 参数组合唯一性门禁: 1124 型号全部通过 (避免选型静默错配)
- 占位/测试文件 (如 USP50J_0___K0 销径为空) 在解析阶段丢弃
- catalog.json 分类系列表同步; crossref 失效引用清理

手册页
- 全部 42 系列改为彩色 (此前脚本误写死 csGRAY 转灰度), 346 页
- 修复 UCBT 附图只有 1 页 (筛选条件写死 UCBT40 且漏 CLAMPING ARMS)
- gen-pending-series.py 改为扫描 manual 目录自动生成附件清单,
  避免重跑脚本把多页附件覆盖回单页

网页版
- 首页改为系列缩略图网格 (40 张: 27 张真实产品图 + 13 张占位图),
  按大类分组; 图放 web/series/<系列码>.webp, 覆盖同名文件即可替换
- 左侧树改手风琴 (默认收起, 一次展开一个), 父级带 SVG 图标,
  悬停放大动画, 按钮间距随高度均匀分布; 可收起为图标窄条
- 修复分隔条被拖出容器后无法找回 (上限改为容器感知 + resize 重夹取
  + 双击复位); 同时阻止图片原生拖拽
- 购物车打包改取 .step.gz 副档 (传输量降至约 1/4.5), 无副档自动回退
- 修复搜索到型号后点结果内容区空白 (switchTab('dim') 已失效)
- OpcPackage 超大包 (>1.5GB) 改走临时文件, 绕过 MemoryStream 2GB 上限

工具
- gen-pending-series/manual, copy-pending-step, gen-step-gz,
  gen-series-thumbs, scan-pending-series 等 11 个脚本

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 01:05:41 +08:00

525 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
未完成分类真实数据清洗 —— 定位销(PIN)/勾销缸(HOK)/翻转缸(ROT)/电缸(ELEC)
命名规则来自 Univer 官方图册 CODIFICATION KEY(pdftotext 提取核对)。
解析采用「官方值域校验 + 左对齐优先」的 DFS 填空,可正确处理省略了空位的型号
(如 ULP50040SK0 = rod 空 + orient=S + sensor=K)。
用法:
PYTHONUTF8=1 python gen-pending-series.py --scan # 只扫 X 盘并缓存
PYTHONUTF8=1 python gen-pending-series.py --dry-run # 解析统计,不写文件
PYTHONUTF8=1 python gen-pending-series.py --cat PIN # 只处理某分类
PYTHONUTF8=1 python gen-pending-series.py # 全部生成 CSV/meta
PYTHONUTF8=1 python gen-pending-series.py --copy # 同时复制 STEP(X盘慢)
"""
import os, re, sys, json, shutil, collections
BASE = r'X:/新开发产品-Research and Development/1_产品3D数模(发客户仿真)'
DST = r'D:/开发/OnebotCatalog/onebot-data/catalog'
CACHE = r'D:/开发/OnebotCatalog/onebot-data/tools/_scan_cache.json'
ARGS = sys.argv[1:]
DRY = '--dry-run' in ARGS
DO_COPY = '--copy' in ARGS
DO_SCAN = '--scan' in ARGS
ONLY_CAT = ARGS[ARGS.index('--cat') + 1].upper() if '--cat' in ARGS else None
# 所有待扫目录(各分类共用,一次扫完缓存)
ALL_DIRS = ['2-伸缩销', '2019 Math Data 铁臂', '2019-NEW product', '2020UNICLAMP数模汇总',
'3-勾销缸', '4-翻转缸', '1-夹紧缸/电缸', '5-夹臂', 'GR8_REV02']
# ---------------------------------------------------------------- 官方值域
D_SENSOR_PIN = set('NKDJY') # 定位销传感器
D_SENSOR_PIV = set('NKYJA') # 翻转缸传感器
D_SENSOR_PC = set('NKDJY') # 勾销缸传感器
D_ORIENT = set('XEONS')
D_MOUNT = set('AS')
D_ROD_ULP = set('ABCDEFHILM')
D_ROD_ULA = set('AGNP')
D_ROD_ULB = set('BCE')
D_ROD_ULT = set('AG')
D_MODEL = set('JRF')
D_PDIA = set('123456')
D_LOC = set('01')
D_HOOK = set('SD')
D_HOOKPOS = set('BC')
D_TPOS = set('OPVZ')
D_BRAKE = set('BMW')
D_CONN = set('SDFPC')
D_LAEPOS = set('NOV')
D_LAESTY = set('CDSN')
# ---------------------------------------------------------------- 中文显示名
SENSOR_PIN = {'N': '无传感器(带保护板)', 'K': '电子 PNP M12 (DF-K)',
'D': '气动传感器 (DF-UPNW)', 'J': '电子 NPN 光学 (DF-J)',
'Y': '电子 PNP M12 白LED (DF-Y)', '': '不适用'}
SENSOR_PIV = {'N': '无传感器(带保护板)', 'K': '电子 PNP M12 (DF-K)',
'Y': '电子 PNP M12 白LED (DF-Y)', 'J': '电子 NPN M12 (DF-J)',
'A': '电子 PNP 光学 M12 + 制动传感器', '': '不适用'}
ORIENT = {'X': '对称', 'E': '东', 'O': '西', 'N': '北', 'S': '南', '': '不适用'}
MOUNT = {'A': '55 mm', 'S': '标准', '': '不适用'}
ROD_ULP = {'A': '偏移销杆', 'B': '成型端杆', 'C': '十字成型端 内Ø16', 'D': '偏移销杆(加大销孔)',
'E': '十字成型端 内Ø10', 'F': 'NAAMS 兼容 内Ø16', 'H': '平端 内Ø10',
'I': '平端 内Ø12', 'L': '内Ø12', 'M': '内Ø20', '': '不适用'}
ROD_ULA = {'A': '偏移销杆(2螺钉1销)', 'N': '偏移销杆反孔(1螺钉2销)',
'G': '偏移销杆 杆面与本体中轴对齐', 'P': '偏移销杆反孔 杆面与中轴对齐', '': '不适用'}
ROD_ULB = {'B': '成型端杆', 'C': '十字成型端 内Ø16', 'E': '十字成型端 内Ø10', '': '不适用'}
ROD_ULT = {'A': '偏移销杆', 'G': '偏移销杆 杆面与本体中轴对齐', '': '不适用'}
MODEL_DISP = {'J': '标准', 'R': '带机械锁紧/解锁装置', 'F': '无补偿', '': '不适用'}
LOCATING = {'0': '不含定位销', '1': '含定位销', '': '不适用'}
HOOK = {'S': '单钩', 'D': '双钩', '': '不适用'}
HOOKPOS = {'B': '标准', 'C': '旋转180°', '': '不适用'}
TABLEPOS = {'O': '水平 90°', 'P': '水平 O 镜像', 'V': '垂直 180°', 'Z': '垂直 V 镜像', '': '不适用'}
BRAKE = {'B': '带制动', 'M': '带制动+手动解锁', 'W': '无制动', '': '不适用'}
CONN = {'S': '左侧(标准)', 'D': '右侧', 'F': '前侧', 'P': '后侧', '': '不适用'}
# LAGP 旧码特有值(官方图册已改名 UAGP,无记载,按数据实际取值标注)
SENSOR_PIV_LEGACY = dict(SENSOR_PIV, U='旧码传感器位')
LAEPOS = {'N': '无臂', 'O': '180°', 'V': '90°', '': '不适用'}
LAESTY = {'C': '中央 15mm offset', 'D': '右侧 15mm offset', 'S': '左侧 15mm offset',
'N': '无臂', '': '不适用'}
# 维度元信息: code -> (zh, en, keywords, unit, type)
DIM_META = {
'size': ('规格', 'Size', 'guige size', 'mm', 'number'),
'stroke': ('行程', 'Stroke', 'xingcheng stroke', 'mm', 'number'),
'rod': ('杆端样式', 'Rod End Style', 'ganduan rod end', '', 'enum'),
'orient': ('杆方向', 'Rod Orientation', 'ganfangxiang orientation', '', 'enum'),
'mount': ('侧装方式', 'Side Mounting', 'cezhuang mounting', '', 'enum'),
'sensor': ('传感器', 'Sensor', 'chuanganqi sensor', '', 'enum'),
'pcmodel': ('型式', 'Model', 'xingshi pin clamp model', '', 'enum'),
'prog': ('流水号', 'Progressive No.', 'liushuihao prog special', '', 'enum'),
'pin_dia': ('销径', 'Pin Diameter', 'xiaojing pin dia', 'mm', 'number'),
'locating': ('定位销', 'Locating Pin', 'dingweixiao locating pin', '', 'enum'),
'hook': ('挂钩', 'Hook', 'guagou hook', '', 'enum'),
'hook_pos': ('挂钩位置', 'Hook Position', 'guagouweizhi hook pos', '', 'enum'),
'table_pos': ('工作台位置', 'Table Position', 'gongzuotai table pos', '', 'enum'),
'rotation': ('翻转角度', 'Pivot Rotation', 'fanzhuanjiaodu rotation', '°', 'number'),
'brake': ('制动系统', 'Brake System', 'zhidong brake', '', 'enum'),
'conn': ('接口方向', 'Connections', 'jiekou connections', '', 'enum'),
'pos': ('臂位置', 'Arm Position', 'biweizhi arm pos', '', 'enum'),
'style': ('臂样式', 'Arm Style', 'biyangshi arm style', '', 'enum'),
'control': ('控制单元', 'Control Unit', 'kongzhi control unit', '', 'enum'),
}
DIM_DISPLAY = {
'sensor': None, 'orient': ORIENT, 'mount': MOUNT, 'pcmodel': MODEL_DISP,
'locating': LOCATING, 'hook': HOOK, 'hook_pos': HOOKPOS,
'table_pos': TABLEPOS, 'brake': BRAKE, 'conn': CONN,
'pos': LAEPOS, 'style': LAESTY,
}
# ---------------------------------------------------------------- 工具
def fill_dims(s, dims, domains, prefer_left=True):
"""把字符串 s 填进 dims(允许维度为空),左对齐优先。返回 dict 或 None。"""
def dfs(i, j):
if i == len(s):
return {d: '' for d in dims[j:]}
if j >= len(dims):
return None
order = [True, False] if prefer_left else [False, True]
for eat in order:
if eat:
if s[i] in domains.get(dims[j], set()):
r = dfs(i + 1, j + 1)
if r is not None:
return dict(r, **{dims[j]: s[i]})
else:
# 当前维度为空
if j + 1 <= len(dims):
r = dfs(i, j + 1)
if r is not None:
return dict(r, **{dims[j]: ''})
return None
return dfs(0, 0)
def norm(raw):
"""去全部扩展名 / 大写 / 去空格 / 组合件取 '-' 前主码"""
base = raw.strip()
while True: # 处理 .stp.STP 这类多重扩展名
stem, ext = os.path.splitext(base)
if ext.lower() in ('.stp', '.step'):
base = stem
else:
break
s = re.sub(r'\s+', '', base.upper())
if '-' in s: # 与定位销组合的勾销缸:取主码
s = s.split('-')[0]
return s
def dig(s, n):
return s[:n] if len(s) >= n else None
# ---------------------------------------------------------------- 解析器
def parse_pin(stem, ver, rod_dom):
"""UL<ver><size2><stroke3><dims...><sensor1><rev1>[<atex1>]"""
m = re.match(r'^UL' + ver + r'(\d{2})(\d{3})(.+)$', stem)
if not m:
return None
size, stroke, rest = m.groups()
if rest.endswith('X') and len(rest) > 4: # ATEX
rest = rest[:-1]
if len(rest) < 2:
return None
if not rest[-1].isdigit(): # product revision
return None
body = rest[:-1]
dims = {'A': ['rod', 'mount', 'sensor'], 'B': ['rod', 'sensor'],
'L': ['rod', 'orient', 'sensor'], 'M': ['rod', 'orient', 'sensor'],
'O': ['rod', 'sensor'], 'P': ['rod', 'orient', 'sensor'],
'R': ['rod', 'orient', 'sensor'], 'T': ['rod', 'sensor'],
'X': ['rod', 'sensor'], 'Y': ['rod', 'orient', 'sensor'],
'Z': ['rod', 'orient', 'sensor']}[ver]
domains = {'rod': rod_dom, 'orient': D_ORIENT, 'mount': D_MOUNT,
'sensor': D_SENSOR_PIN}
got = fill_dims(body, dims, domains)
if got is None:
return None
return dict(got, series='UL' + ver, size=int(size), stroke=int(stroke))
def parse_usp(stem, series):
"""US[P|L]<size2><model1><pdia1><loc1><prog3><sensor1><rev1>[<variant>] _ = 该位为空"""
m = re.match(r'^US[PL](\d{2})([A-Z_])([0-9_])([0-9_])([0-9_]{3})([A-Z_])([0-9_])[A-Z]*$', stem)
if not m:
return None
size, mdl, pdia, loc, pg, sen, _rev = m.groups()
return {'series': series, 'size': int(size),
'pcmodel': '' if mdl == '_' else mdl,
'pin_dia': int(pdia) if pdia.isdigit() else None,
'locating': '' if loc == '_' else loc,
'prog': '' if set(pg) == {'_'} else pg,
'sensor': '' if sen == '_' else sen}
def parse_usr(stem):
"""USR<size2><model1><pdia1><loc1><hook1><prog3><sensor1><rev1> _ = 该位为空"""
m = re.match(r'^USR(\d{2})([A-Z_])([0-9_])([0-9_])([A-Z_])([0-9_]{3})([A-Z_])([0-9_])$', stem)
if not m:
return None
size, mdl, pdia, loc, hook, pg, sen, _rev = m.groups()
return {'series': 'USR', 'size': int(size),
'pcmodel': '' if mdl == '_' else mdl,
'pin_dia': int(pdia) if pdia.isdigit() else None,
'locating': '' if loc == '_' else loc,
'hook': '' if hook == '_' else hook,
'prog': '' if set(pg) == {'_'} else pg,
'sensor': '' if sen == '_' else sen}
def parse_uss(stem, series):
"""US[SL|S]<size2><model1><hookpos1><pdia1><loc1><prog3><sensor1><rev1>"""
m = re.match(r'^US(?:SL|S)(\d{2})([A-Z])([A-Z])(\d)(\d)(\d{3})([A-Z])(\d)$', stem)
if not m:
return None
size, mdl, hpos, pdia, loc, pg, sen, _rev = m.groups()
return {'series': series, 'size': int(size), 'pcmodel': mdl, 'hook_pos': hpos,
'pin_dia': int(pdia), 'locating': loc, 'prog': pg, 'sensor': sen}
def parse_uagp(stem):
"""UAGP<size3><tpos1><sensor1><rot3><brake1><conn1><rev> _ 表示该位为空"""
m = re.match(r'^UAGP(\d{3})([A-Z_])([A-Z_])(\d{3})([A-Z])([A-Z_])[A-Z0-9_]*$', stem)
if not m:
return None
size, tp, sen, rot, brk, cn = m.groups()
return {'series': 'UAGP', 'size': int(size),
'table_pos': '' if tp == '_' else tp,
'sensor': '' if sen == '_' else sen,
'rotation': int(rot), 'brake': brk,
'conn': '' if cn == '_' else cn}
def parse_lagp(stem):
"""LAGP<size3><tpos1><sensor1><rot3><brake1>[<conn1>] (旧码,部分无 conn)"""
m = re.match(r'^LAGP(\d{3})([A-Z_])([A-Z_])(\d{3})([A-Z])([A-Z]?)$', stem)
if not m:
return None
size, tp, sen, rot, brk, cn = m.groups()
if sen not in D_SENSOR_PIV and sen != '_':
sen = 'U' # LAGP063 早期码用 U 表示传感器位
return {'series': 'LAGP', 'size': int(size),
'table_pos': '' if tp == '_' else tp,
'sensor': '' if sen == '_' else sen,
'rotation': int(rot), 'brake': brk, 'conn': cn or ''}
def parse_lae(stem):
"""LAE<size3><pos1><style1><controlN>"""
m = re.match(r'^LAE(\d{3})([A-Z])([A-Z])(\d+)$', stem)
if not m:
return None
size, pos, sty, ctl = m.groups()
return {'series': 'LAE', 'size': int(size), 'pos': pos, 'style': sty, 'control': ctl}
# ---------------------------------------------------------------- 系列配置
SPECS = [
dict(cat='PIN', series='ULA', parse=lambda s: parse_pin(s, 'A', D_ROD_ULA),
rod=ROD_ULA, zh='ULA 气动可伸缩定位销(双杆可调)',
en='ULA Pneumatic Retractable Locating Pin Unit, double orientable rod',
kw='ula dingweixiao locating pin'),
dict(cat='PIN', series='ULB', parse=lambda s: parse_pin(s, 'B', D_ROD_ULB),
rod=ROD_ULB, zh='ULB 气动可伸缩定位销(后置传感器)',
en='ULB Pneumatic Retractable Locating Pin Unit, rear sensor, single rod',
kw='ulb dingweixiao locating pin'),
dict(cat='PIN', series='ULL', parse=lambda s: parse_pin(s, 'L', D_ROD_ULP),
rod=ROD_ULP, zh='ULL 手动可伸缩定位销(手杆)',
en='ULL Manual Retractable Locating Pin Unit with Hand Lever',
kw='ull dingweixiao shoudong manual'),
dict(cat='PIN', series='ULM', parse=lambda s: parse_pin(s, 'M', D_ROD_ULP),
rod=ROD_ULP, zh='ULM 带手杆气动可伸缩定位销',
en='ULM Pneumatic Retractable Locating Pin Unit with Hand Lever',
kw='ulm dingweixiao shougan lever'),
dict(cat='PIN', series='ULO', parse=lambda s: parse_pin(s, 'O', D_ROD_ULA),
rod=ROD_ULA, zh='ULO 气动可伸缩定位销(双杆带手杆)',
en='ULO Pneumatic Retractable Locating Pin Unit, double rod with hand lever',
kw='ulo dingweixiao double rod'),
dict(cat='PIN', series='ULP', parse=lambda s: parse_pin(s, 'P', D_ROD_ULP),
rod=ROD_ULP, zh='ULP 气动可伸缩定位销(单杆)',
en='ULP Pneumatic Retractable Locating Pin Unit, single rod',
kw='ulp dingweixiao locating pin'),
dict(cat='PIN', series='ULR', parse=lambda s: parse_pin(s, 'R', D_ROD_ULP),
rod=ROD_ULP, zh='ULR 紧凑型气动可伸缩定位销',
en='ULR Compact Pneumatic Retractable Locating Pin Unit',
kw='ulr dingweixiao compact'),
dict(cat='PIN', series='ULT', parse=lambda s: parse_pin(s, 'T', D_ROD_ULT),
rod=ROD_ULT, zh='ULT 肘节机构气动可伸缩定位销(不可逆)',
en='ULT Irreversible Pneumatic Retractable Locating Pin Unit, double rod',
kw='ult dingweixiao toggle'),
dict(cat='PIN', series='ULX', parse=lambda s: parse_pin(s, 'X', D_ROD_ULT),
rod=ROD_ULT, zh='ULX 肘节机构气动可伸缩定位销(带手杆)',
en='ULX Irreversible Pneumatic Locating Pin Unit with Hand Lever',
kw='ulx dingweixiao toggle lever'),
dict(cat='PIN', series='ULY', parse=lambda s: parse_pin(s, 'Y', D_ROD_ULP),
rod=ROD_ULP, zh='ULY 肘节机构气动可伸缩定位销(单杆带手杆)',
en='ULY Irreversible Pneumatic Locating Pin Unit, single rod with hand lever',
kw='uly dingweixiao toggle'),
dict(cat='PIN', series='ULZ', parse=lambda s: parse_pin(s, 'Z', D_ROD_ULP),
rod=ROD_ULP, zh='ULZ 肘节机构气动可伸缩定位销(单杆)',
en='ULZ Irreversible Pneumatic Retractable Locating Pin Unit, single rod',
kw='ulz dingweixiao toggle'),
dict(cat='HOK', series='USP', parse=lambda s: parse_usp(s, 'USP'), rod=None,
zh='USP 气动勾销缸', en='USP Pneumatic Pin Clamp', kw='usp gouxiao pin clamp'),
dict(cat='HOK', series='USL', parse=lambda s: parse_usp(s, 'USL'), rod=None,
zh='USL 手动勾销缸(手杆)', en='USL Manual Pin Clamp with Hand Lever',
kw='usl gouxiao manual'),
dict(cat='HOK', series='USR', parse=parse_usr, rod=None,
zh='USR 紧凑型气动勾销缸', en='USR Pneumatic Compact Pin Clamp',
kw='usr gouxiao compact'),
dict(cat='HOK', series='USS', parse=lambda s: parse_uss(s, 'USS'), rod=None,
zh='USS 带补偿气动勾销缸', en='USS Pneumatic Pin Clamp with Compensation',
kw='uss gouxiao compensation'),
dict(cat='HOK', series='USSL', parse=lambda s: parse_uss(s, 'USSL'), rod=None,
zh='USSL 激光焊用气动勾销缸',
en='USSL Pneumatic Pin Clamp for Laser Beam Applications',
kw='ussl gouxiao laser'),
dict(cat='ROT', series='UAGP', parse=parse_uagp, rod=None,
zh='UAGP 气动翻转缸', en='UAGP Pneumatic Power Pivot',
kw='uagp fanzhuan power pivot'),
dict(cat='ROT', series='LAGP', parse=parse_lagp, rod=None,
zh='LAGP 气动翻转缸(旧型号)', en='LAGP Pneumatic Power Pivot (Legacy)',
kw='lagp fanzhuan power pivot legacy'),
dict(cat='ELEC', series='LAE', parse=parse_lae, rod=None,
zh='LAE 电动夹紧缸', en='LAE Electric Power Clamp',
kw='lae diandong electric clamp'),
]
# ---------------------------------------------------------------- 扫描 / 缓存
def do_scan():
found = collections.defaultdict(list)
for d in ALL_DIRS:
root = os.path.join(BASE, d)
if not os.path.isdir(root):
print(f' ! 目录不存在: {d}')
continue
n0 = len(found)
for dirpath, _, files in os.walk(root):
for f in files:
if f.lower().endswith(('.stp', '.step')):
found[f].append(os.path.join(dirpath, f).replace('\\', '/'))
print(f' {d}: +{len(found)-n0} 文件')
with open(CACHE, 'w', encoding='utf-8') as fp:
json.dump(found, fp)
print(f'缓存写入 {CACHE} 共 {len(found)} 个唯一文件名')
def load_scan():
if not os.path.exists(CACHE):
sys.exit(f'缺少扫描缓存,请先运行: --scan ({CACHE})')
with open(CACHE, encoding='utf-8') as fp:
return json.load(fp)
# ---------------------------------------------------------------- 生成
def collect(spec, scanned):
by_model, unresolved = {}, []
for fname, paths in scanned.items():
stem = norm(fname)
if not stem.startswith(spec['series']):
continue
try:
rec = spec['parse'](stem)
except Exception as e:
unresolved.append((stem, f'ERR {e}'))
continue
if rec is None or rec.get('series') != spec['series']:
unresolved.append((stem, fname))
continue
# 占位/测试文件(如 USP50J_0___K0 销径为空)不是真实型号,丢弃
if any(v is None for k, v in rec.items() if k not in ('series',)):
unresolved.append((stem, fname + ' (关键参数为空, 丢弃)'))
continue
by_model.setdefault(stem, dict(rec, model=stem, src=paths[0]))
return by_model, unresolved
def build_meta(spec, records):
order = ['size', 'stroke', 'rod', 'orient', 'mount', 'pos', 'style', 'pcmodel',
'hook_pos', 'hook', 'pin_dia', 'locating', 'prog', 'table_pos', 'rotation',
'brake', 'conn', 'control', 'sensor']
# 主键 model=完整型号,绝不能当维度
dims = [d for d in order if d != 'model' and any(d in r for r in records)]
params = []
for d in dims:
zh, en, kw, unit, typ = DIM_META[d]
vals = sorted({r[d] for r in records if r.get(d) not in (None, '')})
if typ == 'number':
params.append({"code": d, "nameZh": zh, "nameEn": en, "keywords": kw,
"unit": unit, "type": "number", "values": vals, "display": {}})
continue
has_empty = any(r.get(d, '') == '' for r in records)
vlist = ([''] if has_empty else []) + [str(v) for v in vals]
if d == 'sensor':
dmap = SENSOR_PIV if spec['cat'] == 'ROT' else SENSOR_PIN
if spec['series'] == 'LAGP':
dmap = SENSOR_PIV_LEGACY
elif d == 'rod':
dmap = spec.get('rod') or ROD_ULP
else:
dmap = DIM_DISPLAY.get(d) or {}
params.append({"code": d, "nameZh": zh, "nameEn": en, "keywords": kw,
"unit": unit, "type": "enum", "values": vlist,
"display": {k: dmap.get(k, k) for k in vlist}})
# 剔除非信息维度: 值域为空(无可选值), 或枚举维度只有 [空串] 一种取值。
# 保留"单一真值"维度(如单型号系列的 pcmodel=['J']) —— 否则参数全空导致
# CSV 表头出现空列名, 构建器报"CSV 行字段不足"。
params = [p for p in params
if p['values'] and not (p['type'] == 'enum' and p['values'] == [''])]
# 附件按 manual/<系列>/ 实际 png 数量生成 —— 不能硬编码 p01。
# (曾踩坑: 先跑 gen-pending-manual 生成多页, 再跑本脚本重写 meta 时把附件
# 覆盖回单页, 导致包内只有 1 张附图。此处扫目录自动对齐。)
atts = []
mdir = os.path.join(DST, 'manual', spec['series'])
if os.path.isdir(mdir):
for f in sorted(os.listdir(mdir)):
if f.lower().endswith('.png'):
atts.append({"kind": "manual", "lang": "",
"source": f"manual/{spec['series']}/{f}", "model": None})
for f in sorted(os.listdir(mdir)):
if f.lower().endswith('.pdf'):
atts.append({"kind": "datasheet", "lang": "",
"source": f"manual/{spec['series']}/{f}", "model": None})
if not atts:
atts = [
{"kind": "manual", "lang": "", "source": f"manual/{spec['series']}/p01.png", "model": None},
{"kind": "datasheet", "lang": "", "source": f"manual/{spec['series']}/{spec['series']}_manual.pdf", "model": None},
]
return {
"series": {
"code": spec['series'], "nameZh": spec['zh'], "nameEn": spec['en'],
"keywords": spec['kw'], "parameters": params, "rules": [],
"naming": {"stepNameTemplate": "{model}.step"},
"attachments": [], "variants": []
},
"attachments": atts
}
def main():
if DO_SCAN:
do_scan()
return
scanned = load_scan()
print(f'扫描缓存: {len(scanned)} 个唯一文件名')
csv_dir = os.path.join(DST, 'csv')
series_dir = os.path.join(DST, 'series')
step_dir = os.path.join(DST, 'step')
summary = collections.defaultdict(list)
for spec in SPECS:
if ONLY_CAT and spec['cat'] != ONLY_CAT:
continue
records, unresolved = collect(spec, scanned)
recs = sorted(records.values(), key=lambda r: r['model'])
print(f"\n{'='*74}\n## {spec['cat']} / {spec['series']} 型号 {len(recs)} 个")
summary[spec['cat']].append((spec['series'], len(recs)))
if not recs:
print(' (X盘无数据 —— 保留原 demo)')
continue
print(' 例:', {k: v for k, v in recs[0].items() if k != 'src'})
if unresolved:
print(f' 未解析 {len(unresolved)}:', [u[0] for u in unresolved[:8]])
meta = build_meta(spec, recs)
for p in meta['series']['parameters']:
print(f" {p['code']:10s} {p['type']:6s} {p['values']}")
# 唯一性门禁: 参数组合必须唯一确定型号,否则选型会静默给错货
pcs = [p['code'] for p in meta['series']['parameters']]
seen = {}
for r in recs:
seen.setdefault(tuple(str(r.get(d, '')) for d in pcs), []).append(r['model'])
dup = {k: v for k, v in seen.items() if len(v) > 1}
if dup:
print(f' !! 参数组合冲突 {len(dup)} 组 —— 会被静默错配:')
for k, v in list(dup.items())[:5]:
print(' ', dict(zip(pcs, k)), '->', v)
else:
print(f' [OK] 参数组合唯一 ({len(seen)} 组合 / {len(recs)} 型号)')
if DRY:
continue
dims = [p['code'] for p in meta['series']['parameters']]
def cv(x):
return '' if x is None else str(x)
rows = ['model_code,' + ','.join(dims) + ',step_file']
for r in recs:
rows.append(','.join([r['model']] + [cv(r.get(d, '')) for d in dims]
+ [r['model'] + '.step']))
with open(os.path.join(csv_dir, spec['series'] + '.csv'), 'w',
encoding='utf-8-sig', newline='') as fp:
fp.write('\r\n'.join(rows) + '\r\n')
with open(os.path.join(series_dir, spec['series'] + '.meta.json'), 'w',
encoding='utf-8') as fp:
json.dump(meta, fp, ensure_ascii=False, indent=1)
print(f" -> 写入 {spec['series']}.csv / .meta.json")
if DO_COPY:
n = 0
for r in recs:
dst = os.path.join(step_dir, r['model'] + '.step')
if not os.path.exists(dst):
shutil.copy2(r['src'], dst)
n += 1
print(f' -> 复制 STEP {n} 个')
print(f"\n{'='*74}\n### 汇总")
for cat, lst in summary.items():
tot = sum(n for _, n in lst)
print(f' {cat:5s} 共 {tot:4d} 型号 ' + ', '.join(f'{s}:{n}' for s, n in lst))
if __name__ == '__main__':
main()