← back to Secrets Manager

fix-greenland-titles.py

179 lines

#!/usr/bin/env python3
"""
fix-greenland-titles.py  —  Greenland private-label LEAK + DUPLICATE cleanup
============================================================================

WHY THIS SCRIPT EXISTS
----------------------
The Greenland cork line is sold customer-facing as **Phillipe Romano**; the word
"Greenland" must NEVER appear customer-facing (standing rule).

Investigation (2026-07-21) found **143 live Shopify products** on the LIVE store
(designer-laboratory-sandbox.myshopify.com) titled "... | Greenland"
(vendor=Greenland, old SKUs DWGL-<mfr> / DWGL-100xxx). 7 of them were ACTIVE and
had been (re)published TODAY; 136 were draft.

CRITICAL FINDING — these are NOT unique products that merely need a title fix.
Every one of the 143 is a **STALE DUPLICATE** of an already-LIVE, correctly-titled
canonical twin:  "<City>-<Color> Cork Wallcovering | Phillipe Romano"
(e.g. leaked "Metallic Cork I Indian Tan | Greenland" == live canonical
"Hilton Head-Indian Tan Cork Wallcovering | Phillipe Romano", SKU Cork-500060).
They were created by an old `greenland resume` launchd job that used the
pre-relabel naming. The canonical Phillipe Romano set is already serving customers.

DECISION (DTD panel, unanimous 5/5, 2026-07-21): **ARCHIVE the 143 duplicates.**
Renaming them would only mask the leaked word while leaving 143 duplicate products
competing with the canonical set (SEO cannibalization, split inventory, buyer
confusion). Archiving kills the leak AND the duplication in one move; the canonical
twins already exist and stay live.

WHAT THIS SCRIPT DOES  (idempotent, safe to re-run)
---------------------------------------------------
1. BACKS UP the current title + status of every one of the 143 products to a
   timestamped .tsv (so this is fully reversible) BEFORE any write.
2. For each of the 143 live products (read from the embedded, verified plan):
     - verifies the product still carries "| Greenland" (skips anything already
       cleaned — idempotent),
     - PUTs status=archived + published=false to the Shopify Admin API,
     - records the canonical twin SKU/title it is a duplicate of (audit trail).
3. Updates the LOCAL dw_unified mirror rows for those 143 products to
   status='ARCHIVED' so internal viewers stop showing the leak.

The 66 mirror-ONLY orphan rows (products that were never on / no longer on Shopify)
were already deleted from the mirror during investigation (reversible; backup at
greenland-cleanup-backups/mirror-orphans-*.tsv). This script does NOT touch them.

NOTE: the Koroseal product "Whispering Waves Greenland - Light Blue ..." merely
contains the word "Greenland" as a COLOR name — it is NOT a private-label leak and
is deliberately EXCLUDED from the plan.

RUN IT
------
  DRY RUN (default — prints what it WOULD do, writes nothing):
      python3 fix-greenland-titles.py

  APPLY (archives the 143 on the LIVE store + updates the mirror):
      python3 fix-greenland-titles.py --apply

This is a customer-facing / production Shopify write — run it yourself.
No product is published or activated. Only draft/active DUPLICATES are archived.
"""
import os, sys, json, time, urllib.request, urllib.error, subprocess, datetime, re

SHOP = "designer-laboratory-sandbox.myshopify.com"
API  = "2024-10"
HERE = os.path.dirname(os.path.abspath(__file__))
PLAN_FILE   = os.path.join(HERE, "greenland-cleanup-backups", "archive-plan.json")
BACKUP_DIR  = os.path.join(HERE, "greenland-cleanup-backups")
APPLY = "--apply" in sys.argv

# ---- token: DW-Write-Content app, read by exact name only, never printed ----
def read_token():
    env = os.path.join(HERE, ".env")
    with open(env) as f:
        for line in f:
            if line.startswith("SHOPIFY_CONTENT_TOKEN="):
                return line.split("=", 1)[1].strip().strip('"').strip("'").strip()
    sys.exit("SHOPIFY_CONTENT_TOKEN not found in .env")

TOKEN = read_token()
HDRS  = {"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"}

def api(path, method="GET", body=None):
    url = f"https://{SHOP}/admin/api/{API}{path}"
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, headers=HDRS, method=method)
    for attempt in range(4):
        try:
            with urllib.request.urlopen(req) as r:
                return r.status, json.load(r)
        except urllib.error.HTTPError as e:
            if e.code == 429:                      # rate limit — back off
                time.sleep(2 * (attempt + 1)); continue
            return e.code, {"error": e.read().decode()[:300]}
        except Exception as e:
            time.sleep(1.5 * (attempt + 1))
            if attempt == 3:
                return 0, {"error": str(e)[:300]}
    return 0, {"error": "retries exhausted"}

def main():
    if not os.path.exists(PLAN_FILE):
        sys.exit(f"Plan file missing: {PLAN_FILE}")
    plan = json.load(open(PLAN_FILE))["plan"]
    assert plan, "empty plan"
    print(f"Greenland duplicate-archive cleanup · {len(plan)} products · "
          f"{'APPLY (LIVE WRITES)' if APPLY else 'DRY RUN (no writes)'}\n")

    # 1) BACKUP current live state (title + status) BEFORE any write ------------
    os.makedirs(BACKUP_DIR, exist_ok=True)
    stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
    backup_path = os.path.join(BACKUP_DIR, f"live-titles-backup-{stamp}.tsv")
    with open(backup_path, "w") as bk:
        bk.write("shopify_id\thandle\tcurrent_title\tcurrent_status\ttwin_sku\ttwin_title\n")
        for r in plan:
            st, d = api(f"/products/{r['id']}.json?fields=id,handle,title,status")
            if st == 200 and d.get("product"):
                p = d["product"]
                bk.write(f"{p['id']}\t{p.get('handle','')}\t{p.get('title','')}\t"
                         f"{p.get('status','')}\t{r.get('twin_sku','')}\t{r.get('twin_title','')}\n")
    print(f"Backed up current live state -> {backup_path}\n")

    # 2) ARCHIVE each duplicate on Shopify -------------------------------------
    archived = skipped = failed = already = 0
    archived_ids = []
    for r in plan:
        st, d = api(f"/products/{r['id']}.json?fields=id,title,status")
        if st != 200 or not d.get("product"):
            failed += 1
            print(f"  ! FETCH FAIL {r['id']} {r['handle']}: {st} {d.get('error','')[:120]}")
            continue
        p = d["product"]
        title, status = p.get("title", ""), p.get("status", "")
        # idempotency guard: only touch products still carrying the leak word,
        # and skip anything already archived.
        if "greenland" not in title.lower():
            skipped += 1
            print(f"  · skip (no leak word) {r['handle']}: \"{title}\"")
            continue
        if status == "archived":
            already += 1
            print(f"  = already archived {r['handle']}")
            archived_ids.append(str(r['id']))
            continue
        print(f"  {'ARCHIVE' if APPLY else 'would archive'} [{status}] {r['handle']}\n"
              f"        \"{title}\"  (dup of {r.get('twin_sku')} \"{r.get('twin_title')}\")")
        if not APPLY:
            archived += 1
            continue
        wst, wd = api(f"/products/{r['id']}.json", "PUT",
                      {"product": {"id": r['id'], "status": "archived", "published": False}})
        if wst == 200:
            archived += 1
            archived_ids.append(str(r['id']))
        else:
            failed += 1
            print(f"     !! WRITE FAIL {r['id']}: {wst} {wd.get('error','')[:150]}")
        time.sleep(0.3)  # gentle on the 2-call/sec REST limit

    print(f"\nShopify: {'archived' if APPLY else 'would archive'}={archived} "
          f"already-archived={already} skipped={skipped} failed={failed}")

    # 3) Sync the LOCAL dw_unified mirror --------------------------------------
    if APPLY and archived_ids:
        ids_sql = ",".join(f"'{i}'" for i in archived_ids)
        sql = (f"UPDATE shopify_products SET status='ARCHIVED', synced_at=now() "
               f"WHERE shopify_id IN ({ids_sql}) "
               f"AND title ILIKE '%| Greenland%' AND vendor='Greenland'")
        res = subprocess.run(["psql", "-h", "/tmp", "-d", "dw_unified", "-tA", "-c", sql],
                             capture_output=True, text=True)
        print(f"Mirror: {res.stdout.strip() or res.stderr.strip()}")
    elif not APPLY:
        print("Mirror: (dry run — would set status='ARCHIVED' on the matching mirror rows)")

    print("\nDone. No product was published or activated. Backup for rollback: "
          + backup_path)

if __name__ == "__main__":
    main()