← back to Dw Material Envelope Canary

check.py

98 lines

#!/usr/bin/env python3
"""dw-material-envelope-canary — READ-ONLY recurrence monitor.

Scans the live DW Shopify catalog for the double-encoded metafield bug where a
value leaked its raw JSON wrapper, e.g. {"type": "single_line_text_field",
"value": "Paper"} — instead of the plain value. Born 2026-07-07 after a Python
importer double-encoded custom.material + dwc.contents on 571 products.

Alerts (CNCP parking-lot card) ONLY on FAIL (>0 envelopes found). PASS is silent.
Always writes data/latest.json so the canary meta-watchdog sees it ran.
Touches nothing — pure GraphQL reads."""
import os, json, sys, time, urllib.request, datetime

HERE = os.path.dirname(os.path.abspath(__file__))
def load_env():
    for p in [os.path.expanduser("~/Projects/Designer-Wallcoverings/.env"),
              os.path.join(HERE, ".env")]:
        if os.path.exists(p):
            for ln in open(p):
                if "=" in ln and not ln.strip().startswith("#"):
                    k, v = ln.strip().split("=", 1)
                    os.environ.setdefault(k, v.strip().strip('"').strip("'"))
load_env()
DOMAIN = os.environ.get("SHOPIFY_STORE_DOMAIN", "designer-laboratory-sandbox.myshopify.com")
TOK = os.environ.get("SHOPIFY_ADMIN_TOKEN") or os.environ.get("SHOPIFY_ADMIN_ACCESS_TOKEN")
if not TOK:
    sys.exit("ERROR: SHOPIFY_ADMIN_TOKEN not set (check ~/Projects/Designer-Wallcoverings/.env)")
URL = f"https://{DOMAIN}/admin/api/2024-01/graphql.json"
CNCP = "http://127.0.0.1:3333/api/parking_lot"

# Daily FAST pass: the 3 keys the bug actually hit (cheap GraphQL, quick signal).
# The WEEKLY check-bulk.py covers ALL metafields exhaustively (the durable net).
CHECK_KEYS = [("custom", "material"), ("dwc", "contents"), ("global", "Material")]
FIELDS = " ".join(f'k{i}: metafield(namespace:"{ns}", key:"{k}"){{ value }}'
                  for i, (ns, k) in enumerate(CHECK_KEYS))
QUERY = "query($c:String){ products(first:200, after:$c){ pageInfo{ hasNextPage endCursor } edges{ node{ handle status " + FIELDS + " } } } }"

def gql(cursor):
    body = json.dumps({"query": QUERY, "variables": {"c": cursor}}).encode()
    req = urllib.request.Request(URL, data=body, headers={
        "X-Shopify-Access-Token": TOK, "Content-Type": "application/json"})
    for attempt in range(6):
        try:
            with urllib.request.urlopen(req, timeout=60) as r:
                d = json.loads(r.read())
            if d.get("errors") and any("throttl" in str(e).lower() for e in d["errors"]):
                time.sleep(2 + attempt); continue
            return d
        except Exception:
            time.sleep(2 + attempt)
    raise SystemExit("gql failed")

def is_env(v):
    # robust: the metafield TYPE NAME never legitimately appears in a spec value,
    # so any value containing it is a leaked envelope — catches scalar, list,
    # and double-escaped (\"single_line_text_field\") variants alike.
    return isinstance(v, str) and "single_line_text_field" in v

def main():
    os.makedirs(os.path.join(HERE, "data"), exist_ok=True)
    total = 0; bad = []
    cursor = None
    while True:
        conn = gql(cursor)["data"]["products"]
        for e in conn["edges"]:
            n = e["node"]; total += 1
            for i, (ns, k) in enumerate(CHECK_KEYS):
                mf = n.get(f"k{i}")
                if mf and is_env(mf.get("value")):
                    bad.append({"handle": n["handle"], "status": n["status"], "field": f"{ns}.{k}"})
                    break
        if not conn["pageInfo"]["hasNextPage"]:
            break
        cursor = conn["pageInfo"]["endCursor"]
    verdict = "FAIL" if bad else "PASS"
    result = {"ts": datetime.datetime.now().isoformat(), "scanned": total,
              "broken": len(bad), "verdict": verdict, "sample": bad[:20]}
    with open(os.path.join(HERE, "data", "latest.json"), "w") as fh:
        json.dump(result, fh, indent=1)
    print(f"{verdict}: scanned {total}, {len(bad)} envelope-corrupted")
    if bad:
        try:
            active = sum(1 for b in bad if b["status"] == "ACTIVE")
            card = {"title": f"⚠️ Material envelope regression: {len(bad)} products ({active} ACTIVE)",
                    "note": f"dw-material-envelope-canary found metafield values leaking raw JSON wrappers again. "
                            f"e.g. {', '.join(b['handle'] for b in bad[:5])}. Re-run /tmp/fix_material.py or check the importer.",
                    "source": "dw-material-envelope-canary"}
            urllib.request.urlopen(urllib.request.Request(
                CNCP, data=json.dumps(card).encode(),
                headers={"Content-Type": "application/json"}), timeout=5)
            print("  posted CNCP alert")
        except Exception as ex:
            print("  CNCP post failed:", ex)
    return 1 if bad else 0

if __name__ == "__main__":
    sys.exit(main())