Files
gdm-Servo-model/scripts/extract_catalog.py
2026-08-11 00:14:20 +08:00

239 lines
9.4 KiB
Python

"""Extract the official GAUDELOT PDF catalog into site data and product thumbnails."""
from __future__ import annotations
import json
import re
from pathlib import Path
import pdfplumber
from PIL import Image, ImageChops
ROOT = Path(__file__).resolve().parents[1]
PDF_PATH = ROOT.parent / "公司资料" / "公司产品" / "汕头市高德乐产品型号表20260630.pdf"
RENDER_DIR = ROOT / "tmp" / "pdfs" / "catalog-hi"
IMAGE_DIR = ROOT / "public" / "catalog-products"
DATA_PATH = ROOT / "app" / "catalog-data.ts"
CATEGORY_BY_PAGE = {
**{page: "标准舵机" for page in range(1, 14)},
**{page: "异形舵机" for page in range(14, 16)},
**{page: "全金属舵机" for page in range(16, 20)},
**{page: "大扭矩舵机" for page in range(20, 22)},
**{page: "积木舵机" for page in range(22, 24)},
**{page: "标准齿轮箱" for page in range(24, 28)},
**{page: "翻盖电机" for page in range(28, 30)},
}
SPEC_LABELS = {
"标准舵机": [
"产品尺寸", "最大砝码负载", "堵转扭矩", "空载速度", "可操作角度",
"额定电压", "电压范围", "外壳材质", "齿轮级数", "齿轮材质", "马达类型", "端子连接线",
],
"异形舵机": [
"产品尺寸", "最大砝码负载", "堵转扭矩", "空载速度", "可操作角度",
"额定电压", "电压范围", "外壳材质", "齿轮级数", "齿轮材质", "马达类型", "端子连接线",
],
"全金属舵机": [
"产品尺寸", "马达类型", "传感器类型", "信号类型", "协议版本", "连接线类型",
"电压范围", "额定电压", "操作角度范围", "外壳材质", "齿轮材质", "齿轮比",
"齿轮级数", "堵转扭矩", "负载扭矩", "额定扭矩", "转动速度", "转动速度RPM",
],
"大扭矩舵机": [
"产品尺寸", "马达类型", "传感器类型", "信号类型", "协议版本", "连接线类型",
"电压范围", "额定电压", "操作角度范围", "外壳材质", "齿轮材质", "齿轮比",
"齿轮级数", "堵转扭矩", "负载扭矩", "额定扭矩", "转动速度", "转动速度RPM",
"输出齿类型", "重量", "附加说明",
],
"积木舵机": [
"外形尺寸", "额定电压", "电压范围", "最大砝码负载", "堵转扭矩", "空载速度",
"通讯方式", "端子连接线",
],
"标准齿轮箱": [
"产品尺寸", "空载速度", "堵转扭矩", "齿轮比", "齿轮级数", "齿轮类型",
"马达类型", "额定电压", "电压范围", "外壳材质",
],
"翻盖电机": [
"产品尺寸", "空载速度", "最大砝码负载", "堵转扭矩", "额定电压", "电压范围",
"寿命", "马达类型", "外壳材质", "齿轮材质",
],
}
def clean(value: str | None) -> str:
if not value:
return ""
value = value.replace("\n", "").replace("\u00a0", " ")
value = re.sub(r"\s+", "", value)
return value.strip()
def clean_code(value: str | None) -> str:
return clean(value).replace("|", "")
def slug(value: str) -> str:
safe = re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-").lower()
return safe or "product"
def parse_number(value: str) -> float | None:
match = re.search(r"-?\d+(?:\.\d+)?", value.replace(",", ""))
return float(match.group()) if match else None
def normalize_spec_values(category: str, row: list[str | None], inherited_size: str) -> list[str]:
# PDF tables contain a blank merged column before the actual size value.
size = clean(row[4]) or inherited_size
if category in {"标准舵机", "异形舵机"}:
return [size] + [clean(value) for value in row[5:16]]
if category == "全金属舵机":
return [size] + [clean(value) for value in row[5:22]]
if category == "大扭矩舵机":
return [size] + [clean(value) for value in row[5:25]]
if category == "积木舵机":
return [size] + [clean(value) for value in row[5:12]]
if category in {"标准齿轮箱", "翻盖电机"}:
return [size] + [clean(value) for value in row[5:14]]
raise ValueError(f"Unsupported category: {category}")
def crop_product_image(
page_image: Image.Image,
page_width: float,
page_height: float,
cell: tuple[float, float, float, float],
output_path: Path,
) -> None:
scale_x = page_image.width / page_width
scale_y = page_image.height / page_height
x0, top, x1, bottom = cell
inset = 3
box = (
max(0, int(x0 * scale_x) + inset),
max(0, int(top * scale_y) + inset),
min(page_image.width, int(x1 * scale_x) - inset),
min(page_image.height, int(bottom * scale_y) - inset),
)
crop = page_image.crop(box).convert("RGB")
background = Image.new("RGB", crop.size, "white")
diff = ImageChops.difference(crop, background).convert("L")
mask = diff.point(lambda pixel: 255 if pixel > 14 else 0)
bbox = mask.getbbox()
if bbox:
crop = crop.crop(bbox)
crop.thumbnail((280, 210), Image.Resampling.LANCZOS)
card = Image.new("RGB", (320, 240), "white")
card.paste(crop, ((320 - crop.width) // 2, (240 - crop.height) // 2))
card.save(output_path, "WEBP", quality=88, method=6)
def main() -> None:
if not PDF_PATH.exists():
raise FileNotFoundError(PDF_PATH)
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
products: list[dict[str, object]] = []
category_counts: dict[str, int] = {}
with pdfplumber.open(PDF_PATH) as pdf:
for page_number, page in enumerate(pdf.pages, 1):
category = CATEGORY_BY_PAGE[page_number]
page_text = clean(page.extract_text() or "")
expected_heading = f"{category}产品明细表"
if expected_heading not in page_text:
raise RuntimeError(
f"Category heading mismatch on page {page_number}: expected {expected_heading}"
)
tables = page.find_tables()
if not tables:
raise RuntimeError(f"No table found on page {page_number}")
table = tables[0]
rows = table.extract()
render_path = RENDER_DIR / f"page-{page_number:02d}.png"
if not render_path.exists():
raise FileNotFoundError(f"Missing rendered page: {render_path}")
rendered = Image.open(render_path).convert("RGB")
inherited_name = ""
inherited_size = ""
inherited_image = ""
for row_index, row in enumerate(rows[3:], 3):
if len(row) < 3:
continue
code = clean_code(row[2])
if not code or not re.search(r"[A-Z].*\d", code):
continue
explicit_name = clean(row[1])
if explicit_name:
inherited_name = explicit_name
name = explicit_name or inherited_name or code
explicit_size = clean(row[4]) if len(row) > 4 else ""
if explicit_size:
inherited_size = explicit_size
cell = table.rows[row_index].cells[0] if row_index < len(table.rows) else None
if explicit_name and cell:
image_name = f"p{page_number:02d}-{slug(code)}.webp"
crop_product_image(rendered, page.width, page.height, cell, IMAGE_DIR / image_name)
inherited_image = f"/catalog-products/{image_name}"
values = normalize_spec_values(category, row, inherited_size)
specs = {
label: value
for label, value in zip(SPEC_LABELS[category], values)
if value and value != "/"
}
torque_value = parse_number(specs.get("堵转扭矩", ""))
rated_voltage = parse_number(specs.get("额定电压", ""))
products.append(
{
"id": f"p{page_number:02d}-{row_index}-{slug(code)}",
"category": category,
"name": name,
"code": code,
"image": inherited_image,
"page": page_number,
"specs": specs,
"torqueValue": torque_value,
"ratedVoltage": rated_voltage,
}
)
category_counts[category] = category_counts.get(category, 0) + 1
payload = json.dumps(products, ensure_ascii=False, indent=2)
counts = json.dumps(category_counts, ensure_ascii=False, indent=2)
DATA_PATH.write_text(
"// Generated from the official 2026-06-30 PDF product catalog.\n"
"// Run scripts/extract_catalog.py after the PDF or page renders change.\n\n"
"export type CatalogProduct = {\n"
" id: string;\n"
" category: string;\n"
" name: string;\n"
" code: string;\n"
" image: string;\n"
" page: number;\n"
" specs: Record<string, string>;\n"
" torqueValue: number | null;\n"
" ratedVoltage: number | null;\n"
"};\n\n"
f"export const CATALOG_PRODUCTS: CatalogProduct[] = {payload};\n\n"
f"export const CATALOG_COUNTS: Record<string, number> = {counts};\n",
encoding="utf-8",
)
print(json.dumps({"total": len(products), "categories": category_counts}, ensure_ascii=False))
if __name__ == "__main__":
main()