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,57 @@
# -*- coding: utf-8 -*-
"""
并行复制待补分类的 STEP 数模(X盘网络盘慢,串行不可行)。
复用 gen-pending-series.py 的扫描缓存 + 解析器。
用法: PYTHONUTF8=1 python copy-pending-step.py
"""
import os, sys, shutil
from concurrent.futures import ThreadPoolExecutor, as_completed
import importlib.util
HERE = os.path.dirname(os.path.abspath(__file__))
spec = importlib.util.spec_from_file_location('gps', os.path.join(HERE, 'gen-pending-series.py'))
gps = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gps)
DST = os.path.join(gps.DST, 'step')
os.makedirs(DST, exist_ok=True)
scanned = gps.load_scan()
todo = []
for sp in gps.SPECS:
records, _ = gps.collect(sp, scanned)
for m in records.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
for attempt in range(3):
try:
shutil.copy2(src, dst)
return (model, 'ok', None)
except Exception as e:
if attempt == 2:
return (model, 'err', str(e))
return (model, 'err', 'unknown')
ok = err = 0
errors = []
with ThreadPoolExecutor(max_workers=8) as ex:
futs = [ex.submit(cp, it) for it in todo]
for i, fut in enumerate(as_completed(futs), 1):
model, status, e = fut.result()
if status == 'ok':
ok += 1
else:
err += 1
errors.append(f'{model}: {e}')
if i % 100 == 0:
print(f' {i}/{len(todo)} (ok={ok} err={err})')
print(f'\n完成: 复制 {ok} 个, 失败 {err} 个')
for e in errors[:20]:
print(' !', e)
print(f'step 目录现有 {len(os.listdir(DST))} 个文件')

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""
评估"预生成每型号 zip"方案的收益与代价。
扫描 web 发布目录的 step/,统计原始总体积 vs zip 后体积。
"""
import os, zipfile, io, random
WEB = r'D:/开发/OnebotCatalog/release/web_2026.08/step'
if not os.path.isdir(WEB):
raise SystemExit('找不到 ' + WEB)
files = [f for f in os.listdir(WEB) if f.lower().endswith(('.step', '.stp'))]
print(f'step 文件数: {len(files)}')
tot = sum(os.path.getsize(os.path.join(WEB, f)) for f in files)
print(f'原始总体积: {tot/1073741824:.2f} GB')
# 抽样测压缩率(避免全量压缩耗时)
random.seed(42)
sample = random.sample(files, min(40, len(files)))
raw = zipped = 0
for f in sample:
p = os.path.join(WEB, f)
n = os.path.getsize(p)
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
z.write(p, f[:-4] + '.step' if f.lower().endswith('.stp') else f)
raw += n
zipped += buf.tell()
print(f'\n抽样 {len(sample)} 个:')
print(f' 原始 {raw/1048576:.1f} MB -> zip {zipped/1048576:.1f} MB (省 {(1-zipped/raw)*100:.1f}%)')
print(f'\n推算全量:')
print(f' stepz 目录约 {(tot*(zipped/raw))/1073741824:.2f} GB')
print(f' 部署总目录 = step {tot/1073741824:.2f}GB + stepz {(tot*(zipped/raw))/1073741824:.2f}GB'
f' = {(tot*(1+zipped/raw))/1073741824:.2f} GB')
print(f'\n购物车场景收益 (选 20 个型号, 平均 {tot/len(files)/1048576:.1f}MB/个):')
per = tot / len(files)
print(f' 现状: 传 {20*per/1048576:.0f} MB 原始文件')
print(f' 改后: 传 {20*per*(zipped/raw)/1048576:.0f} MB 压缩文件')

View File

@@ -47,7 +47,8 @@ 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)
# 彩色输出 (官方图册本身是彩色的; 早期版本误用 csGRAY 转灰度)
pix = doc[i].get_pixmap(dpi=DPI)
pix.save(os.path.join(out_dir, f'p{n:02d}.png'))
n += 1
return n
@@ -101,11 +102,14 @@ def main():
os.makedirs(d, exist_ok=True)
main_pdf = os.path.join(SRC, MAIN_CATALOG)
doc = fitz.open(main_pdf)
# 找 UCBT40 相关页(数据页)
# 找 UCBT 相关页(数据页 + 夹臂页)
# 注: 早期版本用 'UCBT40' 精确匹配 + 漏了 CLAMPING ARMS, 3 页只抓到 1 页。
# 主目录里 UCBT 占连续 3 页 (idx 40/41/42), 前后分别是 UCBM 与新章节, 故按 UCBT 匹配即准确。
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')):
if 'UCBT' in t and any(m in t for m in (
'DIMENSIONS', 'CODIFICATION', 'CHARACTERISTICS', 'CLAMPING ARMS')):
idxs.append(i)
if idxs:
n = render([(doc, i) for i in idxs], d, 1)

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""
为待补分类(定位销/勾销缸/翻转缸/电缸)生成附图。
来源: Univer 官方图册 PDF(X盘 官方图册 / 选型表/spanner)。
输出: manual/<系列>/p<NN>.png + <系列>_manual.pdf
用法: PYTHONUTF8=1 python gen-pending-manual.py [--clean]
"""
import fitz, os, shutil, sys
SRC = r'X:/新开发产品-Research and Development/2_产品资料/Univer资料/官方图册'
SPANNER = r'X:/新开发产品-Research and Development/2_产品资料/Univer资料/选型表/spanner'
DST = r'D:/开发/OnebotCatalog/onebot-data/catalog/manual'
DPI = 110
CLEAN = '--clean' in sys.argv
# 系列 -> (目录, [pdf 名]);pdf 名不含扩展名
SPECS = {
# ---- 定位销 ----
'ULA': (SRC, ['ula40cten_en419', 'ula63cten_en407']),
'ULB': (SRC, ['ulb32cten_en423']),
'ULL': (SRC, ['ull50cten_en411']),
'ULM': (SRC, ['ulm50cten_en410']),
'ULO': (SRC, ['ulo50cten_en413']),
'ULP': (SRC, ['ulp32cten_en404', 'ulp50cten_en405', 'ulp63cten_en406']),
'ULR': (SRC, ['ulr50nken_en476', 'ulr63cten_en477']),
'ULT': (SRC, ['ult50cten_en408']),
'ULX': (SRC, ['ulx50cten_en425']),
'ULY': (SRC, ['uly40cten_en426']),
'ULZ': (SRC, ['ulz40cten_en428']),
# ---- 勾销缸 ----
'USP': (SRC, ['usp32cten_en355', 'usp50cten_en437', 'usp50ucten_en168']),
'USL': (SRC, ['usl50cten_en171']),
'USR': (SRC, ['usr40cten_en436']),
'USS': (SRC, ['uss50cten_en433', 'uss63cten_en434']),
'USSL': (SRC, ['ussl50cten_en358']),
# ---- 翻转缸 ----
'UAGP': (SRC, ['uagp075wcten_en580', 'uagp075bcten_en181', 'uagp080wcten_en174',
'uagp080wcten_en573', 'uagp120bcten_en175', 'uagp120wcten_en574',
'uagp150bcten_en178', 'uagp150wcten_en577', 'uagp155wcten_en583',
'uagp170bnken_en176', 'uagp170wcten_en575', 'uagp210bcten_en182',
'uagp210wcten_en581', 'uagp215bcten_en373', 'uagp215wcten_en584',
'uagp300bcten_en177', 'uagp300wcten_en576', 'uagp350bcten_en183',
'uagp350wcten_en582', 'uagp355bcten_en374', 'uagp355wcten_en585',
'uagp600bcten_en179', 'uagp600wcten_en578', 'uagp605bcten_en180',
'uagp605wcten_en579']),
# LAGP 官方已改名 UAGP,图册在选型表/spanner(老版)
'LAGP': (SPANNER, ['lagp075_en_c181', 'lagp120_en_c175', 'lagp150_en_c178',
'lagp155_en_c350', 'lagp170_en_c176', 'lagp210_en_c182',
'lagp215nwrev-00-0119en_en373', 'lagp300_en_c177']),
# ---- 电缸 ----
'LAE': (SRC, ['lae140_en_c220', 'lae225_en_c221', 'lae350_en_c222']),
}
def classify(page):
t = page.get_text().upper()
if 'ACCESSORIES' in t or 'CLAMPING ARMS' in t or 'BLADE ARMS' in t:
return 'arm'
if 'DIMENSIONS' in t:
return 'dim'
if 'CHARACTERISTICS' in t or 'CODIFICATION' in t:
return 'char'
return 'other'
def main():
os.makedirs(DST, exist_ok=True)
missing = []
for series, (srcdir, pdfs) in SPECS.items():
d = os.path.join(DST, series)
if CLEAN and os.path.isdir(d):
shutil.rmtree(d)
os.makedirs(d, exist_ok=True)
docs, all_pages = [], []
for name in pdfs:
p = os.path.join(srcdir, name + '.pdf')
if not os.path.exists(p):
missing.append(f'{series}: {name}')
continue
doc = fitz.open(p)
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 = 1
for doc, i, _k in all_pages:
# 彩色输出 (官方图册本身是彩色的; 早期版本误用 csGRAY 转灰度,
# 体积约翻倍但相对整个数据包可忽略)
doc[i].get_pixmap(dpi=DPI).save(
os.path.join(d, f'p{n:02d}.png'))
n += 1
out_pdf = fitz.open()
for _name, doc in docs:
out_pdf.insert_pdf(doc)
outp = os.path.join(d, series + '_manual.pdf')
out_pdf.save(outp, garbage=3, deflate=True)
out_pdf.close()
print(f'[{series}] {len(all_pages)} 页 / {len(pdfs)} PDF -> '
f'{os.path.getsize(outp)/1024:.0f}KB')
if missing:
print('\n源缺失:')
for m in missing:
print(' ', m)
print('完成')
if __name__ == '__main__':
main()

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()

View File

@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""
为没有产品图的系列生成统一风格占位缩略图 (web/series/<系列码>.webp)。
背景: 首页系列缩略图网格需要每个系列一张图。bom-viewer 项目已有 27 张真实产品图
(来自 onebot251231 网站后台), 其余 13 个系列 (KC/M/MS/MAL/KS 等基础气缸 +
UFL/LAGP 老码系列) 暂无 → 生成占位图, 用户后续用真图覆盖同名文件即可。
风格对齐卡片: 浅灰底 (--gray-50 #f9fafb) + 青色系列码大字 (--primary-700 #0f766e)
+ "图片待补" 小字, 尺寸 500x450 (与 bom-viewer 产品图一致)。
用法: PYTHONUTF8=1 python gen-series-thumbs.py
"""
import os
from PIL import Image, ImageDraw, ImageFont
WEB_SERIES = r'D:/开发/OnebotCatalog/web/series'
# 需要占位的系列 (bom-viewer 里没有对应的产品图)
PLACEHOLDER = ['KC', 'KCB', 'KSB', 'KCS', 'KS', 'KSU',
'M', 'MS', 'MSC', 'MAL', 'MALC', 'UFL', 'LAGP']
W, H = 500, 450
BG = (249, 250, 251) # --gray-50
CODE_COLOR = (15, 118, 110) # --primary-700
SUB_COLOR = (156, 163, 175) # --gray-400
BOX = (229, 231, 235) # --gray-200
FONT_CANDIDATES = [
r'C:\Windows\Fonts\consolab.ttf', # Consolas Bold — 与卡片等宽字体一致
r'C:\Windows\Fonts\consola.ttf',
r'C:\Windows\Fonts\msyhbd.ttc',
]
CN_FONT_CANDIDATES = [
r'C:\Windows\Fonts\msyh.ttc',
r'C:\Windows\Fonts\msyhl.ttc',
]
def load_font(paths, size):
for p in paths:
if os.path.exists(p):
try:
return ImageFont.truetype(p, size)
except Exception:
continue
return ImageFont.load_default()
def make_one(code, out_path):
im = Image.new('RGB', (W, H), BG)
d = ImageDraw.Draw(im)
# 虚线圆角外框 (暗示"这里是图位")
d.rounded_rectangle([26, 26, W - 26, H - 26], radius=18, outline=BOX, width=2)
# 系列码大字 (居中)
f_code = load_font(FONT_CANDIDATES, 84)
bbox = d.textbbox((0, 0), code, font=f_code)
d.text(((W - (bbox[2] - bbox[0])) / 2 - bbox[0],
H / 2 - 62 - (bbox[3] - bbox[1]) / 2), code, font=f_code, fill=CODE_COLOR)
# 分隔短线
d.line([(W / 2 - 34, H / 2 + 8), (W / 2 + 34, H / 2 + 8)], fill=BOX, width=2)
# "图片待补" (中英各一行, 居中)
f_cn = load_font(CN_FONT_CANDIDATES, 20)
f_en = load_font(FONT_CANDIDATES, 15)
t1 = '图片待补'
b1 = d.textbbox((0, 0), t1, font=f_cn)
d.text(((W - (b1[2] - b1[0])) / 2 - b1[0], H / 2 + 30), t1, font=f_cn, fill=SUB_COLOR)
t2 = 'IMAGE PENDING'
b2 = d.textbbox((0, 0), t2, font=f_en)
d.text(((W - (b2[2] - b2[0])) / 2 - b2[0], H / 2 + 62), t2, font=f_en, fill=SUB_COLOR)
im.save(out_path, 'WEBP', quality=88, method=6)
return os.path.getsize(out_path)
def main():
os.makedirs(WEB_SERIES, exist_ok=True)
total = 0
for code in PLACEHOLDER:
p = os.path.join(WEB_SERIES, code + '.webp')
sz = make_one(code, p)
total += sz
print(f' {code:6s} -> {sz/1024:5.1f} KB')
print(f'\n生成 {len(PLACEHOLDER)} 张占位图, 共 {total/1024:.0f} KB')
print('替换方式: 用真图覆盖 web/series/<系列码>.webp 即可 (无需改代码)')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""
为网页发布目录生成 STEP 压缩副档 (.step.gz),供浏览器端按需解压下载。
背景: 网页版购物车打包是逐个 fetch 原始 STEP (翻转缸单个 7-13MB), 选 20 个型号
要传 40MB+。预生成 gz 副档后前端优先取 .gz 并在浏览器解压 (DecompressionStream),
传输量降至约 1/4.5。
用法:
PYTHONUTF8=1 python gen-step-gz.py <web目录> # 全量生成(跳过已存在)
PYTHONUTF8=1 python gen-step-gz.py <web目录> --force # 强制重建
PYTHONUTF8=1 python gen-step-gz.py <web目录> --clean # 删除已有 .gz 后重建
"""
import os, sys, gzip, shutil, time
from concurrent.futures import ThreadPoolExecutor, as_completed
args = [a for a in sys.argv[1:] if not a.startswith('--')]
FORCE = '--force' in sys.argv
CLEAN = '--clean' in sys.argv
if not args:
sys.exit('用法: python gen-step-gz.py <web目录> [--force|--clean]')
WEB = args[0]
STEP = os.path.join(WEB, 'step')
if not os.path.isdir(STEP):
sys.exit('找不到 ' + STEP)
if CLEAN:
n = 0
for f in os.listdir(STEP):
if f.lower().endswith('.gz'):
os.remove(os.path.join(STEP, f))
n += 1
print(f'已清理 {n} 个 .gz')
files = [f for f in os.listdir(STEP)
if f.lower().endswith(('.step', '.stp'))]
todo = []
for f in files:
src = os.path.join(STEP, f)
dst = src + '.gz'
if FORCE or not os.path.exists(dst):
todo.append((src, dst, f))
print(f'step 文件 {len(files)} 个, 待压缩 {len(todo)} 个')
def gz_one(item):
src, dst, name = item
tmp = dst + '.part'
try:
with open(src, 'rb') as fi, gzip.open(tmp, 'wb', compresslevel=6) as fo:
shutil.copyfileobj(fi, fo, 1024 * 1024)
os.replace(tmp, dst)
return (name, os.path.getsize(src), os.path.getsize(dst), None)
except Exception as e:
try:
os.remove(tmp)
except OSError:
pass
return (name, 0, 0, str(e))
t0 = time.time()
raw = comp = done = err = 0
errors = []
with ThreadPoolExecutor(max_workers=4) as ex:
futs = [ex.submit(gz_one, it) for it in todo]
for i, fut in enumerate(as_completed(futs), 1):
name, r, c, e = fut.result()
if e:
err += 1
errors.append(f'{name}: {e}')
else:
done += 1
raw += r
comp += c
if i % 200 == 0:
print(f' {i}/{len(todo)} ({time.time()-t0:.0f}s)')
print(f'\n完成: {done} 个, 失败 {err} 个, 用时 {time.time()-t0:.0f}s')
if raw:
print(f' 原始 {raw/1073741824:.2f} GB -> gz {comp/1073741824:.2f} GB '
f'(省 {(1-comp/raw)*100:.1f}%)')
for e in errors[:10]:
print(' !', e)
print(f'step 目录现有 .gz {sum(1 for f in os.listdir(STEP) if f.endswith(".gz"))} 个')

View File

@@ -6,8 +6,10 @@
# 输出: OnebotCatalog\onebot-data\catalog\manual\<系列>\p<物理页码>.png (灰度 PNG 110dpi, 源目录布局)
import fitz, os, sys
PDF = r"OnebotCatalog\ONEBOT - 直线气缸 - 中文.pdf"
OUT = r"OnebotCatalog\onebot-data\catalog\manual"
# 脚本位于 onebot-data/tools/,项目根即上两级
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PDF = os.path.join(_ROOT, "ONEBOT - 直线气缸 - 中文.pdf")
OUT = os.path.join(_ROOT, "onebot-data", "catalog", "manual")
DPI = 110
# 系列 → 手册物理页码 (1-based, 含首尾); 依据: 手册印刷页码 = 物理页码 - 1
@@ -38,7 +40,7 @@ def main():
os.makedirs(d, exist_ok=True)
for p in pages:
out = os.path.join(d, f"p{p:02d}.png")
pix = doc[p - 1].get_pixmap(dpi=DPI, colorspace=fitz.csGRAY)
pix = doc[p - 1].get_pixmap(dpi=DPI)
pix.save(out)
kb = os.path.getsize(out) // 1024
total += kb

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""
勘察未完成分类的真实数模(定位销/勾销缸/翻转缸/夹爪缸/电缸)
用法: PYTHONUTF8=1 python scan-pending-series.py
"""
import os, re, collections, sys
BASE = r'X:/新开发产品-Research and Development/1_产品3D数模(发客户仿真)'
# 各分类的数据源目录
ROOTS = {
'PIN': ['2-伸缩销', '2019 Math Data 铁臂', '2019-NEW product', '2020UNICLAMP数模汇总'],
'HOK': ['3-勾销缸'],
'ROT': ['4-翻转缸'],
'GRP': ['5-夹臂', 'GR8_REV02'],
'ELEC': ['1-夹紧缸/电缸'],
}
# 关注的系列前缀(新码 UL* / US* / UAGP / UGP / LAE)
FOCUS = re.compile(r'^(UL[A-Z]|US[A-Z]?|UAGP|UGP|LAE|LAGP|UAG)')
def scan(dirs):
found = collections.defaultdict(list)
for d in dirs:
root = os.path.join(BASE, d)
if not os.path.isdir(root):
continue
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))
return found
def main():
for cat, dirs in ROOTS.items():
print(f'\n{"="*70}\n### {cat} (源: {", ".join(dirs)})\n{"="*70}')
files = scan(dirs)
buckets = collections.defaultdict(set)
for fname in files:
stem = os.path.splitext(fname)[0].upper().strip()
m = FOCUS.match(stem)
if not m:
continue
pfx = m.group(1)
buckets[pfx].add(stem)
for pfx in sorted(buckets):
names = sorted(buckets[pfx])
print(f'\n -- {pfx} ({len(names)} 个)')
for n in names[:200]:
print(f' {n}')
if len(names) > 200:
print(f' ... 还有 {len(names)-200} 个')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""测试 STEP 文件的 zip 压缩率 —— 判断"下载时压缩"能省多少。"""
import zipfile, os, tempfile
FILES = [
r'D:/开发/OnebotCatalog/onebot-data/catalog/step/UAGP155__045BS0.step',
r'D:/开发/OnebotCatalog/onebot-data/catalog/step/LAGP210OA045BC.step',
r'D:/开发/OnebotCatalog/onebot-data/catalog/step/ULP50040AEK0.step',
r'D:/开发/OnebotCatalog/onebot-data/catalog/step/UCBP40OESK0.step',
]
tmp = tempfile.mkdtemp()
tot_raw = tot_def = tot_sto = 0
print(f'{"文件":30s} {"原始":>9s} {"deflate":>9s} {"压缩率":>7s}')
for f in FILES:
if not os.path.exists(f):
print(f' 缺失: {f}')
continue
raw = os.path.getsize(f)
tot_raw += raw
sizes = {}
for lvl, tag in ((zipfile.ZIP_DEFLATED, 'def'), (zipfile.ZIP_STORED, 'sto')):
p = os.path.join(tmp, tag + '.zip')
with zipfile.ZipFile(p, 'w', lvl) as z:
z.write(f, os.path.basename(f))
sizes[tag] = os.path.getsize(p)
tot_def += sizes['def']
tot_sto += sizes['sto']
print(f'{os.path.basename(f):30s} {raw/1048576:8.2f}M {sizes["def"]/1048576:8.2f}M '
f'{(1-sizes["def"]/raw)*100:6.1f}%')
if tot_raw:
print(f'\n合计 {len(FILES)} 个: 原始 {tot_raw/1048576:.2f}MB')
print(f' deflate {tot_def/1048576:.2f}MB (省 {(1-tot_def/tot_raw)*100:.1f}%)')
print(f' 不压缩 {tot_sto/1048576:.2f}MB')
# 同时测 .opc 整包压缩率(判断主包还有多少水分)
opc = r'D:/开发/OnebotCatalog/sample/OnebotCatalog_2026.08.opc'
if os.path.exists(opc):
print(f'\n.opc 主包: {os.path.getsize(opc)/1073741824:.2f}GB')
try:
with zipfile.ZipFile(opc) as z:
infos = z.infolist()
c = sum(i.compress_size for i in infos)
u = sum(i.file_size for i in infos)
print(f' 内部 {len(infos)} 条目: 未压缩 {u/1073741824:.2f}GB -> 已压缩 {c/1073741824:.2f}GB '
f'(当前压缩率 {(1-c/u)*100:.1f}%)')
except Exception as e:
print(' 读取失败:', e)

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
"""
按 manual/<系列>/ 实际 png 数量重写 meta 的 attachments(多页附图)。
用法: PYTHONUTF8=1 python update-pending-meta-attach.py
"""
import json, os, glob
CAT = r'D:/开发/OnebotCatalog/onebot-data/catalog'
SERIES = ['ULA','ULB','ULL','ULM','ULO','ULP','ULR','ULT','ULX','ULY','ULZ',
'USP','USL','USR','USS','USSL', 'UAGP','LAGP', 'LAE']
for s in SERIES:
mp = os.path.join(CAT, 'series', s + '.meta.json')
if not os.path.exists(mp):
print(f'[{s}] meta 不存在,跳过')
continue
meta = json.load(open(mp, encoding='utf-8'))
d = os.path.join(CAT, 'manual', s)
pngs = sorted(glob.glob(os.path.join(d, 'p*.png')))
if not pngs:
print(f'[{s}] 无 png,保留原 attachments')
continue
atts = [{"kind": "manual", "lang": "", "source": f"manual/{s}/{os.path.basename(p)}",
"model": None} for p in pngs]
pdf = os.path.join(d, s + '_manual.pdf')
if os.path.exists(pdf):
atts.append({"kind": "datasheet", "lang": "",
"source": f"manual/{s}/{s}_manual.pdf", "model": None})
meta['attachments'] = atts
with open(mp, 'w', encoding='utf-8') as fp:
json.dump(meta, fp, ensure_ascii=False, indent=1)
print(f'[{s}] {len(pngs)} 页附图' + (' + datasheet' if os.path.exists(pdf) else ''))
# catalog.json 的 GRP 分类:UGP 无 X 盘数据,保留 demo —— 提示用户
print('\n注: GRP/UGP 无 X 盘真实数模,保留原 demo 数据')