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>
219 lines
8.2 KiB
Python
Executable File
219 lines
8.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Pak Jabit — Photoroom return leg.
|
|
|
|
The missing half of photoroom_pipeline.py: takes a source image, runs it through
|
|
Photoroom, and pushes the result back onto the Spree product as a media asset.
|
|
|
|
source jpg -> Photoroom /v2/edit -> Spree POST /products/:id/media -> verify
|
|
|
|
Verification is not optional here. Spree's media#create has a url path that
|
|
enqueues a background job and answers `head :accepted` — a 202 with no body and
|
|
no way to know if the image ever landed. This uses the multipart path, which
|
|
returns 201 plus the serialized asset, and then re-reads the product's media to
|
|
confirm the asset id is really there.
|
|
|
|
Usage:
|
|
photoroom_to_spree.py --slug 19-electro-denim-lab-chino-pants --live
|
|
photoroom_to_spree.py --slug <slug> --position 1 # make it the thumbnail
|
|
photoroom_to_spree.py --slug <slug> --dry-run
|
|
|
|
Keys come from /root/.photoroom/credentials. Sandbox is the default and
|
|
watermarks every image; --live uses the paid allowance (10/month) and is the
|
|
only way to get a clean result onto a live storefront.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import mimetypes
|
|
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()
|
|
PHOTOROOM_EDIT = "https://image-api.photoroom.com/v2/edit"
|
|
CREDS = pathlib.Path("/root/.photoroom/credentials")
|
|
SOURCE_ROOT = pathlib.Path(
|
|
"/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products"
|
|
)
|
|
|
|
|
|
def fatal(msg):
|
|
print(f"FATAL: {msg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def load_key(live: bool) -> str:
|
|
if not CREDS.exists():
|
|
fatal(f"{CREDS} missing — no API key to use")
|
|
want = "PHOTOROOM_LIVE_KEY" if live else "PHOTOROOM_SANDBOX_KEY"
|
|
for line in CREDS.read_text().splitlines():
|
|
if line.startswith(want + "="):
|
|
return line.split("=", 1)[1].strip()
|
|
fatal(f"{want} not found in {CREDS}")
|
|
|
|
|
|
def multipart(fields: dict, file_field: str, filename: str, blob: bytes, content_type: str):
|
|
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 spree_get(path):
|
|
req = urllib.request.Request(
|
|
f"{SPREE_API}{path}", headers={"X-Spree-Api-Key": SPREE_KEY}
|
|
)
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
return json.loads(r.read())
|
|
|
|
|
|
def find_product(slug):
|
|
data = spree_get(f"/products?filter[slug_eq]={slug}&per_page=50")
|
|
for p in data.get("data", []):
|
|
if p.get("slug") == slug or p.get("attributes", {}).get("slug") == slug:
|
|
return p
|
|
# The filter key varies by Spree build; fall back to a full scan rather than
|
|
# reporting "not found" for a product that is actually there.
|
|
page = 1
|
|
while True:
|
|
d = spree_get(f"/products?per_page=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 photoroom(img_path: pathlib.Path, key: str, size: str, bg: str) -> bytes:
|
|
body, ctype = multipart(
|
|
{"background.color": bg, "outputSize": size, "padding": "0.05"},
|
|
"imageFile",
|
|
img_path.name,
|
|
img_path.read_bytes(),
|
|
mimetypes.guess_type(img_path.name)[0] or "image/jpeg",
|
|
)
|
|
req = urllib.request.Request(
|
|
PHOTOROOM_EDIT, data=body, headers={"x-api-key": key, "Content-Type": ctype}
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=180) as r:
|
|
data = r.read()
|
|
if not data:
|
|
fatal("Photoroom returned 200 with an empty body")
|
|
if not r.headers.get("content-type", "").startswith("image/"):
|
|
fatal(f"Photoroom returned non-image: {r.headers.get('content-type')}")
|
|
return data
|
|
except urllib.error.HTTPError as e:
|
|
fatal(f"Photoroom HTTP {e.code}: {e.read().decode()[:300]}")
|
|
|
|
|
|
def upload_to_spree(product_id: str, blob: bytes, filename: str, position):
|
|
# position is always sent. Spree defaults a new asset to position 1, which
|
|
# silently makes it the product thumbnail — a watermarked sandbox image went
|
|
# live on the storefront that way. Callers pass the append position unless
|
|
# they explicitly want the front.
|
|
fields = {"type": "Spree::Image", "position": str(position)}
|
|
body, ctype = multipart(fields, "attachment", filename, blob, "image/png")
|
|
req = urllib.request.Request(
|
|
f"{SPREE_API}/products/{product_id}/media",
|
|
data=body,
|
|
headers={"X-Spree-Api-Key": SPREE_KEY, "Content-Type": ctype},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=180) as r:
|
|
return r.status, json.loads(r.read() or b"{}")
|
|
except urllib.error.HTTPError as e:
|
|
fatal(f"Spree HTTP {e.code}: {e.read().decode()[:400]}")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--slug", required=True)
|
|
ap.add_argument("--image", help="source file (default: products/<slug>/image-1.jpg)")
|
|
ap.add_argument("--live", action="store_true", help="use the paid key (10/month, no watermark)")
|
|
ap.add_argument("--position", type=int,
|
|
help="1 makes it the product thumbnail. Default: appended last.")
|
|
ap.add_argument("--size", default="1000x1000")
|
|
ap.add_argument("--bg", default="FFFFFF")
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
src = pathlib.Path(args.image) if args.image else SOURCE_ROOT / args.slug / "image-1.jpg"
|
|
if not src.exists():
|
|
fatal(f"source image not found: {src}")
|
|
|
|
product = find_product(args.slug)
|
|
if not product:
|
|
fatal(f"no Spree product with slug {args.slug}")
|
|
pid = product["id"]
|
|
name = product.get("name", "?")
|
|
|
|
before = spree_get(f"/products/{pid}/media").get("data", [])
|
|
print(f"product : {name} ({pid})")
|
|
print(f"source : {src} ({src.stat().st_size:,} bytes)")
|
|
print(f"media : {len(before)} existing")
|
|
print(f"key : {'LIVE (consumes paid quota)' if args.live else 'sandbox (watermarked)'}")
|
|
|
|
if args.dry_run:
|
|
print("\n--dry-run: nothing sent.")
|
|
return
|
|
|
|
key = load_key(args.live)
|
|
position = args.position if args.position is not None else len(before) + 1
|
|
print(f"\nprocessing... (will land at position {position}"
|
|
f"{' — THUMBNAIL' if position == 1 else ''})")
|
|
out = photoroom(src, key, args.size, args.bg)
|
|
print(f" photoroom ok: {len(out):,} bytes png")
|
|
|
|
status, resp = upload_to_spree(pid, out, f"{args.slug}-photoroom.png", position)
|
|
asset_id = resp.get("data", resp).get("id")
|
|
print(f" spree upload: HTTP {status} asset={asset_id} position={position}")
|
|
|
|
# Re-read rather than trusting the response. A 201 is not proof the asset is
|
|
# attached and serving.
|
|
after = spree_get(f"/products/{pid}/media").get("data", [])
|
|
ids = [m.get("id") for m in after]
|
|
if asset_id and asset_id in ids:
|
|
url = next((m.get("original_url") or m.get("url") for m in after if m.get("id") == asset_id), None)
|
|
print(f"\ncheck ok: media {len(before)} -> {len(after)}, asset present")
|
|
if url:
|
|
print(f"url: {url}")
|
|
else:
|
|
print(f"\nCHECK FAILED: asset {asset_id} not in product media after upload", file=sys.stderr)
|
|
print(f" media ids: {ids}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|