← 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()