233 lines
9.1 KiB
Python
233 lines
9.1 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]
|
|
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()
|