← back to Dw Wallpaper Cleanup

job1_tags.py

129 lines

#!/usr/bin/env python3
"""JOB 1 — fix banned 'wallpaper' word in TAGS of 238 active DW products.
Whole-word, case-preserving: wallpaper->wallcovering, wallpapers->wallcoverings.
PG-first mirror to dw_unified.shopify_products, then Shopify tagsAdd/tagsRemove.
ACTIVE products only. Tag/vendor edits create NO variants.
"""
import json, re, sys, time, urllib.request, subprocess

TOKEN = subprocess.check_output(
    "grep -E '^SHOPIFY_ADMIN_TOKEN=' ~/Projects/secrets-manager/.env | cut -d= -f2",
    shell=True, text=True).strip()
DOMAIN = "designer-laboratory-sandbox.myshopify.com"
VER = "2024-10"
URL = f"https://{DOMAIN}/admin/api/{VER}/graphql.json"

# whole-word, case-preserving replacement
def fix_tag(t):
    def f(m):
        s = m.group(0)
        rep = 'wallcoverings' if s.lower().endswith('s') else 'wallcovering'
        if s[0].isupper():
            rep = rep.capitalize()
        return rep
    return re.sub(r'[Ww]allpapers?', f, t)

def gql(query, variables=None):
    body = {"query": query}
    if variables is not None:
        body["variables"] = variables
    req = urllib.request.Request(URL, data=json.dumps(body).encode(),
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
    for attempt in range(6):
        try:
            d = json.load(urllib.request.urlopen(req, timeout=60))
            # throttle backoff
            cost = d.get("extensions", {}).get("cost", {}).get("throttleStatus", {})
            if cost.get("currentlyAvailable", 1000) < 200:
                time.sleep(2)
            return d
        except urllib.error.HTTPError as e:
            if e.code == 429:
                time.sleep(3 * (attempt + 1)); continue
            time.sleep(2 * (attempt + 1))
        except Exception:
            time.sleep(2 * (attempt + 1))
    raise RuntimeError("gql failed")

TAGS_ADD = """mutation($id:ID!,$tags:[String!]!){ tagsAdd(id:$id, tags:$tags){ userErrors{field message} } }"""
TAGS_REMOVE = """mutation($id:ID!,$tags:[String!]!){ tagsRemove(id:$id, tags:$tags){ userErrors{field message} } }"""

def fetch_product(handle):
    q = '{ products(first:1, query:"handle:%s") { edges { node { id handle status tags } } } }' % handle
    edges = gql(q)["data"]["products"]["edges"]
    return edges[0]["node"] if edges else None

def gid_to_numeric(gid):
    return gid.rsplit("/", 1)[-1]

def mirror_pg(shopify_numeric_id, new_tags_list):
    new_tags_str = ", ".join(new_tags_list)
    # match either bare numeric id or gid form stored in shopify_id
    sql = """UPDATE shopify_products SET tags=%s
             WHERE shopify_id=%s OR shopify_id=%s;"""
    gid = f"gid://shopify/Product/{shopify_numeric_id}"
    subprocess.run(
        ["psql", "-d", "dw_unified", "-v", "ON_ERROR_STOP=1", "-c",
         "UPDATE shopify_products SET tags = $$%s$$ WHERE shopify_id=$$%s$$ OR shopify_id=$$%s$$;"
         % (new_tags_str.replace("$$",""), str(shopify_numeric_id), gid)],
        check=True, capture_output=True, text=True)

def process(handles, apply=False, gap=0.6):
    results = []
    for i, h in enumerate(handles):
        node = fetch_product(h)
        if not node:
            results.append({"handle": h, "status": "NOT_FOUND"}); continue
        if node["status"] != "ACTIVE":
            results.append({"handle": h, "status": "SKIP_NOT_ACTIVE"}); continue
        old_tags = node["tags"]
        banned = [t for t in old_tags if re.search(r'[Ww]allpapers?', t)]
        if not banned:
            results.append({"handle": h, "status": "NO_BANNED"}); continue
        new_for_banned = [fix_tag(t) for t in banned]
        # final tag set: remove banned, add fixed (dedup, preserve order)
        final = [t for t in old_tags if t not in banned]
        for nt in new_for_banned:
            if nt not in final:
                final.append(nt)
        rec = {"handle": h, "id": node["id"], "removed": banned,
               "added": new_for_banned, "final": final, "status": "PLANNED"}
        if apply:
            num = gid_to_numeric(node["id"])
            # PG FIRST
            mirror_pg(num, final)
            # add fixed tags
            ra = gql(TAGS_ADD, {"id": node["id"], "tags": new_for_banned})
            ea = ra["data"]["tagsAdd"]["userErrors"]
            # remove banned tags
            rr = gql(TAGS_REMOVE, {"id": node["id"], "tags": banned})
            er = rr["data"]["tagsRemove"]["userErrors"]
            rec["status"] = "APPLIED" if not ea and not er else "ERROR"
            if ea or er:
                rec["errors"] = {"add": ea, "remove": er}
            time.sleep(gap)
        results.append(rec)
        if (i + 1) % 25 == 0:
            sys.stderr.write(f"  ...{i+1}/{len(handles)}\n")
    return results

if __name__ == "__main__":
    mode = sys.argv[1] if len(sys.argv) > 1 else "plan"
    src = json.load(open("/tmp/wp_tag_hits.json"))
    handles = [r["handle"] for r in src]
    if mode == "canary":
        n = int(sys.argv[2]) if len(sys.argv) > 2 else 3
        res = process(handles[:n], apply=True)
    elif mode == "apply":
        res = process(handles, apply=True)
    elif mode == "apply-rest":
        skip = int(sys.argv[2])
        res = process(handles[skip:], apply=True)
    else:
        res = process(handles, apply=False)
    out = f"/tmp/job1_{mode}.json"
    json.dump(res, open(out, "w"), indent=2)
    from collections import Counter
    print("STATUS:", dict(Counter(r["status"] for r in res)))
    print("AUDIT:", out)