← back to Hollywood Price 2026

quote_only.py

96 lines

#!/usr/bin/env python3
"""Quote-only the remaining unpriceable Hollywood products (Steve 2026-07-12).
After pricing (856) + archiving (267), the leftover ACTIVE + Needs-Price Hollywood
set is the ~246 unmatched + ~12 no-sku — no resolvable Momentum/Versa price
(Versa commercial publishes none). Mark them quote-only: ADD `quotes` tag, REMOVE
`Needs-Price`. Keeps active + Sample-only, no fabricated price. Reversible. $0."""
import sys
import os
import csv
import datetime
import time
sys.path.insert(0, "lib")
from shopify import gql

LEDGER = os.path.join("ledger", "quote_only.csv")
M_ADD = """mutation($id:ID!,$tags:[String!]!){ tagsAdd(id:$id, tags:$tags){ userErrors{ message } } }"""
M_RM = """mutation($id:ID!,$tags:[String!]!){ tagsRemove(id:$id, tags:$tags){ userErrors{ message } } }"""
Q = """query($q:String!,$after:String){ products(first:150, after:$after, query:$q){
  pageInfo{hasNextPage endCursor}
  edges{ node{ id legacyResourceId title tags
    variants(first:5){ edges{ node{ sku title } } } } } } }"""
SEARCH = "vendor:'Hollywood Wallcoverings' status:active tag:Needs-Price"


def has_sellable(n):
    for e in n["variants"]["edges"]:
        v = e["node"]; sk = (v["sku"] or "")
        if v["title"] != "Sample" and not sk.lower().endswith("-sample"):
            return True
    return False


def log(pid, title, status):
    new = not (os.path.exists(LEDGER) and os.path.getsize(LEDGER) > 0)
    with open(LEDGER, "a", newline="") as f:
        w = csv.writer(f)
        if new:
            w.writerow(["ts", "product_id", "title", "status"])
        w.writerow([datetime.datetime.now().isoformat(timespec="seconds"), pid, title, status])


def seen():
    s = set()
    if os.path.exists(LEDGER):
        for r in csv.DictReader(open(LEDGER)):
            if r["status"].startswith("OK") and r.get("product_id"):
                s.add(r["product_id"])
    return s


def fetch_all():
    out, cur = [], None
    while True:
        d = gql(Q, {"q": SEARCH, "after": cur})
        p = d["products"]; out += [e["node"] for e in p["edges"]]
        if not p["pageInfo"]["hasNextPage"]:
            break
        cur = p["pageInfo"]["endCursor"]
    return out


def process(n):
    pid = str(n["legacyResourceId"]); gid = n["id"]
    # safety: never quote-only a product that actually has a real sellable variant
    if has_sellable(n):
        log(pid, n["title"], "SKIP:has-sellable"); return "SKIP:has-sellable"
    if "quotes" in n["tags"] and "Needs-Price" not in n["tags"]:
        log(pid, n["title"], "SKIP:already-quote-only"); return "SKIP:already"
    a = gql(M_ADD, {"id": gid, "tags": ["quotes"]})["tagsAdd"]["userErrors"]
    r = gql(M_RM, {"id": gid, "tags": ["Needs-Price"]})["tagsRemove"]["userErrors"]
    if a or r:
        log(pid, n["title"], "FAIL:" + str(a or r)[:80]); return "FAIL"
    log(pid, n["title"], "OK"); return "OK"


if __name__ == "__main__":
    nodes = fetch_all()
    print(f"remaining ACTIVE Needs-Price Hollywood: {len(nodes)}")
    if "--canary" in sys.argv:
        k = int(sys.argv[sys.argv.index("--canary") + 1])
        for n in nodes[:k]:
            print(f"  {n['title'][:44]:44} -> {process(n)}")
        sys.exit(0)
    done = seen()
    todo = [n for n in nodes if str(n["legacyResourceId"]) not in done]
    ok = sk = fl = 0
    for i, n in enumerate(todo):
        st = process(n)
        if st == "OK": ok += 1
        elif st.startswith("FAIL"): fl += 1
        else: sk += 1
        if (i + 1) % 40 == 0:
            print(f"  ...{i+1}/{len(todo)} ok={ok} skip={sk} fail={fl}")
        time.sleep(0.1)
    print(f"QUOTE-ONLY DONE: ok={ok} skip={sk} fail={fl}")