mattrguerra/meridian-skillsmainPrivate on GitHub, published here in full

build_speed_csv.py

Language Python13.7 KB395 lines

skills/production-speed-csv/scripts/build_speed_csv.py

  1. #!/usr/bin/env python3
  2. """
  3. build_speed_csv.py: Generate a production-speed CSV from a Meridian Display Co. category URL.
  4. Usage:
  5. python build_speed_csv.py <category_url | category_html_file> [<output_csv_path>]
  6. Example:
  7. python build_speed_csv.py https://meridiandisplay.example/10x10-booths/
  8. Offline mode: pass a saved category page instead of a URL. Each product page is
  9. then read from <slug>.html in the same folder as the category file, with no
  10. network access and no request spacing. The bundled test fixture works this way:
  11. python build_speed_csv.py fixtures/category-10x10-booths.html out.csv
  12. Output columns:
  13. #, Product Name, Standard Turnaround Days, Express Turnaround Days,
  14. Super Express Days, Production Days (slash-separated),
  15. Production + Overnight Shipping (slash-separated)
  16. """
  17. import csv
  18. import html
  19. import os
  20. import re
  21. import sys
  22. import time
  23. import urllib.error
  24. import urllib.parse
  25. import urllib.request
  26. # ---- Tunables ------------------------------------------------------------
  27. # Separator used inside the two summary columns. Slash because Claude.ai's CSV
  28. # previewer mishandles RFC 4180 quoted commas. Edit to "," if a downstream
  29. # parser actually needs comma-separated values (file will still open correctly
  30. # in Excel, Sheets, Numbers).
  31. SEPARATOR = " / "
  32. # Per-request spacing in seconds. 1.5s is the validated safe rate against
  33. # meridiandisplay.example's WAF for sustained sequential requests.
  34. REQUEST_SPACING_S = 1.5
  35. USER_AGENT = (
  36. "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
  37. "AppleWebKit/537.36 (KHTML, like Gecko) "
  38. "Chrome/120.0.0.0 Safari/537.36"
  39. )
  40. # ---- URL filtering -------------------------------------------------------
  41. # Slugs that show up in nav menus, footers, etc. These are never products.
  42. EXCLUDE_SLUGS = {
  43. "shop-all",
  44. "displays-menu",
  45. "outdoor-menu",
  46. "flags-menu",
  47. "stands-menu",
  48. "all-products",
  49. "service-request",
  50. "contact",
  51. "partner-program",
  52. "partner-benefits",
  53. "resources",
  54. "help",
  55. "turnaround-guide",
  56. "shipping",
  57. "about",
  58. "blog",
  59. "faq",
  60. "privacy",
  61. "terms",
  62. "returns",
  63. }
  64. # ---- Label variant patterns ---------------------------------------------
  65. # Order matters: Super Express must be checked before Express (because "Super
  66. # Express Turnaround" contains "Express"), and we want longest-match-first
  67. # behavior.
  68. TIER_PATTERNS = [
  69. ("super_express", re.compile(r"\bsuper\s*express\b", re.I)),
  70. ("express", re.compile(r"\bexpress\b", re.I)),
  71. ("standard", re.compile(r"\bstandard\b", re.I)),
  72. ]
  73. # Number + "business day(s)" extraction. Tolerates inconsistent whitespace
  74. # ("Turnaround10 business days") and singular/plural ("1 business day").
  75. DAYS_RE = re.compile(r"(\d+)\s*business\s*day", re.I)
  76. # Caveat capture: anything in parens after the day count except the price.
  77. # E.g. "1 business day + transit, (only available for 8ft & 10ft widths)(+$845.00)"
  78. # We want the size caveat, not the price.
  79. CAVEAT_RE = re.compile(r"\(([^)]*(?:only|available|except|excludes|sizes?)[^)]*)\)", re.I)
  80. # Storefront markup the parser keys on.
  81. SPEED_SECTION_LABEL = "Turnaround Option" # matches "Turnaround Option" and "Turnaround Options"
  82. OPTION_GROUP_OPEN = '<div class="md-option-group'
  83. OPTION_LABEL_ATTR = "data-option-label"
  84. # ---- Fetching ------------------------------------------------------------
  85. def fetch(url: str, timeout: int = 30, retries: int = 2) -> str:
  86. """GET a URL with a real browser UA. Returns decoded HTML text.
  87. Retries on transient failures (timeouts, intermittent SSL hiccups, 5xx).
  88. Does NOT retry on 4xx: those are real and retrying won't help.
  89. """
  90. last_err = None
  91. for attempt in range(retries + 1):
  92. try:
  93. req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
  94. with urllib.request.urlopen(req, timeout=timeout) as r:
  95. return r.read().decode("utf-8", errors="ignore")
  96. except urllib.error.HTTPError as e:
  97. # 4xx: don't retry. 5xx: retry.
  98. if 400 <= e.code < 500:
  99. raise
  100. last_err = e
  101. except Exception as e:
  102. last_err = e
  103. if attempt < retries:
  104. time.sleep(2.0 * (attempt + 1)) # 2s, then 4s
  105. raise last_err # type: ignore[misc]
  106. def read_local(path: str) -> str:
  107. """Read a saved page from disk (offline mode)."""
  108. with open(path, encoding="utf-8", errors="ignore") as f:
  109. return f.read()
  110. def local_product_path(local_dir: str, url: str) -> str:
  111. """Map a product URL to its saved page: <local_dir>/<slug>.html."""
  112. return os.path.join(local_dir, category_slug(url) + ".html")
  113. # ---- Category-page parsing ----------------------------------------------
  114. def extract_product_urls(category_html: str, category_url: str) -> list[str]:
  115. """Pull product URLs from a category page in display order, deduped."""
  116. pattern = re.compile(r'href="(https?://meridiandisplay\.example/([a-z0-9\-]+)/)"')
  117. seen = set()
  118. ordered = []
  119. for m in pattern.finditer(category_html):
  120. url, slug = m.group(1), m.group(2)
  121. if url == category_url or url == category_url.rstrip("/") + "/":
  122. continue
  123. if slug in EXCLUDE_SLUGS:
  124. continue
  125. if slug.endswith("-menu") or slug.endswith("menu"):
  126. continue
  127. if url in seen:
  128. continue
  129. seen.add(url)
  130. ordered.append(url)
  131. return ordered
  132. def canonical_url(page_html: str) -> str | None:
  133. """The page's <link rel="canonical"> URL, if it has one (offline mode)."""
  134. m = re.search(r'<link[^>]*rel="canonical"[^>]*href="([^"]+)"', page_html, re.I)
  135. return m.group(1) if m else None
  136. # ---- Product-page parsing -----------------------------------------------
  137. def get_product_name(product_html: str) -> str:
  138. """Pull the product H1, fall back to <title>."""
  139. m = re.search(
  140. r'<h1[^>]*class="[^"]*md-product__title[^"]*"[^>]*>(.*?)</h1>',
  141. product_html, re.I | re.S,
  142. )
  143. if not m:
  144. m = re.search(r"<h1[^>]*>(.*?)</h1>", product_html, re.I | re.S)
  145. if m:
  146. name = re.sub(r"<[^>]+>", "", m.group(1))
  147. name = html.unescape(name)
  148. name = re.sub(r"\s+", " ", name).strip()
  149. if name:
  150. return name
  151. m = re.search(r"<title>(.*?)</title>", product_html, re.I | re.S)
  152. if m:
  153. t = html.unescape(m.group(1)).strip()
  154. t = re.sub(r"\s*\|\s*Meridian Display.*$", "", t, flags=re.I)
  155. return t
  156. return "(unknown product)"
  157. def get_speed_block(product_html: str) -> str | None:
  158. """Isolate the option group containing the Turnaround Option(s) radios.
  159. Handles both 'Turnaround Option' (singular, used on some pages)
  160. and 'Turnaround Options' (plural).
  161. """
  162. m = re.search(SPEED_SECTION_LABEL, product_html)
  163. if not m:
  164. return None
  165. idx = m.start()
  166. # Walk backward to the enclosing option-group div.
  167. start = product_html.rfind(OPTION_GROUP_OPEN, 0, idx)
  168. if start == -1:
  169. start = max(0, idx - 800)
  170. # Forward to the next option-group div, which bounds the speed-option block.
  171. rest = product_html[idx:]
  172. next_fg = re.search(re.escape(OPTION_GROUP_OPEN), rest)
  173. end = idx + next_fg.start() if next_fg else idx + 8000
  174. return product_html[start:end]
  175. def parse_options(block: str | None) -> list[dict]:
  176. """Extract each speed option from the block.
  177. Returns list of dicts: {tier, days, caveat, raw}.
  178. tier is one of: 'standard', 'express', 'super_express', or 'unknown'.
  179. """
  180. if not block:
  181. return []
  182. options = []
  183. # Pull every data-option-label attr; filter to those that look like a
  184. # speed option (mention 'business day').
  185. for raw_match in re.finditer(OPTION_LABEL_ATTR + r'="([^"]*?)"', block):
  186. raw = raw_match.group(1)
  187. raw_clean = re.sub(r"\s+", " ", html.unescape(raw)).strip()
  188. if "business day" not in raw_clean.lower():
  189. continue
  190. days_m = DAYS_RE.search(raw_clean)
  191. if not days_m:
  192. continue
  193. days = int(days_m.group(1))
  194. tier = "unknown"
  195. for tier_name, tier_re in TIER_PATTERNS:
  196. if tier_re.search(raw_clean):
  197. tier = tier_name
  198. break
  199. caveat_m = CAVEAT_RE.search(raw_clean)
  200. caveat = caveat_m.group(1).strip() if caveat_m else None
  201. options.append({
  202. "tier": tier,
  203. "days": days,
  204. "caveat": caveat,
  205. "raw": raw_clean,
  206. })
  207. return options
  208. # ---- Output formatting --------------------------------------------------
  209. def format_days_cell(days: int | None) -> str:
  210. if days is None:
  211. return ""
  212. if days == 1:
  213. return "1 business day"
  214. return f"{days} business days"
  215. def slash_join(values: list[int]) -> str:
  216. return SEPARATOR.join(str(v) for v in values)
  217. # ---- Main orchestration -------------------------------------------------
  218. def category_slug(category_url: str) -> str:
  219. path = urllib.parse.urlparse(category_url).path.strip("/")
  220. return path.split("/")[-1] if path else "category"
  221. def run(category_url: str, output_path: str | None = None) -> str:
  222. # Offline mode: a saved category page on disk. URLs take the path below
  223. # unchanged.
  224. local_dir = None
  225. if not category_url.startswith("http") and os.path.isfile(category_url):
  226. local_file = category_url
  227. local_dir = os.path.dirname(os.path.abspath(local_file))
  228. print(f"Reading category page from file: {local_file}", file=sys.stderr)
  229. cat_html = read_local(local_file)
  230. category_url = canonical_url(cat_html) or ""
  231. if output_path is None:
  232. slug = category_slug(category_url) if category_url else \
  233. os.path.splitext(os.path.basename(local_file))[0]
  234. output_path = f"{slug}_production_speeds.csv"
  235. else:
  236. if not category_url.startswith("http"):
  237. sys.exit(f"Bad URL: {category_url!r}")
  238. if output_path is None:
  239. output_path = f"{category_slug(category_url)}_production_speeds.csv"
  240. print(f"Fetching category page: {category_url}", file=sys.stderr)
  241. cat_html = fetch(category_url)
  242. product_urls = extract_product_urls(cat_html, category_url)
  243. print(f"Found {len(product_urls)} product URLs", file=sys.stderr)
  244. rows = []
  245. no_speed_section = []
  246. unknown_tier_labels = []
  247. caveats_dropped = []
  248. for i, url in enumerate(product_urls, 1):
  249. try:
  250. if local_dir:
  251. phtml = read_local(local_product_path(local_dir, url))
  252. else:
  253. time.sleep(REQUEST_SPACING_S)
  254. phtml = fetch(url)
  255. except Exception as e:
  256. print(f" [{i:02d}] FETCH FAILED ({e}), skipping {url}", file=sys.stderr)
  257. continue
  258. name = get_product_name(phtml)
  259. block = get_speed_block(phtml)
  260. opts = parse_options(block)
  261. if not opts:
  262. no_speed_section.append((i, name, url))
  263. print(f" [{i:02d}] {name} ⚠ no speed options found", file=sys.stderr)
  264. continue
  265. # Pick first occurrence per tier (radio options are unique by tier).
  266. tiers = {}
  267. for o in opts:
  268. if o["tier"] == "unknown":
  269. unknown_tier_labels.append((name, o["raw"]))
  270. continue
  271. tiers.setdefault(o["tier"], o)
  272. if o["caveat"]:
  273. caveats_dropped.append((name, o["tier"], o["caveat"]))
  274. std = tiers.get("standard", {}).get("days")
  275. exp = tiers.get("express", {}).get("days")
  276. sup = tiers.get("super_express", {}).get("days")
  277. tier_days = [d for d in (std, exp, sup) if d is not None]
  278. rows.append({
  279. "num": i,
  280. "name": name,
  281. "std": format_days_cell(std),
  282. "exp": format_days_cell(exp),
  283. "sup": format_days_cell(sup),
  284. "values": slash_join(tier_days),
  285. "values_p1": slash_join([d + 1 for d in tier_days]),
  286. })
  287. print(
  288. f" [{i:02d}] {name} → "
  289. f"std={std} exp={exp} sup={sup}",
  290. file=sys.stderr,
  291. )
  292. with open(output_path, "w", newline="", encoding="utf-8") as f:
  293. w = csv.writer(f, quoting=csv.QUOTE_MINIMAL)
  294. w.writerow([
  295. "#",
  296. "Product Name",
  297. "Standard Turnaround Days",
  298. "Express Turnaround Days",
  299. "Super Express Days",
  300. "Production Days (slash-separated)",
  301. "Production + Overnight Shipping (slash-separated)",
  302. ])
  303. for r in rows:
  304. w.writerow([
  305. r["num"], r["name"], r["std"], r["exp"], r["sup"],
  306. r["values"], r["values_p1"],
  307. ])
  308. print("", file=sys.stderr)
  309. print(f"Wrote {len(rows)} rows → {output_path}", file=sys.stderr)
  310. if no_speed_section:
  311. print(f"\n{len(no_speed_section)} products had no Turnaround Options section "
  312. "(likely custom-quote-only):", file=sys.stderr)
  313. for i, name, url in no_speed_section:
  314. print(f" [{i:02d}] {name} ({url})", file=sys.stderr)
  315. if caveats_dropped:
  316. print(f"\n{len(caveats_dropped)} caveats stripped from cells "
  317. "(tracked separately, not in CSV):", file=sys.stderr)
  318. for name, tier, caveat in caveats_dropped:
  319. print(f" {name} [{tier}] → {caveat}", file=sys.stderr)
  320. if unknown_tier_labels:
  321. print(f"\n{len(unknown_tier_labels)} option labels couldn't be matched "
  322. "to a tier (add to TIER_PATTERNS):", file=sys.stderr)
  323. for name, raw in unknown_tier_labels:
  324. print(f" {name} → {raw!r}", file=sys.stderr)
  325. return output_path
  326. if __name__ == "__main__":
  327. if len(sys.argv) < 2:
  328. sys.exit(__doc__)
  329. cat_url = sys.argv[1]
  330. out = sys.argv[2] if len(sys.argv) > 2 else None
  331. run(cat_url, out)