← back to Reid Witlin Onboarding

publish_all_channels.py

126 lines

#!/usr/bin/env python3
"""
Reid Witlin / Architectural Fabrics — publish the 1,005 products to ALL
sales channels except "Fabricut" (a documented vendor-dedicated channel,
TK-10762: "published via its OWN full-pattern daily poster, never the
shared 1-colorway rotation" — publishing another vendor's line there would
be a mismatch, not what Steve wants).

Idempotent: publishablePublish is safe to call again for channels a
product is already published to (no duplicate side effects).

SAFETY: DRY_RUN=1 default. Reads the live product set fresh via GraphQL.
"""
import os, json, time, urllib.request

DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
LIMIT = int(os.environ.get("LIMIT", "0")) or None

def _tok():
    p = os.path.expanduser("~/Projects/secrets-manager/.env")
    for line in open(p):
        if line.startswith("SHOPIFY_ADMIN_TOKEN="):
            return line.split("=", 1)[1].strip().strip('"')
    raise SystemExit("SHOPIFY_ADMIN_TOKEN not found")

TOKEN = os.environ.get("AT") or _tok()
URL = "https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json"

def gql(q, v=None):
    body = json.dumps({"query": q, "variables": v or {}}).encode()
    req = urllib.request.Request(URL, body,
        {"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
    for a in range(8):
        try:
            d = json.load(urllib.request.urlopen(req, timeout=90))
            if "errors" in d and any("THROTTLED" in str(e) for e in d["errors"]):
                time.sleep(2 * (a + 1)); continue
            return d
        except Exception:
            time.sleep(2 * (a + 1))
    raise RuntimeError("gql failed")

# All 13 minus "Fabricut" (vendor-dedicated, see docstring).
CHANNELS = {
    "Online Store": "gid://shopify/Publication/22208643184",
    "Buy Button": "gid://shopify/Publication/22497296496",
    "Google & YouTube": "gid://shopify/Publication/29646651457",
    "Facebook & Instagram": "gid://shopify/Publication/29739483201",
    "Houzz": "gid://shopify/Publication/29776969793",
    "Point of Sale": "gid://shopify/Publication/37904089153",
    "Pinterest": "gid://shopify/Publication/44234276915",
    "Rakuten Ichiba (JP)": "gid://shopify/Publication/44317474867",
    "Shop": "gid://shopify/Publication/44317507635",
    "Inbox": "gid://shopify/Publication/71898464307",
    "TikTok": "gid://shopify/Publication/115856375859",
    "DWAutoPostBlog": "gid://shopify/Publication/140027723827",
}

LIST_Q = """
query($cursor: String) {
  products(first: 250, after: $cursor, query: "vendor:'Architectural Fabrics' status:active") {
    pageInfo { hasNextPage endCursor }
    nodes {
      id handle
      resourcePublications(first: 20) { nodes { publication { id } } }
    }
  }
}"""

PUBLISH_M = """
mutation($id: ID!, $input: [PublicationInput!]!) {
  publishablePublish(id: $id, input: $input) {
    userErrors { field message }
  }
}"""

def fetch_all():
    out, cursor = [], None
    while True:
        d = gql(LIST_Q, {"cursor": cursor})
        data = d.get("data", {}).get("products", {})
        out.extend(data.get("nodes", []))
        pi = data.get("pageInfo", {})
        if not pi.get("hasNextPage"):
            return out
        cursor = pi.get("endCursor")

def main():
    products = fetch_all()
    if LIMIT:
        products = products[:LIMIT]
    print(f"{'DRY-RUN' if DRY_RUN else 'LIVE'}: {len(products)} products, "
          f"target channels: {', '.join(CHANNELS)}")
    published, errs, skipped_already_full = 0, [], 0
    for i, p in enumerate(products):
        already = {n["publication"]["id"] for n in p["resourcePublications"]["nodes"]}
        missing = [{"publicationId": pid} for name, pid in CHANNELS.items() if pid not in already]
        if not missing:
            skipped_already_full += 1
            continue
        if DRY_RUN:
            if i < 3:
                print(f"  would publish {p['handle']} to {len(missing)} missing channel(s)")
            published += 1
            continue
        d = gql(PUBLISH_M, {"id": p["id"], "input": missing})
        r = (d.get("data") or {}).get("publishablePublish") or {}
        ue = r.get("userErrors") or []
        if ue:
            errs.append({"id": p["id"], "handle": p["handle"], "errors": ue[:3]})
        else:
            published += 1
        if i % 50 == 0:
            print(f"  ...{i}/{len(products)} published={published} errs={len(errs)}")
        time.sleep(0.25)
    out = {"published": published, "already_full": skipped_already_full,
           "errors": errs[:30], "total": len(products)}
    json.dump(out, open("publish-all-channels-results.json", "w"), indent=2)
    print(f"\nDONE. published={published} already_full={skipped_already_full} "
          f"userErrors={len(errs)} ({'DRY-RUN — nothing written' if DRY_RUN else 'live'})")
    if errs:
        print("sample errors:", json.dumps(errs[:5]))

if __name__ == "__main__":
    main()