← back to Kravet Sheet Sync 2026 04 20

audit_last_30.py

207 lines

#!/usr/bin/env python3
"""
Click-through audit of the last 30 Kravet products pushed to Shopify.

For each product, checks everything a human would see clicking through admin:
  - Title format (Pattern Color | Brand)
  - Status (ACTIVE / DRAFT / ARCHIVED)
  - Images (count, storefront URL, size > placeholder)
  - Variants: main DWKK-XXX + DWKK-XXX-Sample, price, cost
  - Metafields: manufacturer_sku (custom + dwc), color, width, compliance
  - SEO title & description
  - descriptionHtml length
  - Tags (incl. New Arrival)
  - Collection membership (New Arrivals)

Emits a pass/fail summary + detailed issue list per product.
"""
import json, subprocess, urllib.request, io, csv, sys

TOKEN=os.environ.get('SHOPIFY_ADMIN_TOKEN','')
URL='https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json'
SSH=['ssh','root@45.61.58.125']
NEW_ARRIVALS_GID='gid://shopify/Collection/167327760435'


def pg(sql):
    r = subprocess.run(SSH + [
        f'PGPASSWORD=DW2024! psql -h 127.0.0.1 -U dw_admin -d dw_unified --csv -t -c "{sql}"'],
        capture_output=True, text=True, check=True)
    return [row for row in csv.reader(io.StringIO(r.stdout)) if row]


def gql(q, v=None):
    body=json.dumps({"query":q,"variables":v or {}}).encode()
    req=urllib.request.Request(URL,data=body,method="POST",
        headers={"Content-Type":"application/json","X-Shopify-Access-Token":TOKEN})
    d=json.loads(urllib.request.urlopen(req,timeout=30).read())
    if "errors" in d: raise RuntimeError(d["errors"])
    return d["data"]


# Last 30 Kravet products I pushed
rows = pg("""
  SELECT dw_sku, shopify_product_id, mfr_sku, pattern_name, color_name, brand, imported_at::text
    FROM kravet_catalog
   WHERE dw_sku LIKE 'DWKK-%'
     AND imported_at IS NOT NULL
     AND imported_at >= now() - INTERVAL '2 days'
     AND shopify_product_id LIKE 'gid://%'
   ORDER BY imported_at DESC
   LIMIT 30
""")
if len(rows) < 30:
    print(f"note: only {len(rows)} rows with imported_at in last 2 days")

print(f"\nauditing {len(rows)} products ...\n")

Q="""query($id:ID!){
  product(id:$id){
    id title handle status vendor productType tags createdAt
    descriptionHtml
    seo{ title description }
    images(first:10){ nodes{ url width height } }
    media(first:10){ nodes{ status mediaContentType } }
    variants(first:10){ nodes{
      id sku title price
      inventoryItem{ unitCost{ amount } tracked }
    }}
    metafields(first:80){ nodes{ namespace key value } }
    collections(first:50){ nodes{ id title handle } }
  }
}"""

# Accept EITHER pre-Full-Monty keys (global.*) OR post-Full-Monty normalized keys
# (custom.*, specs.*). Full Monty's onboard_enrich rewrites under the normalized
# conventions but keeps values intact.
REQ_META_ALTS = {
    "manufacturer_sku":   [("custom","manufacturer_sku"), ("dwc","manufacturer_sku")],
    "width":              [("global","width"), ("custom","width"), ("specs","width")],
    "brand":              [("global","Brand"), ("custom","brand"), ("dwc","brand"), ("specs","brand")],
    "content":            [("global","Content"), ("custom","material"), ("specs","composition")],
    "prop_65":            [("global","Prop-65"), ("custom","prop_65_text")],
    "ca_tb117":           [("global","CA-TB117"), ("custom","ca_tb117_text")],
    "collection":         [("global","Collection"), ("custom","collection_name"), ("custom","collection_text"), ("specs","collection")],
}

def check(row):
    dw_sku, pid, mfr, pat, col, brn, imported = row
    p = gql(Q, {"id": pid})["product"]
    if not p:
        return {"dw_sku": dw_sku, "status": "NOT_FOUND", "issues": ["product missing on Shopify"]}

    issues = []

    # Title
    if "|" not in p["title"]:
        issues.append("title missing ' | Brand' suffix")
    if "wallpaper" in p["title"].lower():
        issues.append("title contains banned word 'wallpaper'")

    # Status
    if p["status"] != "ACTIVE":
        issues.append(f"status={p['status']} (expected ACTIVE)")

    # Images
    imgs = p["images"]["nodes"]
    media = p["media"]["nodes"]
    failed_media = [m for m in media if m.get("status") == "FAILED"]
    if len(imgs) == 0:
        issues.append("NO images")
    if failed_media:
        issues.append(f"{len(failed_media)} FAILED media")

    # Variants
    vs = p["variants"]["nodes"]
    main = next((v for v in vs if v["sku"] == dw_sku), None)
    sample = next((v for v in vs if v["sku"] == f"{dw_sku}-Sample"), None)
    if not main:
        issues.append("main variant DWKK-XXX missing")
    else:
        cost = (main["inventoryItem"].get("unitCost") or {}).get("amount")
        if not cost:
            issues.append("main variant has no cost")
        try:
            if float(main["price"]) <= 0: issues.append("main price <= 0")
        except: issues.append("main price unparseable")
    if not sample:
        issues.append("Sample variant missing")
    elif sample["price"] != "4.25":
        issues.append(f"Sample price={sample['price']} (expected 4.25)")

    # Metafields — each required concept can be satisfied by any of its alternative keys
    have = {(m["namespace"], m["key"]) for m in p["metafields"]["nodes"]}
    missing_concepts = []
    for concept, alts in REQ_META_ALTS.items():
        if not any(alt in have for alt in alts):
            missing_concepts.append(concept)
    if missing_concepts:
        issues.append(f"missing concepts: {missing_concepts}")

    # SEO
    if not p["seo"]["title"]:
        issues.append("no SEO title")
    if not p["seo"]["description"]:
        issues.append("no SEO description")

    # Description
    if not p["descriptionHtml"] or len(p["descriptionHtml"]) < 200:
        issues.append(f"thin description ({len(p.get('descriptionHtml') or '')} chars)")

    # Tags
    if "New Arrival" not in p["tags"]:
        issues.append("'New Arrival' tag missing")

    # Collection
    in_new_arrivals = any(c["id"] == NEW_ARRIVALS_GID for c in p["collections"]["nodes"])
    if not in_new_arrivals:
        issues.append("not in New Arrivals collection")

    return {
        "dw_sku": dw_sku,
        "pid_num": pid.split("/")[-1],
        "title": p["title"],
        "status": p["status"],
        "images": len(imgs),
        "variants": len(vs),
        "metafields": len(p["metafields"]["nodes"]),
        "issues": issues,
    }


results = [check(r) for r in rows]

# Summary
total = len(results)
pass_ = sum(1 for r in results if not r["issues"])
fail = total - pass_
print(f"=== AUDIT SUMMARY ===")
print(f"  total:  {total}")
print(f"  pass:   {pass_}")
print(f"  fail:   {fail}")

# Issue histogram
from collections import Counter
hist = Counter()
for r in results:
    for i in r["issues"]:
        # Collapse variable details
        import re
        canon = re.sub(r"=\S+", "=X", re.sub(r"\(\d+ chars\)", "(N chars)", i))
        canon = re.sub(r"'[^']+'", "'X'", canon)
        canon = re.sub(r"\[.*\]", "[...]", canon)
        hist[canon] += 1
print(f"\n=== ISSUE HISTOGRAM ===")
for issue, count in hist.most_common():
    print(f"  {count:3d}  {issue}")

print(f"\n=== PER-PRODUCT ===")
for r in results:
    mark = "✓" if not r["issues"] else "✗"
    print(f"  {mark} {r['dw_sku']:15s} [{r['status']:8s}] {r['images']}img {r['variants']}v {r['metafields']}mf  '{r['title'][:50]}'")
    for i in r["issues"]:
        print(f"       ! {i[:120]}")

json.dump(results, open("audit_last_30.json","w"), indent=2)
print(f"\nwrote audit_last_30.json")