Import full product catalog
This commit is contained in:
232
scripts/extract_catalog.py
Normal file
232
scripts/extract_catalog.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user