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>
This commit is contained in:
Executable
+218
@@ -0,0 +1,218 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user