v1.0: 夹紧缸模块真实数据替换 + 仓库瘦身

数据 (CLM 气动夹紧缸分类):
- 1 系列 demo -> 11 个官方系列, 6 型号 -> 859 个真实型号
- 参数化升级: 仅缸径 -> 最多 5 维 (缸径/臂位置/臂类型/臂样式/传感器)
- 11 系列各含官方图册附图 (84 张) + datasheet PDF
- 修复: 枚举维度空串必须为合法值 (否则级联过滤后下拉全空)
- 修复: catalog.json 分类 series 数组未更新导致新系列不进包
- 修复: _step 副本后缀归一化错误 (产生假型号)

工具:
- gen-clamp-series.py     数据清洗 (X盘数模 -> CSV/meta/STEP)
- copy-clamp-step.py      8线程并行复制 (网络盘 I/O 优化)
- gen-clamp-manual.py     官方图册 PDF -> 附图
- update-clamp-meta-attach.py / fix-clamp-meta-empty-enum.py

仓库瘦身:
- .gitignore 排除数模(5GB)/.opc(1.2GB)/编译产物/发布产物
- 移出索引: release/ bin产物 STEP数模 .opc 大型PDF 等
- 提交体积 17MB (原 15.97GB)

其他: SelfTest.cs 可解析选型逻辑放宽 (稀疏数据用真实变体回验)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
wangruiguo
2026-09-14 15:24:16 +08:00
parent acbf6f2db0
commit 0584ba67b4
4301 changed files with 3366 additions and 1474787 deletions

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""并行复制夹紧缸 STEP 文件(去重后)到 catalog/step/,用归一化 .step 命名。"""
import os, sys, shutil, collections
from concurrent.futures import ThreadPoolExecutor, as_completed
sys.path.insert(0, os.path.dirname(__file__))
# 复用 gen-clamp-series 的 collect()(仅复制,不重写 CSV/meta)
import importlib.util
spec = importlib.util.spec_from_file_location('gcs', os.path.join(os.path.dirname(__file__), 'gen-clamp-series.py'))
gcs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gcs)
DST = r'D:/开发/OnebotCatalog/onebot-data/catalog/step'
os.makedirs(DST, exist_ok=True)
models = gcs.collect()
print(f'去重后需复制 {len(models)} 个文件')
todo = []
for m in models.values():
dst = os.path.join(DST, m['model'] + '.step')
if not os.path.exists(dst):
todo.append((m['src'], dst, m['model']))
print(f'尚未复制 {len(todo)} 个')
def cp(item):
src, dst, model = item
try:
shutil.copy2(src, dst)
return (model, 'ok', None)
except Exception as e:
return (model, 'err', str(e))
done = 0
with ThreadPoolExecutor(max_workers=8) as ex:
futs = [ex.submit(cp, it) for it in todo]
for fut in as_completed(futs):
model, status, err = fut.result()
done += 1
if status == 'err':
print(f'[ERR] {model}: {err}')
elif done % 50 == 0:
print(f' 进度 {done}/{len(todo)}')
print(f'完成,共复制 {done} 个文件')

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""
修复已有 meta.json: 枚举维度的空串 '' 必须是合法值(参考 KC magnet values=['','M'])。
原因: DomainValid 要求 enum 值在 values 内, 数据里大量型号该维度为空 → 判非法
→ AllowedOptions 过滤后下拉全空 (用户反馈"只有 UCBP 能选, 其他都空")。
用法: PYTHONUTF8=1 python fix-clamp-meta-empty-enum.py
"""
import json, csv, os
CAT = r'D:/开发/OnebotCatalog/onebot-data/catalog'
SERIES = ['UCBP','UCBM','UCBT','UCBQ','UCBH','UCDP','UCIM','UCIP','UCNM','UCNP','UFL']
ENUM_CODES = ['pos', 'type', 'style', 'sensor']
DISPLAY_EMPTY = {
'pos': '不适用', 'type': '不适用', 'style': '不适用', 'sensor': '不适用',
}
for s in SERIES:
mp = os.path.join(CAT, 'series', s + '.meta.json')
with open(mp, encoding='utf-8') as f:
meta = json.load(f)
params = meta['series']['parameters']
changed = []
for p in params:
if p['code'] not in ENUM_CODES:
continue
vals = p['values']
if '' in vals:
continue
# 从 CSV 确认该维度是否真有空值
cp = os.path.join(CAT, 'csv', s + '.csv')
has_empty = False
with open(cp, encoding='utf-8-sig') as f:
for row in csv.DictReader(f):
if p['code'] in row and (row[p['code']] or '') == '':
has_empty = True
break
if not has_empty:
continue
p['values'] = [''] + vals
d = p.get('display') or {}
d[''] = DISPLAY_EMPTY.get(p['code'], '不适用')
p['display'] = d
changed.append(f"{p['code']}:{p['values']}")
if changed:
with open(mp, 'w', encoding='utf-8') as f:
json.dump(meta, f, ensure_ascii=False, indent=1)
print(f'[{s}] 补空值 -> {" | ".join(changed)}')
else:
print(f'[{s}] 无需修改')

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""
为夹紧缸 11 个系列生成附图(多页尺寸图 + 裁剪版数据表 PDF)。
来源:Univer 官方图册 PDF(X 盘)。
用法: PYTHONUTF8=1 python gen-clamp-manual.py [--clean]
--clean 先清空各系列 manual 目录再生成(避免旧页残留)
输出: manual/<系列>/p<NN>.png + <系列>_manual.pdf(裁成该系列的页)
"""
import fitz, os, shutil, sys
SRC = r'X:/新开发产品-Research and Development/2_产品资料/Univer资料/官方图册'
DST = r'D:/开发/OnebotCatalog/onebot-data/catalog/manual'
DPI = 110
CLEAN = '--clean' in sys.argv
MAIN_CATALOG = 'katalog-pneumatische-spanner-univer-13032019-en.pdf'
# 系列 -> 官方 PDF 列表(多缸径系列合并成一个系列的图,按缸径顺序)
SERIES_PDFS = {
'UCBP': ['ucbp25cten_en380', 'ucbp40en_en382', 'ucbp50cten_en391', 'ucbp63cten_en401', 'ucbp80cten_en392'],
'UCBM': ['ucbm32cten_en432', 'ucbm50cten_en394', 'ucbm63cten_en395', 'ucbm80cten_en414'],
'UCBH': ['ucbh63cten_en393'],
'UCBQ': ['ucbq63cten_en397'],
'UCDP': ['ucdp40cten_en417', 'ucdp63cten_en418'],
'UCIM': ['ucim40cten_en415'],
'UCIP': ['ucip40cten_en402'],
'UCNM': ['ucnm50cten_en387', 'ucnm63cten_en389', 'ucnm80cten_en429'],
'UCNP': ['ucnp50cten_en385', 'ucnp63cten_en403', 'ucnp80cten_en388'],
# UFL450/UHL463 实为官方 UCBL(Manual power clamps Ø50/Ø63)
'UFL': ['ucbl450cten_en398', 'ucbl463cten_en478'],
}
UCBT_FROM_MAIN = True # UCBT 无独立图册,从主目录提取
def classify(page):
"""按内容标记归类页面: char(特性) / dim(尺寸) / arm(夹臂配件) / other"""
t = page.get_text().upper()
if 'CLAMPING ARMS' in t or 'ACCESSORIES' in t:
return 'arm'
if 'DIMENSIONS' in t:
return 'dim'
if 'CHARACTERISTICS' in t or 'CODIFICATION' in t:
return 'char'
return 'other'
def render(pages, out_dir, start_no):
"""pages = [(doc, page_idx), ...] 渲染为 p<NN>.png,返回下一个编号"""
n = start_no
for doc, i in pages:
pix = doc[i].get_pixmap(dpi=DPI, colorspace=fitz.csGRAY)
pix.save(os.path.join(out_dir, f'p{n:02d}.png'))
n += 1
return n
def main():
os.makedirs(DST, exist_ok=True)
for series, pdfs in SERIES_PDFS.items():
d = os.path.join(DST, series)
if CLEAN and os.path.isdir(d):
shutil.rmtree(d)
os.makedirs(d, exist_ok=True)
all_pages = [] # (doc, page_idx, kind)
src_docs = []
for name in pdfs:
p = os.path.join(SRC, name + '.pdf')
if not os.path.exists(p):
print(f'[{series}] 源缺失: {name}')
continue
doc = fitz.open(p)
src_docs.append((name, doc))
for i in range(doc.page_count):
all_pages.append((doc, i, classify(doc[i])))
if not all_pages:
print(f'[{series}] 无可用源 PDF')
continue
# 渲染顺序: 特性页 -> 尺寸页 -> 夹臂页(稳定顺序,便于对照)
order = {'char': 0, 'dim': 1, 'arm': 2, 'other': 3}
all_pages.sort(key=lambda x: order[x[2]])
n = render([(doc, i) for doc, i, k in all_pages], d, 1)
kinds = [k for _, _, k in all_pages]
print(f'[{series}] {len(all_pages)} 页 -> {kinds}')
# 数据表 PDF: 只保留该系列的页(合并多缸径 PDF)
out_pdf = fitz.open()
for name, doc in src_docs:
out_pdf.insert_pdf(doc)
out_path = os.path.join(d, series + '_manual.pdf')
out_pdf.save(out_path, garbage=3, deflate=True)
out_pdf.close()
print(f'[{series}] datasheet <- {len(pdfs)} 个PDF合并 ({os.path.getsize(out_path)/1024:.0f}KB)')
# UCBT: 从主目录提取
if UCBT_FROM_MAIN:
d = os.path.join(DST, 'UCBT')
if CLEAN and os.path.isdir(d):
shutil.rmtree(d)
os.makedirs(d, exist_ok=True)
main_pdf = os.path.join(SRC, MAIN_CATALOG)
doc = fitz.open(main_pdf)
# 找 UCBT40 相关页(数据页)
idxs = []
for i in range(doc.page_count):
t = doc[i].get_text().upper()
if 'UCBT40' in t and any(m in t for m in ('DIMENSIONS', 'CODIFICATION', 'CHARACTERISTICS')):
idxs.append(i)
if idxs:
n = render([(doc, i) for i in idxs], d, 1)
print(f'[UCBT] {len(idxs)} 页 <- 主目录 idx={idxs}')
# datasheet: 只裁 UCBT 那几页
out_pdf = fitz.open()
for i in idxs:
out_pdf.insert_pdf(doc, from_page=i, to_page=i)
out_path = os.path.join(d, 'UCBT_manual.pdf')
out_pdf.save(out_path, garbage=3, deflate=True)
out_pdf.close()
print(f'[UCBT] datasheet <- 裁剪 {len(idxs)} 页 ({os.path.getsize(out_path)/1024:.0f}KB)')
else:
print('[UCBT] 未找到数据页')
print('完成')
if __name__ == '__main__':
main()

View File

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

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
"""按 manual/<系列>/ 下实际存在的 p*.png 更新 meta.json 的附件引用。"""
import json, os, glob, re
CAT = r'D:/开发/OnebotCatalog/onebot-data/catalog'
SERIES = ['UCBP','UCBM','UCBT','UCBQ','UCBH','UCDP','UCIM','UCIP','UCNM','UCNP','UFL']
for s in SERIES:
d = os.path.join(CAT, 'manual', s)
if not os.path.isdir(d):
print(f'[{s}] 无 manual 目录,跳过'); continue
pngs = sorted(glob.glob(os.path.join(d, 'p*.png')))
if not pngs:
print(f'[{s}] 无 png,跳过'); continue
atts = []
for p in pngs:
atts.append({"kind": "manual", "lang": "", "source": f"manual/{s}/{os.path.basename(p)}", "model": None})
pdf = os.path.join(d, f'{s}_manual.pdf')
if os.path.exists(pdf):
atts.append({"kind": "datasheet", "lang": "", "source": f"manual/{s}/{s}_manual.pdf", "model": None})
mp = os.path.join(CAT, 'series', s + '.meta.json')
with open(mp, encoding='utf-8') as f:
meta = json.load(f)
meta['attachments'] = atts
with open(mp, 'w', encoding='utf-8') as f:
json.dump(meta, f, ensure_ascii=False, indent=1)
print(f'[{s}] 附件 {len(atts)} 个 ({len(pngs)}图 + {"1PDF" if os.path.exists(pdf) else "无PDF"})')