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>
158 lines
5.1 KiB
Python
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()
|