← back to Carnegie Import
scrape_carnegie.py
169 lines
#!/usr/bin/env python3
"""Carnegie Fabrics feed-first scraper — Magento 2 GraphQL ($0, no browser, no AI).
Stages configurable products + colorway variants into dw_unified.carnegie_catalog.
Classifies wallcovering-vs-fabric by URL/SKU type-suffix. NEVER-DUPLICATE dedup on mfr_sku.
"""
import json, re, sys, time, subprocess, urllib.request
GQL = "https://carnegiefabrics.com/graphql"
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
# type-suffix -> DW class. Wallcovering = wall-surface products; else Fabric/textile.
WALL_SUFFIXES = {"wallcoverings", "upholstered-walls"}
QUERY = """{ products(search:"", pageSize:%d, currentPage:%d) {
total_count page_info{ total_pages current_page }
items {
sku name url_key __typename
image{ url } media_gallery{ url }
price_range{ minimum_price{ regular_price{ value currency } } }
categories{ name }
short_description{ html } description{ html }
... on ConfigurableProduct {
variants { product { sku name image{ url } } attributes { label value_index } }
}
}
} }"""
def gql(page, size=50, retries=4):
body = json.dumps({"query": QUERY % (size, page)}).encode()
for a in range(retries):
try:
req = urllib.request.Request(GQL, data=body,
headers={"Content-Type": "application/json", "User-Agent": UA})
with urllib.request.urlopen(req, timeout=40) as r:
d = json.loads(r.read())
if "errors" in d and not d.get("data"):
raise RuntimeError(d["errors"])
return d["data"]["products"]
except Exception:
if a == retries - 1:
raise
time.sleep(2 * (a + 1))
def suffix_of(url_key, sku):
# url_key like "mood-6602-windows" ; sku like "6602-windows"
for src in (url_key or "", sku or ""):
m = re.search(r"-([a-z-]+)$", src)
if m:
return m.group(1)
return ""
def classify(suffix):
return "Wallcovering" if suffix in WALL_SUFFIXES else "Fabric"
def sqlq(v):
if v is None:
return "NULL"
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float)):
return str(v)
if isinstance(v, (dict, list)):
return "'" + json.dumps(v).replace("'", "''") + "'"
return "'" + str(v).replace("'", "''") + "'"
def pgarr(lst):
if not lst:
return "NULL"
inner = ",".join('"' + str(x).replace('"', '\\"') + '"' for x in lst)
return "'{" + inner + "}'"
def main():
first = gql(1)
total, pages = first["total_count"], first["page_info"]["total_pages"]
print(f"[feed] total_count={total} pages={pages} (pageSize=50)", flush=True)
all_items = list(first["items"])
for p in range(2, pages + 1):
d = gql(p)
all_items.extend(d["items"])
print(f"[feed] page {p}/{pages} cum={len(all_items)}", flush=True)
time.sleep(0.4) # polite
rows = [] # per-colorway (sellable) SKU rows
prod_count = 0
split = {} # suffix -> count (product level)
for it in all_items:
prod_count += 1
suf = suffix_of(it.get("url_key"), it.get("sku"))
dwc = classify(suf)
split[suf] = split.get(suf, 0) + 1
pnum = re.sub(r"-.*$", "", it.get("sku") or "")
cats = [c["name"] for c in (it.get("categories") or [])]
pr = (((it.get("price_range") or {}).get("minimum_price") or {}).get("regular_price") or {})
price, cur = pr.get("value"), pr.get("currency")
desc_html = (it.get("description") or {}).get("html") or (it.get("short_description") or {}).get("html")
desc_txt = re.sub(r"<[^>]+>", "", desc_html or "").strip() or None
gallery = [g["url"] for g in (it.get("media_gallery") or [])]
purl = "https://carnegiefabrics.com/" + (it.get("url_key") or "")
variants = it.get("variants") or []
def base_row(mfr, cnum, cimg, is_var):
return dict(mfr_sku=mfr, dw_sku=None, parent_sku=it.get("sku"),
pattern_number=pnum, pattern_name=it.get("name"),
color_number=cnum, color_name=(f"Color {cnum}" if cnum else None),
title=(f"{it.get('name')} {cnum}".strip() if cnum else it.get("name")),
dw_class=dwc, type_suffix=suf, product_type=(cats[0] if cats else None),
categories=cats, body_html=desc_html, description_text=desc_txt,
price=price, currency=cur,
all_images=([cimg] if cimg else []) + gallery,
image_url=(cimg or (it.get("image") or {}).get("url")),
product_url=purl, is_variant=is_var,
raw={"sku": it.get("sku"), "typename": it.get("__typename")})
if variants:
for v in variants:
vp = v["product"]
cnum_lbl = (v["attributes"][0]["label"] if v.get("attributes") else None)
rows.append(base_row(vp["sku"], cnum_lbl, (vp.get("image") or {}).get("url"), True))
else:
rows.append(base_row(it.get("sku"), None, (it.get("image") or {}).get("url"), False))
# dedup on mfr_sku (keep first)
seen, uniq = set(), []
for r in rows:
if r["mfr_sku"] in seen:
continue
seen.add(r["mfr_sku"])
uniq.append(r)
print(f"[stage] product-level={prod_count} sellable-SKU rows(deduped)={len(uniq)}", flush=True)
cols = ["mfr_sku","dw_sku","parent_sku","pattern_number","pattern_name","color_number",
"color_name","title","dw_class","type_suffix","product_type","categories",
"body_html","description_text","price","currency","all_images","image_url",
"product_url","is_variant","raw"]
def val(r, c):
v = r[c]
if c in ("categories", "all_images"):
return pgarr(v)
return sqlq(v)
# batch inserts with upsert (NEVER-DUPLICATE)
BATCH = 200
inserted = 0
for i in range(0, len(uniq), BATCH):
chunk = uniq[i:i+BATCH]
vals = ",\n".join("(" + ",".join(val(r, c) for c in cols) + ")" for r in chunk)
sql = (f"INSERT INTO carnegie_catalog ({','.join(cols)}) VALUES\n{vals}\n"
"ON CONFLICT (mfr_sku) DO UPDATE SET "
+ ", ".join(f"{c}=EXCLUDED.{c}" for c in cols if c != "mfr_sku")
+ ", updated_at=now();")
p = subprocess.run(["psql", "host=/tmp dbname=dw_unified", "-v", "ON_ERROR_STOP=1", "-q"],
input=sql, text=True, capture_output=True)
if p.returncode != 0:
sys.stderr.write(p.stderr[:2000])
raise SystemExit(f"psql failed on batch {i}")
inserted += len(chunk)
print(f"[stage] upserted {inserted}/{len(uniq)}", flush=True)
# summary
wall = sum(1 for r in uniq if r["dw_class"] == "Wallcovering")
fab = len(uniq) - wall
print(json.dumps({"products": prod_count, "sellable_skus": len(uniq),
"wallcovering_skus": wall, "fabric_skus": fab,
"product_split_by_suffix": split}, indent=2))
if __name__ == "__main__":
main()