← back to Reid Witlin Onboarding
recover_held.py
143 lines
#!/usr/bin/env python3
"""
Image recovery for the image-held pile — SAFE (read-only external + local CSVs).
The 167 held products couldn't be matched by gallery filename (hash names,
numeric colorways). But rwltd.com is Shopify: {product_url}.js exposes an
authoritative variant.featured_image per colorway. Fetch each held pattern's .js
once, map colorway (from mfr_sku) -> the vendor's own featured_image, and promote
recovered rows into targets_ready.csv. Whatever still can't be matched stays held.
Writes only local CSVs. Idempotent. $0 (curl).
"""
import csv, json, os, re, time, subprocess, urllib.request, collections
HERE = os.path.dirname(os.path.abspath(__file__))
def slugify(s):
return re.sub(r'-+', '-', re.sub(r'[^a-z0-9]+', '-', (s or '').lower())).strip('-')
def fetch_js(url):
# Shopify's edge bot-protection fingerprints urllib (intermittent 403) but
# lets curl through — shell to curl, with retry/backoff so a transient block
# degrades gracefully instead of silently zeroing the whole recovery pass.
for attempt in range(4):
r = subprocess.run(
["curl", "-s", "--max-time", "25", "-A", "Mozilla/5.0", url + ".js"],
capture_output=True, text=True)
if r.returncode == 0 and r.stdout.strip():
try:
return json.loads(r.stdout)
except Exception:
pass
time.sleep(1.5 * (attempt + 1))
return None
def main():
held = list(csv.DictReader(open(os.path.join(HERE, "targets_image_held.csv"))))
ready = list(csv.DictReader(open(os.path.join(HERE, "targets_ready.csv"))))
ready_cols = ready[0].keys() if ready else held[0].keys()
# mfr_sku -> product_url from the catalog
r = subprocess.run(["psql", "host=/tmp dbname=dw_unified", "-tA", "-F", "\t", "-c",
"SELECT mfr_sku, product_url FROM rwltd_catalog WHERE product_url LIKE 'http%';"],
capture_output=True, text=True)
url_by_mfr = {}
for line in r.stdout.strip().splitlines():
if "\t" in line:
m, u = line.split("\t", 1); url_by_mfr[m] = u
# Verify against the authoritative vendor source (colorway == variant title):
# - held rows: recover an image if the vendor has one.
# - fuzzy "prefix" ready rows: CONFIRM via vendor-js (Cody gate) — the 4-char
# filename guess is not certainty; let the vendor's own data decide.
# - "exact" / already-"vendor-js" rows: trusted whole-token matches, kept as-is.
trusted = [r for r in ready if r["image_confidence"] in ("exact", "vendor-js")]
to_verify = held + [r for r in ready if r["image_confidence"] == "prefix"]
js_cache = {}
recovered, still_held = [], []
for row in to_verify:
mfr = row["mfr_sku"]
url = url_by_mfr.get(mfr)
colorway = mfr[len(slugify(row["pattern"]))+1:] if slugify(row["pattern"]) and \
slugify(mfr).startswith(slugify(row["pattern"])+"-") else slugify(mfr).rsplit("-",1)[-1]
img = None
if url:
if url not in js_cache:
js_cache[url] = fetch_js(url); time.sleep(0.4)
d = js_cache[url]
if d:
cw = colorway.replace('-', '').lower()
for v in d.get("variants", []):
vt = re.sub(r'[^a-z0-9]', '', (v.get("title") or "").lower())
fi = v.get("featured_image") or {}
if vt and cw and vt == cw and fi.get("src"):
img = fi["src"]; break
if not img: # looser: colorway token contained in variant title
for v in d.get("variants", []):
vt = re.sub(r'[^a-z0-9]', '', (v.get("title") or "").lower())
fi = v.get("featured_image") or {}
if vt and cw and (cw in vt or vt in cw) and fi.get("src"):
img = fi["src"]; break
if img:
row["image_url"] = img
row["image_confidence"] = "vendor-js"
recovered.append(row)
else:
# vendor can't confirm — hold for eyeball rather than ship a guess
row["image_url"] = ""
row["image_confidence"] = "unconfirmed-held"
still_held.append(row)
ready = trusted + recovered
# collision post-pass: an image shared by >1 product is ambiguous (the vendor
# reuses one swatch for two colorways) -> hold both rather than mis-ship.
img_counts = collections.Counter(r["image_url"] for r in ready if r["image_url"])
collided = {u for u, c in img_counts.items() if c > 1}
if collided:
keep = []
for r in ready:
if r["image_url"] in collided:
r["image_confidence"] = "collision-held"
r["image_url"] = ""
still_held.append(r)
else:
keep.append(r)
ready = keep
def write_csv(path, recs, cols):
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(cols)); w.writeheader()
for r_ in recs:
w.writerow({k: r_.get(k, "") for k in cols})
write_csv(os.path.join(HERE, "targets_ready.csv"), ready, ready_cols)
write_csv(os.path.join(HERE, "targets_image_held.csv"), still_held,
held[0].keys() if held else ready_cols)
conf_mix = dict(collections.Counter(r["image_confidence"] for r in ready))
out = {
"held_before": len(held),
"verified_this_pass": len(to_verify),
"recovered_or_confirmed_via_vendor_js": len(recovered),
"still_held": len(still_held),
"ready_now": len(ready),
"ready_confidence_mix": conf_mix,
"pages_fetched": len(js_cache),
}
print(json.dumps(out, indent=2))
# keep summary.json authoritative (build_batch's is pre-recovery / stale otherwise)
sp = os.path.join(HERE, "summary.json")
try:
s = json.load(open(sp))
except Exception:
s = {}
s.update({"ready_to_onboard": len(ready), "image_held_for_eyeball": len(still_held),
"ready_confidence_mix": conf_mix, "recovery": out})
json.dump(s, open(sp, "w"), indent=2)
if __name__ == "__main__":
main()