build_speed_csv.py
Language Python13.7 KB395 lines
#!/usr/bin/env python3"""build_speed_csv.py: Generate a production-speed CSV from a Meridian Display Co. category URL.Usage:python build_speed_csv.py <category_url | category_html_file> [<output_csv_path>]Example:python build_speed_csv.py https://meridiandisplay.example/10x10-booths/Offline mode: pass a saved category page instead of a URL. Each product page isthen read from <slug>.html in the same folder as the category file, with nonetwork access and no request spacing. The bundled test fixture works this way:python build_speed_csv.py fixtures/category-10x10-booths.html out.csvOutput columns:#, Product Name, Standard Turnaround Days, Express Turnaround Days,Super Express Days, Production Days (slash-separated),Production + Overnight Shipping (slash-separated)"""import csvimport htmlimport osimport reimport sysimport timeimport urllib.errorimport urllib.parseimport urllib.request# ---- Tunables ------------------------------------------------------------# Separator used inside the two summary columns. Slash because Claude.ai's CSV# previewer mishandles RFC 4180 quoted commas. Edit to "," if a downstream# parser actually needs comma-separated values (file will still open correctly# in Excel, Sheets, Numbers).SEPARATOR = " / "# Per-request spacing in seconds. 1.5s is the validated safe rate against# meridiandisplay.example's WAF for sustained sequential requests.REQUEST_SPACING_S = 1.5USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ""AppleWebKit/537.36 (KHTML, like Gecko) ""Chrome/120.0.0.0 Safari/537.36")# ---- URL filtering -------------------------------------------------------# Slugs that show up in nav menus, footers, etc. These are never products.EXCLUDE_SLUGS = {"shop-all","displays-menu","outdoor-menu","flags-menu","stands-menu","all-products","service-request","contact","partner-program","partner-benefits","resources","help","turnaround-guide","shipping","about","blog","faq","privacy","terms","returns",}# ---- Label variant patterns ---------------------------------------------# Order matters: Super Express must be checked before Express (because "Super# Express Turnaround" contains "Express"), and we want longest-match-first# behavior.TIER_PATTERNS = [("super_express", re.compile(r"\bsuper\s*express\b", re.I)),("express", re.compile(r"\bexpress\b", re.I)),("standard", re.compile(r"\bstandard\b", re.I)),]# Number + "business day(s)" extraction. Tolerates inconsistent whitespace# ("Turnaround10 business days") and singular/plural ("1 business day").DAYS_RE = re.compile(r"(\d+)\s*business\s*day", re.I)# Caveat capture: anything in parens after the day count except the price.# E.g. "1 business day + transit, (only available for 8ft & 10ft widths)(+$845.00)"# We want the size caveat, not the price.CAVEAT_RE = re.compile(r"\(([^)]*(?:only|available|except|excludes|sizes?)[^)]*)\)", re.I)# Storefront markup the parser keys on.SPEED_SECTION_LABEL = "Turnaround Option" # matches "Turnaround Option" and "Turnaround Options"OPTION_GROUP_OPEN = '<div class="md-option-group'OPTION_LABEL_ATTR = "data-option-label"# ---- Fetching ------------------------------------------------------------def fetch(url: str, timeout: int = 30, retries: int = 2) -> str:"""GET a URL with a real browser UA. Returns decoded HTML text.Retries on transient failures (timeouts, intermittent SSL hiccups, 5xx).Does NOT retry on 4xx: those are real and retrying won't help."""last_err = Nonefor attempt in range(retries + 1):try:req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})with urllib.request.urlopen(req, timeout=timeout) as r:return r.read().decode("utf-8", errors="ignore")except urllib.error.HTTPError as e:# 4xx: don't retry. 5xx: retry.if 400 <= e.code < 500:raiselast_err = eexcept Exception as e:last_err = eif attempt < retries:time.sleep(2.0 * (attempt + 1)) # 2s, then 4sraise last_err # type: ignore[misc]def read_local(path: str) -> str:"""Read a saved page from disk (offline mode)."""with open(path, encoding="utf-8", errors="ignore") as f:return f.read()def local_product_path(local_dir: str, url: str) -> str:"""Map a product URL to its saved page: <local_dir>/<slug>.html."""return os.path.join(local_dir, category_slug(url) + ".html")# ---- Category-page parsing ----------------------------------------------def extract_product_urls(category_html: str, category_url: str) -> list[str]:"""Pull product URLs from a category page in display order, deduped."""pattern = re.compile(r'href="(https?://meridiandisplay\.example/([a-z0-9\-]+)/)"')seen = set()ordered = []for m in pattern.finditer(category_html):url, slug = m.group(1), m.group(2)if url == category_url or url == category_url.rstrip("/") + "/":continueif slug in EXCLUDE_SLUGS:continueif slug.endswith("-menu") or slug.endswith("menu"):continueif url in seen:continueseen.add(url)ordered.append(url)return ordereddef canonical_url(page_html: str) -> str | None:"""The page's <link rel="canonical"> URL, if it has one (offline mode)."""m = re.search(r'<link[^>]*rel="canonical"[^>]*href="([^"]+)"', page_html, re.I)return m.group(1) if m else None# ---- Product-page parsing -----------------------------------------------def get_product_name(product_html: str) -> str:"""Pull the product H1, fall back to <title>."""m = re.search(r'<h1[^>]*class="[^"]*md-product__title[^"]*"[^>]*>(.*?)</h1>',product_html, re.I | re.S,)if not m:m = re.search(r"<h1[^>]*>(.*?)</h1>", product_html, re.I | re.S)if m:name = re.sub(r"<[^>]+>", "", m.group(1))name = html.unescape(name)name = re.sub(r"\s+", " ", name).strip()if name:return namem = re.search(r"<title>(.*?)</title>", product_html, re.I | re.S)if m:t = html.unescape(m.group(1)).strip()t = re.sub(r"\s*\|\s*Meridian Display.*$", "", t, flags=re.I)return treturn "(unknown product)"def get_speed_block(product_html: str) -> str | None:"""Isolate the option group containing the Turnaround Option(s) radios.Handles both 'Turnaround Option' (singular, used on some pages)and 'Turnaround Options' (plural)."""m = re.search(SPEED_SECTION_LABEL, product_html)if not m:return Noneidx = m.start()# Walk backward to the enclosing option-group div.start = product_html.rfind(OPTION_GROUP_OPEN, 0, idx)if start == -1:start = max(0, idx - 800)# Forward to the next option-group div, which bounds the speed-option block.rest = product_html[idx:]next_fg = re.search(re.escape(OPTION_GROUP_OPEN), rest)end = idx + next_fg.start() if next_fg else idx + 8000return product_html[start:end]def parse_options(block: str | None) -> list[dict]:"""Extract each speed option from the block.Returns list of dicts: {tier, days, caveat, raw}.tier is one of: 'standard', 'express', 'super_express', or 'unknown'."""if not block:return []options = []# Pull every data-option-label attr; filter to those that look like a# speed option (mention 'business day').for raw_match in re.finditer(OPTION_LABEL_ATTR + r'="([^"]*?)"', block):raw = raw_match.group(1)raw_clean = re.sub(r"\s+", " ", html.unescape(raw)).strip()if "business day" not in raw_clean.lower():continuedays_m = DAYS_RE.search(raw_clean)if not days_m:continuedays = int(days_m.group(1))tier = "unknown"for tier_name, tier_re in TIER_PATTERNS:if tier_re.search(raw_clean):tier = tier_namebreakcaveat_m = CAVEAT_RE.search(raw_clean)caveat = caveat_m.group(1).strip() if caveat_m else Noneoptions.append({"tier": tier,"days": days,"caveat": caveat,"raw": raw_clean,})return options# ---- Output formatting --------------------------------------------------def format_days_cell(days: int | None) -> str:if days is None:return ""if days == 1:return "1 business day"return f"{days} business days"def slash_join(values: list[int]) -> str:return SEPARATOR.join(str(v) for v in values)# ---- Main orchestration -------------------------------------------------def category_slug(category_url: str) -> str:path = urllib.parse.urlparse(category_url).path.strip("/")return path.split("/")[-1] if path else "category"def run(category_url: str, output_path: str | None = None) -> str:# Offline mode: a saved category page on disk. URLs take the path below# unchanged.local_dir = Noneif not category_url.startswith("http") and os.path.isfile(category_url):local_file = category_urllocal_dir = os.path.dirname(os.path.abspath(local_file))print(f"Reading category page from file: {local_file}", file=sys.stderr)cat_html = read_local(local_file)category_url = canonical_url(cat_html) or ""if output_path is None:slug = category_slug(category_url) if category_url else \os.path.splitext(os.path.basename(local_file))[0]output_path = f"{slug}_production_speeds.csv"else:if not category_url.startswith("http"):sys.exit(f"Bad URL: {category_url!r}")if output_path is None:output_path = f"{category_slug(category_url)}_production_speeds.csv"print(f"Fetching category page: {category_url}", file=sys.stderr)cat_html = fetch(category_url)product_urls = extract_product_urls(cat_html, category_url)print(f"Found {len(product_urls)} product URLs", file=sys.stderr)rows = []no_speed_section = []unknown_tier_labels = []caveats_dropped = []for i, url in enumerate(product_urls, 1):try:if local_dir:phtml = read_local(local_product_path(local_dir, url))else:time.sleep(REQUEST_SPACING_S)phtml = fetch(url)except Exception as e:print(f" [{i:02d}] FETCH FAILED ({e}), skipping {url}", file=sys.stderr)continuename = get_product_name(phtml)block = get_speed_block(phtml)opts = parse_options(block)if not opts:no_speed_section.append((i, name, url))print(f" [{i:02d}] {name} ⚠ no speed options found", file=sys.stderr)continue# Pick first occurrence per tier (radio options are unique by tier).tiers = {}for o in opts:if o["tier"] == "unknown":unknown_tier_labels.append((name, o["raw"]))continuetiers.setdefault(o["tier"], o)if o["caveat"]:caveats_dropped.append((name, o["tier"], o["caveat"]))std = tiers.get("standard", {}).get("days")exp = tiers.get("express", {}).get("days")sup = tiers.get("super_express", {}).get("days")tier_days = [d for d in (std, exp, sup) if d is not None]rows.append({"num": i,"name": name,"std": format_days_cell(std),"exp": format_days_cell(exp),"sup": format_days_cell(sup),"values": slash_join(tier_days),"values_p1": slash_join([d + 1 for d in tier_days]),})print(f" [{i:02d}] {name} → "f"std={std} exp={exp} sup={sup}",file=sys.stderr,)with open(output_path, "w", newline="", encoding="utf-8") as f:w = csv.writer(f, quoting=csv.QUOTE_MINIMAL)w.writerow(["#","Product Name","Standard Turnaround Days","Express Turnaround Days","Super Express Days","Production Days (slash-separated)","Production + Overnight Shipping (slash-separated)",])for r in rows:w.writerow([r["num"], r["name"], r["std"], r["exp"], r["sup"],r["values"], r["values_p1"],])print("", file=sys.stderr)print(f"Wrote {len(rows)} rows → {output_path}", file=sys.stderr)if no_speed_section:print(f"\n{len(no_speed_section)} products had no Turnaround Options section ""(likely custom-quote-only):", file=sys.stderr)for i, name, url in no_speed_section:print(f" [{i:02d}] {name} ({url})", file=sys.stderr)if caveats_dropped:print(f"\n{len(caveats_dropped)} caveats stripped from cells ""(tracked separately, not in CSV):", file=sys.stderr)for name, tier, caveat in caveats_dropped:print(f" {name} [{tier}] → {caveat}", file=sys.stderr)if unknown_tier_labels:print(f"\n{len(unknown_tier_labels)} option labels couldn't be matched ""to a tier (add to TIER_PATTERNS):", file=sys.stderr)for name, raw in unknown_tier_labels:print(f" {name} → {raw!r}", file=sys.stderr)return output_pathif __name__ == "__main__":if len(sys.argv) < 2:sys.exit(__doc__)cat_url = sys.argv[1]out = sys.argv[2] if len(sys.argv) > 2 else Nonerun(cat_url, out)