[object Object]

← back to Designer Wallcoverings

sangetsu: parse 5 pricebook PDFs -> 1,007 domestic-SKU specs (width/repeat/fire/price/functions)

d613c488345dc260d459774cb9a642577d07d696 · 2026-07-02 07:28:56 -0700 · Steve

Files touched

Diff

commit d613c488345dc260d459774cb9a642577d07d696
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 2 07:28:56 2026 -0700

    sangetsu: parse 5 pricebook PDFs -> 1,007 domestic-SKU specs (width/repeat/fire/price/functions)
---
 .../scripts/parse-sangetsu-pdf-specs.py            | 146 +++++++++++++++++++++
 1 file changed, 146 insertions(+)

diff --git a/onboarding/sangetsu-lilycolor/scripts/parse-sangetsu-pdf-specs.py b/onboarding/sangetsu-lilycolor/scripts/parse-sangetsu-pdf-specs.py
new file mode 100644
index 00000000..6090f20d
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/scripts/parse-sangetsu-pdf-specs.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""Parse the 5 Sangetsu Japanese price-book .txt extracts (pricebooks/*.txt) into a
+clean per-SKU spec table -> staging/sangetsu-pdf-specs.jsonl.
+
+Row shape in the 価格表 (price table):
+  [item#] [SKU(s)] [unit] [price1] [price2] [functions...] 巾<W>cm[×..][切売可/巻] \
+     [repeat_v] [repeat_h] [range] [fire: 不燃 NM-#### 準不燃 QM-#### 難燃 RM-####...]
+
+SKUs expand:  LIS-42001~42003 -> 42001,42002,42003 ;  LL-6913・6914 -> 6913,6914
+Domestic SKU namespace (LV/LW/LL/LMT/LIS/LR/LWA/LWT/LLT) — NOT the .co.th export SKUs.
+"""
+import re, json, os, sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+PROJ = os.path.dirname(HERE)
+PB = os.path.join(PROJ, "pricebooks")
+OUT = os.path.join(PROJ, "staging", "sangetsu-pdf-specs.jsonl")
+
+COLLECTION = {  # file -> human collection label
+    "V-wall": "SP (V-wall / vinyl)", "WILL": "WILL", "LIGHT": "LIGHT",
+    "MATERIALS": "MATERIALS", "ImportSelection": "Import Selection",
+}
+
+# a spec row must carry a width token; that's our anchor
+WIDTH_RE = re.compile(r"巾\s*([0-9]+(?:\.[0-9]+)?)\s*cm")
+# SKU head: LETTERS + '-' + first number, optionally with a space after '-'
+SKUHEAD_RE = re.compile(r"\b(L[A-Z]{0,2}T?A?)-\s?([0-9]{4,6})")
+# fire certs
+FIRE_RE = re.compile(r"(不燃|準不燃|難燃)\s*([A-Z]{2}-[0-9]{3,4})")
+# functions vocabulary seen in the books
+FUNCS = ["帯電防止", "消臭", "抗菌", "抗ウイルス", "防カビ", "通気性", "透湿性",
+         "吸放湿", "ホツレ止", "表面強化", "汚れ防止", "水拭き", "スーパー耐久",
+         "マイナスイオン", "蓄光", "屋外", "オレフィン"]
+# repeat_v repeat_h range(e.g 1-4) — the reliable middle anchor
+REP_RANGE_RE = re.compile(
+    r"(—|―|-|[0-9]+(?:\.[0-9]+)?)\s+(—|―|-|[0-9]+(?:\.[0-9]+)?)\s+([0-9]-[0-9])\b")
+# Real ¥ prices in these books are ALWAYS comma-grouped (1,000 / 40,000 / 7,700).
+# Requiring a comma excludes bare SKU digits (42001) that share the row.
+PRICE_RE = re.compile(r"([0-9]{1,3}(?:,[0-9]{3})+)")
+
+
+def expand_skus(seg, prefix):
+    """seg is the raw SKU chunk after prefix, e.g. '2001・2002' or '1742~1745'.
+    Returns list of full SKUs like ['LWA-1742', ...]."""
+    seg = seg.replace("、", "・").replace(",", "・").strip()
+    out = []
+    for part in seg.split("・"):
+        part = part.strip()
+        m = re.match(r"([0-9]{4,6})\s*[~~]\s*([0-9]{4,6})$", part)
+        if m:
+            a, b = int(m.group(1)), int(m.group(2))
+            if 0 <= b - a <= 60:
+                out += [f"{prefix}-{n}" for n in range(a, b + 1)]
+                continue
+        m2 = re.match(r"([0-9]{4,6})$", part)
+        if m2:
+            out.append(f"{prefix}-{m2.group(1)}")
+    return out
+
+
+def parse_row(line):
+    wm = WIDTH_RE.search(line)
+    if not wm:
+        return None
+    sh = SKUHEAD_RE.search(line)
+    if not sh:
+        return None
+    prefix = sh.group(1)
+    # SKU chunk = from first number up to the unit token (m/m/本/枚/特) or width
+    after = line[sh.start(2):]
+    chunk = re.split(r"\s+(?:m|m|本|枚|特)\s|\s{2,}巾|\s+巾", after, maxsplit=1)[0]
+    skus = expand_skus(chunk, prefix)
+    if not skus:
+        skus = [f"{prefix}-{sh.group(2)}"]
+
+    unit = None
+    # \b doesn't bound CJK; match unit token followed by whitespace instead.
+    um = re.search(r"(m|m|本|枚)(?=\s)", line[sh.end():])
+    if um:
+        unit = {"m": "m", "m": "m", "本": "roll", "枚": "sheet"}[um.group(1)]
+
+    # prices: first two comma-grouped ¥ numbers after the SKU chunk (or unit)
+    start = (sh.end() + um.end()) if um else sh.end()
+    prices = [int(p.replace(",", "")) for p in PRICE_RE.findall(line[start:])][:2]
+    price_product = prices[0] if len(prices) >= 1 else None
+    price_material = prices[1] if len(prices) >= 2 else None
+
+    width_cm = float(wm.group(1))
+    roll = re.search(r"巾[0-9.]+cm\s*×\s*([0-9.]+)m", line)
+    roll_len_m = float(roll.group(1)) if roll else None
+    cut_sale = "切売可" in line
+
+    rr = REP_RANGE_RE.search(line[wm.end():])
+    def num(x):
+        return None if x in ("—", "―", "-") else float(x)
+    rep_v = rep_h = rng = None
+    if rr:
+        rep_v, rep_h, rng = num(rr.group(1)), num(rr.group(2)), rr.group(3)
+
+    fires = [{"grade": g, "cert": c} for g, c in FIRE_RE.findall(line)]
+    funcs = [f for f in FUNCS if f in line]
+
+    return {
+        "unit": unit, "price_product_yen": price_product,
+        "price_material_yen_m2": price_material,
+        "width_cm": width_cm, "roll_len_m": roll_len_m, "cut_sale": cut_sale,
+        "repeat_v_cm": rep_v, "repeat_h_cm": rep_h, "roll_class": rng,
+        "fire_ratings": fires, "functions": funcs,
+    }, skus
+
+
+def main():
+    rows = {}
+    per_file = {}
+    for fn, coll in COLLECTION.items():
+        path = os.path.join(PB, fn + ".txt")
+        if not os.path.exists(path):
+            continue
+        n = 0
+        for line in open(path, encoding="utf-8"):
+            r = parse_row(line.rstrip("\n"))
+            if not r:
+                continue
+            spec, skus = r
+            for sku in skus:
+                rows[sku] = {"sku": sku, "collection": coll, "source_pdf": fn + ".pdf",
+                             **spec, "_source": "sangetsu-pricebook-pdf"}
+                n += 1
+        per_file[fn] = n
+    with open(OUT, "w", encoding="utf-8") as f:
+        for sku in sorted(rows):
+            f.write(json.dumps(rows[sku], ensure_ascii=False) + "\n")
+    # report
+    have_w = sum(1 for x in rows.values() if x["width_cm"])
+    have_rep = sum(1 for x in rows.values() if x["repeat_v_cm"] is not None or x["repeat_h_cm"] is not None)
+    have_fire = sum(1 for x in rows.values() if x["fire_ratings"])
+    have_price = sum(1 for x in rows.values() if x["price_product_yen"])
+    print(json.dumps({
+        "total_sku_specs": len(rows), "per_file_rows": per_file,
+        "with_width": have_w, "with_repeat": have_rep,
+        "with_fire": have_fire, "with_price": have_price, "out": OUT,
+    }, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+    main()

← 005e77b9 auto-save: 2026-07-02T06:41:33 (6 files) — pending-approval/  ·  back to Designer Wallcoverings  ·  sangetsu: store-api detail enrich — descriptions 4->614 patt 1f7a0643 →