← back to Designer Wallcoverings

onboarding/sangetsu-lilycolor/scripts/parse-sangetsu-pdf-specs.py

147 lines

#!/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()