#!/usr/bin/env python3 """Replace product 129's square thumbnail with the reframed 762x1100 version. The source was a 2400x2400 square. The storefront grid renders at aspect-3/4 with object-cover, so a 1:1 image loses 12.5% off each side — enough to cut the heel and the toe off the shoe. Every other shoe in the catalogue is 762x1100 with the subject at ~93% width, so this reframes 129 to match its siblings. Uploads the new asset at position 1, verifies it is really there, then removes the square original. --apply is required; the default is a dry run. """ import argparse import json import pathlib import sys import urllib.error import urllib.request import uuid import os as _os def _spree_admin_key() -> str: """The Spree admin API key. Required — never defaulted.""" key = _os.environ.get("SPREE_ADMIN_KEY", "").strip() if not key: raise SystemExit( "SPREE_ADMIN_KEY is not set.\n" " export SPREE_ADMIN_KEY=\n" "It is deliberately not stored in this file — see README.md." ) return key SPREE_API = "https://api.jux-lab.com/api/v3/admin" SPREE_KEY = _spree_admin_key() SLUG = "129-adidas-lifestyle-barreda-shoes-men-white-jp5928" NEW_IMAGE = pathlib.Path( "/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products/" "129-adidas-lifestyle-barreda-shoes-men-white-jp5928/image-1-reframed.jpg" ) def fatal(msg): print(f"FATAL: {msg}", file=sys.stderr) sys.exit(1) def multipart(fields, file_field, filename, blob, content_type): boundary = uuid.uuid4().hex parts = [] for k, v in fields.items(): parts.append( f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode() ) parts.append( f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; ' f'filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'.encode() + blob + b"\r\n" ) parts.append(f"--{boundary}--\r\n".encode()) return b"".join(parts), f"multipart/form-data; boundary={boundary}" def api(path, method="GET", data=None, ctype=None): headers = {"X-Spree-Api-Key": SPREE_KEY} if ctype: headers["Content-Type"] = ctype req = urllib.request.Request( f"{SPREE_API}{path}", data=data, headers=headers, method=method ) try: with urllib.request.urlopen(req, timeout=180) as r: raw = r.read() return r.status, (json.loads(raw) if raw else {}) except urllib.error.HTTPError as e: fatal(f"{method} {path} -> HTTP {e.code}: {e.read().decode()[:400]}") # This Spree build serves flat objects, not JSON:API — the fields are on the # object itself, not under "attributes" — and it pages on `limit`, ignoring # `per_page` entirely. def find_product(slug): page = 1 while True: _, d = api(f"/products?limit=100&page={page}") for p in d.get("data", []): if p.get("slug") == slug: return p if page >= d.get("meta", {}).get("pages", 1): return None page += 1 def media_list(pid): _, d = api(f"/products/{pid}/media?limit=100") return d.get("data", []) def main(): ap = argparse.ArgumentParser() ap.add_argument("--apply", action="store_true", help="actually write to Spree") args = ap.parse_args() if not NEW_IMAGE.exists(): fatal(f"{NEW_IMAGE} missing") p = find_product(SLUG) if not p: fatal(f"product {SLUG} not found") pid = p["id"] print(f"product {pid} {p.get('name')}") before = media_list(pid) print(f"media before: {len(before)}") for m in before: print(f" id={m['id']} position={m.get('position')} {str(m.get('original_url',''))[:80]}") if not args.apply: print("\nDRY RUN — re-run with --apply to upload and remove the square original.") return old_ids = [m["id"] for m in before] body, ctype = multipart( {"type": "Spree::Image", "position": "1"}, "attachment", NEW_IMAGE.name, NEW_IMAGE.read_bytes(), "image/jpeg", ) status, resp = api(f"/products/{pid}/media", "POST", body, ctype) new_id = (resp.get("data") or resp).get("id") print(f"\nupload -> HTTP {status}, new asset id={new_id}") if status == 202 or not new_id: fatal("Spree answered without a serialized asset — cannot confirm the image landed") # Re-read rather than trusting the create response: media#create has a path # that enqueues a background job and answers 202 with no body. after = media_list(pid) if new_id not in [m["id"] for m in after]: fatal(f"asset {new_id} is not in the product media after upload") print(f"verified: asset {new_id} present, {len(after)} media on product") for oid in old_ids: st, _ = api(f"/products/{pid}/media/{oid}", "DELETE") print(f"removed old asset {oid} -> HTTP {st}") final = media_list(pid) print(f"\nmedia after: {len(final)}") for m in final: print(f" id={m['id']} position={m.get('position')} {str(m.get('original_url',''))[:80]}") if __name__ == "__main__": main()