← back to Dw Photo Capture

build_vendor_logos.py

125 lines

#!/usr/bin/env python3
"""Harvest vendor brand LOGOS from Shopify Files and attach them to the learned vendor
profiles, so the scanner can ground logo/typeface recognition against real reference marks
(instead of the VLM guessing a brand cold). The DW store's brand-logo app uploaded these as
Files named like 'maxwell-logo-black.png' — we match each to one of our known vendors.

Reads vendors from data/vendor_profiles.json, pages Shopify Files (query 'logo'), fuzzy-
matches filename -> vendor, downloads the match into data/logos/<slug>.<ext>, and writes
data/vendor_logos.json (vendor -> {url,file,w,h}). Also stamps profiles[vendor].logo_url so
/learn and /api/identify can use it. Read-only Shopify + local download. $0."""
import json, os, re, urllib.request
from datetime import datetime, timezone

SHOP = "designer-laboratory-sandbox.myshopify.com"; API = "2024-10"
ROOT = os.path.dirname(os.path.abspath(__file__))
LOGO_DIR = os.path.join(ROOT, "data", "logos"); os.makedirs(LOGO_DIR, exist_ok=True)
PROF = os.path.join(ROOT, "data", "vendor_profiles.json")
OUT = os.path.join(ROOT, "data", "vendor_logos.json")
# Files need read_files — only the DRAFT token has it (admin token does not).
T = ""
for l in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
    if l.startswith("SHOPIFY_DRAFT_TOKEN="): T = l.strip().split("=", 1)[1]; break
assert T, "no SHOPIFY_DRAFT_TOKEN (needs read_files)"

def gql(q, v=None):
    r = urllib.request.Request(f"https://{SHOP}/admin/api/{API}/graphql.json",
        data=json.dumps({"query": q, "variables": v or {}}).encode(),
        headers={"X-Shopify-Access-Token": T, "Content-Type": "application/json"})
    return json.load(urllib.request.urlopen(r, timeout=60))

def slug(s): return re.sub(r"[^a-z0-9]", "", (s or "").lower())

# vendor -> set of match keys (full slug + distinctive leading words)
prof = json.load(open(PROF))
vendors = list(prof.get("profiles", {}).keys())
STOP = {"fabrics", "fabric", "wallcoverings", "wallcovering", "wallpaper", "home", "and",
        "the", "co", "inc", "design", "designs", "international", "studio", "textiles"}
vkeys = {}
for v in vendors:
    words = [w for w in re.findall(r"[a-z0-9]+", v.lower())]
    keys = {slug(v)}
    sig = [w for w in words if w not in STOP and len(w) >= 4]
    if sig: keys.add(sig[0])                       # distinctive first word (maxwell, brunschwig)
    if len(sig) >= 2: keys.add(sig[0] + sig[1])
    vkeys[v] = {k for k in keys if len(k) >= 4}

# ── page Shopify Files for logo images ────────────────────────────────────────
def clean_fileslug(url, alt):
    base = (url.split("/")[-1].split("?")[0])
    base = re.sub(r"\.(png|jpg|jpeg|svg|webp|gif)$", "", base, flags=re.I)
    base = re.sub(r"[-_]?(logo|nav|black|white|transparent|background|backgro|header|footer|trade|web|small|large|\d+)$", "", base, flags=re.I)
    return slug(base)

files = []
cursor = None
for _ in range(40):
    q = '''query($c:String){ files(first:50, after:$c, query:"logo"){
      pageInfo{ hasNextPage endCursor }
      edges{ node{ __typename ... on MediaImage{ alt image{ url width height } } } } } }'''
    d = gql(q, {"c": cursor}).get("data", {}).get("files", {})
    for e in d.get("edges", []):
        n = e.get("node") or {}
        img = n.get("image") or {}
        if img.get("url"): files.append({"url": img["url"], "alt": n.get("alt") or "",
                                         "w": img.get("width"), "h": img.get("height")})
    if not d.get("pageInfo", {}).get("hasNextPage"): break
    cursor = d["pageInfo"]["endCursor"]
print(f"scanned {len(files)} logo-ish files")

# ── match files -> vendors (TIGHTENED: must be a logo file + anchored key match) ─
def keymatch(k, fslug):
    if k == fslug: return 1.0                       # exact
    if fslug.startswith(k) and len(k) >= 5: return 0.9   # file leads with the key
    if k.startswith(fslug) and len(fslug) >= 5: return 0.85
    if len(k) >= 7 and k in fslug: return 0.7       # long distinctive token contained
    return 0.0
MIN_CONF = 0.7
matches, rejected = {}, []
for f in files:
    # HARD requirement: the file must actually be a brand logo (name/alt says so).
    if not ("logo" in f["url"].lower() or "logo" in (f["alt"] or "").lower()):
        continue
    fslug = clean_fileslug(f["url"], f["alt"])
    if not fslug or len(fslug) < 4: continue
    best = None   # (conf, vendor, key)
    for v, keys in vkeys.items():
        for k in keys:
            c = keymatch(k, fslug)
            if c and (best is None or c > best[0] or (c == best[0] and len(k) > len(best[2]))):
                best = (c, v, k)
    if not best or best[0] < MIN_CONF:
        if best: rejected.append((fslug, best[1], round(best[0], 2)))
        continue
    c, v, k = best
    if v not in matches or c > matches[v]["conf"]:
        matches[v] = {**f, "conf": round(c, 2), "key": k, "fslug": fslug}
if rejected:
    print(f"  ({len(rejected)} below-threshold pairs skipped, e.g. {rejected[:4]})")

print(f"matched {len(matches)} vendors to a logo file")

# ── download + write manifest + stamp profiles ────────────────────────────────
manifest = {}
for v, m in sorted(matches.items()):
    ext = (re.search(r"\.(png|jpg|jpeg|svg|webp|gif)", m["url"], re.I) or [".png"])[0].lower().lstrip(".")
    if ext == "jpeg": ext = "jpg"
    fn = slug(v) + "." + ext
    try:
        data = urllib.request.urlopen(m["url"], timeout=40).read()
        open(os.path.join(LOGO_DIR, fn), "wb").write(data)
        manifest[v] = {"file": "data/logos/" + fn, "url": m["url"], "w": m["w"], "h": m["h"],
                       "matched_on": m["key"], "conf": m["conf"], "bytes": len(data)}
        if v in prof["profiles"]: prof["profiles"][v]["logo_url"] = m["url"]; prof["profiles"][v]["logo_file"] = "data/logos/" + fn
    except Exception as e:
        print(f"  ! {v}: download failed {e}")

json.dump({"_meta": {"vendors_with_logo": len(manifest), "scanned_files": len(files),
                     "built_at": datetime.now(timezone.utc).isoformat()}, "logos": manifest},
          open(OUT, "w"), indent=0)
prof.setdefault("_meta", {})["logos_attached"] = len(manifest)
json.dump(prof, open(PROF, "w"), indent=0)
print(f"\ndownloaded {len(manifest)} vendor logos -> {LOGO_DIR}")
for v in list(manifest)[:14]:
    print(f"  {v:28} <- {manifest[v]['matched_on']:14} {manifest[v]['w']}x{manifest[v]['h']}")