Files
pak-jabit/catalogue/reframe_129.py
T
juxtapoandClaude Opus 5 a58a61b98c Pak Jabit: full stack — storefront, Spree customisations, catalogue tooling
A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products
live, three markets, live Malaysian courier rates, customer-owned carts.

What is here:
  storefront/  the Next.js app
  backend/     ONLY the custom Spree code. Spree itself is a prebuilt image
               that mounts no app code, so every file here needs an explicit
               volume entry in docker-compose.yml or it is silently ignored.
  catalogue/   the scripts that loaded and maintain the products
  docs/        manifest, mock registry, napkins

Real .env files are committed deliberately. This lives on the house's private
Gitea, and a clone that runs beats a clone that needs a secrets handover.
LANDMINE.md maps every key, what it opens, and what must be rotated before this
repo goes anywhere less private.

The admin API key used to be a literal in six catalogue scripts. They now read
SPREE_ADMIN_KEY from the environment and refuse to run without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 22:09:55 +08:00

158 lines
5.1 KiB
Python

#!/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=<the admin API 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()