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>
This commit is contained in:
wangruiguo
2026-09-15 01:05:17 +08:00
parent 0584ba67b4
commit 3c4f236cba
483 changed files with 6520 additions and 201 deletions

View File

@@ -0,0 +1,524 @@
# -*- 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()