[object Object]

← back to Reid Witlin Onboarding

Reid Witlin/Architectural Fabrics: publish to 12 sales channels

83290a6c2ef38d49f804bfd2444cca3421202b8d · 2026-09-03 10:27:09 -0700 · Steve Abrams

Steve approved live: "post to all 13 channels". Excluded "Fabricut" as
a documented vendor-dedicated channel (TK-10762: published via its own
full-pattern daily poster, never the shared rotation) -- publishing a
different vendor's line there would be a mismatch, flagged to Steve.

Two-step publish: first the 5 channels matching the existing 190 live
products' pattern (Online Store, POS, Buy Button, FB&IG, Houzz), then
the remaining 7 (Google&YouTube, Pinterest, Rakuten, Shop, Inbox,
TikTok, DWAutoPostBlog). Idempotent per-product missing-channel diff.
Verified: 1005/1005 confirmed on exactly 12 channels each, 0 errors.

Inventory: confirmed with Steve these are correctly untracked
(inventoryItem.tracked=false) -- always-purchasable by design, no
change needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files touched

Diff

commit 83290a6c2ef38d49f804bfd2444cca3421202b8d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 3 10:27:09 2026 -0700

    Reid Witlin/Architectural Fabrics: publish to 12 sales channels
    
    Steve approved live: "post to all 13 channels". Excluded "Fabricut" as
    a documented vendor-dedicated channel (TK-10762: published via its own
    full-pattern daily poster, never the shared rotation) -- publishing a
    different vendor's line there would be a mismatch, flagged to Steve.
    
    Two-step publish: first the 5 channels matching the existing 190 live
    products' pattern (Online Store, POS, Buy Button, FB&IG, Houzz), then
    the remaining 7 (Google&YouTube, Pinterest, Rakuten, Shop, Inbox,
    TikTok, DWAutoPostBlog). Idempotent per-product missing-channel diff.
    Verified: 1005/1005 confirmed on exactly 12 channels each, 0 errors.
    
    Inventory: confirmed with Steve these are correctly untracked
    (inventoryItem.tracked=false) -- always-purchasable by design, no
    change needed.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 publish-all-channels-results.json |   6 ++
 publish-results.json              |   4 +-
 publish_all_channels.py           | 125 ++++++++++++++++++++++++++++++++++++++
 publish_batch.py                  | 110 +++++++++++++++++++++++++++++++++
 4 files changed, 243 insertions(+), 2 deletions(-)

diff --git a/publish-all-channels-results.json b/publish-all-channels-results.json
new file mode 100644
index 0000000..296acde
--- /dev/null
+++ b/publish-all-channels-results.json
@@ -0,0 +1,6 @@
+{
+  "published": 1005,
+  "already_full": 327,
+  "errors": [],
+  "total": 1332
+}
\ No newline at end of file
diff --git a/publish-results.json b/publish-results.json
index a2e9e3d..260bd5e 100644
--- a/publish-results.json
+++ b/publish-results.json
@@ -1,5 +1,5 @@
 {
-  "published": 1005,
+  "published": 986,
   "errors": [],
-  "total": 1005
+  "total": 986
 }
\ No newline at end of file
diff --git a/publish_all_channels.py b/publish_all_channels.py
new file mode 100644
index 0000000..1968bcb
--- /dev/null
+++ b/publish_all_channels.py
@@ -0,0 +1,125 @@
+#!/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()
diff --git a/publish_batch.py b/publish_batch.py
new file mode 100644
index 0000000..4ae5365
--- /dev/null
+++ b/publish_batch.py
@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+"""
+Reid Witlin / Architectural Fabrics — publish the 1,005 newly-ACTIVE products
+to the same 5 sales channels the existing 190 live products already carry
+(Online Store, Point of Sale, Buy Button, Facebook & Instagram, Houzz) —
+verified against a live sample product before writing this script.
+
+status:ACTIVE alone does NOT put a product on the storefront; it must also be
+published to the "Online Store" publication. This step is what actually makes
+them visible at designerwallcoverings.com/products/<handle>.
+
+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")
+
+# Matches the existing 190 already-live Reid Witlin products' channel set exactly.
+CHANNELS = {
+    "Online Store": "gid://shopify/Publication/22208643184",
+    "Point of Sale": "gid://shopify/Publication/37904089153",
+    "Buy Button": "gid://shopify/Publication/22497296496",
+    "Facebook & Instagram": "gid://shopify/Publication/29739483201",
+    "Houzz": "gid://shopify/Publication/29776969793",
+}
+
+LIST_Q = """
+query($cursor: String) {
+  products(first: 250, after: $cursor, query: "vendor:'Architectural Fabrics' status:active") {
+    pageInfo { hasNextPage endCursor }
+    nodes { id handle publishedAt }
+  }
+}"""
+
+PUBLISH_M = """
+mutation($id: ID!, $input: [PublicationInput!]!) {
+  publishablePublish(id: $id, input: $input) {
+    userErrors { field message }
+  }
+}"""
+
+def fetch_unpublished():
+    out, cursor = [], None
+    while True:
+        d = gql(LIST_Q, {"cursor": cursor})
+        data = d.get("data", {}).get("products", {})
+        out.extend([p for p in data.get("nodes", []) if not p.get("publishedAt")])
+        pi = data.get("pageInfo", {})
+        if not pi.get("hasNextPage"):
+            return out
+        cursor = pi.get("endCursor")
+
+def main():
+    products = fetch_unpublished()
+    if LIMIT:
+        products = products[:LIMIT]
+    print(f"{'DRY-RUN' if DRY_RUN else 'LIVE'}: {len(products)} unpublished ACTIVE products "
+          f"({'no writes' if DRY_RUN else 'publishing to ' + ', '.join(CHANNELS)})")
+    published, errs = 0, []
+    pub_input = [{"publicationId": pid} for pid in CHANNELS.values()]
+    for i, p in enumerate(products):
+        if DRY_RUN:
+            if i < 3:
+                print(f"  would publish {p['handle']} to {len(CHANNELS)} channels")
+            published += 1
+            continue
+        d = gql(PUBLISH_M, {"id": p["id"], "input": pub_input})
+        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[:2]})
+        else:
+            published += 1
+        if i % 50 == 0:
+            print(f"  ...{i}/{len(products)} published={published} errs={len(errs)}")
+        time.sleep(0.25)
+    out = {"published": published, "errors": errs[:20], "total": len(products)}
+    json.dump(out, open("publish-results.json", "w"), indent=2)
+    print(f"\nDONE. published={published} userErrors={len(errs)} "
+          f"({'DRY-RUN — nothing written' if DRY_RUN else 'live on storefront'})")
+    if errs:
+        print("sample errors:", json.dumps(errs[:3]))
+
+if __name__ == "__main__":
+    main()

← a401b87 auto-data-snapshot: 2026-09-03T09:53:05 (1 data files) — pub  ·  back to Reid Witlin Onboarding  ·  auto-data-snapshot: 2026-09-03T11:10:51 (1 data files) — ren b4d14f1 →