← back to Dw Image Dup Audit

fix-run.js

160 lines

#!/usr/bin/env node
// DW PR-line dup-image remediation 2026-07-21. Writes are live Shopify (Steve-approved "run all").
// Ledger: every write appended to fix-ledger.jsonl for reversibility.
const https = require("https");
const fs = require("fs");
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const HOST = "designer-laboratory-sandbox.myshopify.com";
const API = "/admin/api/2024-10";
const LEDGER = __dirname + "/fix-ledger.jsonl";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function req(method, path, body) {
  return new Promise((res, rej) => {
    const data = body ? JSON.stringify(body) : null;
    const r = https.request(
      { host: HOST, path, method, headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json", ...(data ? { "Content-Length": Buffer.byteLength(data) } : {}) } },
      (resp) => { let d = ""; resp.on("data", (c) => (d += c)); resp.on("end", () => res({ status: resp.statusCode, body: d, link: resp.headers.link || "" })); }
    );
    r.on("error", rej);
    if (data) r.write(data);
    r.end();
  });
}
async function reqRetry(method, path, body) {
  for (let i = 0; i < 5; i++) {
    const r = await req(method, path, body);
    if (r.status === 429) { await sleep(2000); continue; }
    return r;
  }
  throw new Error("429 persisted " + path);
}
const ledger = (e) => fs.appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), ...e }) + "\n");

// ---- plan ----
const IMAGE_FIXES = { // handle -> correct CDN file base (already uploaded to DW Shopify Files)
  "palais-gold-glam-pr-801196": "0065364_palais-gold-glam",
  "palais-juniper-pr-801206": "0065363_palais-juniper",
  "palais-new-jade-pr-801216": "0065362_palais-new-jade",
  "palais-peacock-pr-801226": "0065360_palais-peacock",
  "palais-stone-pr-801236": "0065367_palais-stone",
  "bergamo-azure-pr-801136": "0065369_bergamo-azure",
  "bergamo-tan-pr-801146": "0065372_bergamo-tan",
  "chess-cappuccino-pr-801156": "0065377_chess-cappuccino",
  "chess-oat-pr-801166": "0065375_chess-oat",
  "manila-gold-glam-pr-801176": "0065380_manila-gold-glam",
  "manila-oat-pr-801186": "0065378_manila-oat",
  "shantung-aged-stone-pr-801246": "0065389_shantung-aged-stone",
  "shantung-celadon-pr-801256": "0065388_shantung-celadon",
  "shantung-deep-aqua-pr-801266": "0065385_shantung-deep-aqua",
  "shantung-new-jade-pr-801276": "0065384_shantung-new-jade",
};
const IMG_VERSIONS = { // v= params captured from live CDN listing
  "0065360_palais-peacock": "1783366540", "0065362_palais-new-jade": "1783366545", "0065363_palais-juniper": "1783366550",
  "0065364_palais-gold-glam": "1783366555", "0065367_palais-stone": "1783366571", "0065369_bergamo-azure": "1783366582",
  "0065372_bergamo-tan": "1783366597", "0065375_chess-oat": "1783366613", "0065377_chess-cappuccino": "1783366620",
  "0065378_manila-oat": "1783366625", "0065380_manila-gold-glam": "1783366636", "0065384_shantung-new-jade": "1783366673",
  "0065385_shantung-deep-aqua": "1783366679", "0065388_shantung-celadon": "1783366695", "0065389_shantung-aged-stone": "1783366701",
};
const DRAFT_GARBAGE = [ // placeholder gif / image.jpg / broken title
  "ala-forliata-wallpaper-ele-42864", "luega-glam-weave-prt-16113",
  "montenegro-metallic-natural-stripe-prp-56504", "montenegro-metallic-natural-stripe-prp-56505",
  "montenegro-metallic-natural-stripe-prp-56506", "montenegro-metallic-natural-stripe-prp-56507",
  "montenegro-metallic-natural-stripe-prp-56508",
  "phillipe-romano-natural-with-metal-prt-16716", "phillipe-romano-natural-with-metal-prt-16174",
  "pisaro-peacock-damask-walls-prp-56548", "st-barton-paper-weave-prp-56513",
  "tompkins-stain-repellent-real-dupione-silk-xws-52882", "tompkins-stain-repellent-real-dupione-silk-xws-52883",
  "dwjj-40600-davide", "dwjj-40560-giacomo", "dwjj-40570-giacomo", "dwjj-40540-marco",
  "mr-miyago-cork-wallpaper-2", "mr-miyago-cork-wallpaper-7",
  "xxp-969203-designerwallcoverings-los-angeles", // "Kingston-Color Name" template-bug title
];

(async () => {
  // 1) full fetch: handle -> {id, images, title, status}
  console.log("fetching PR line…");
  let path = `${API}/products.json?vendor=${encodeURIComponent("Phillipe Romano")}&fields=id,title,handle,status,images&limit=250`;
  const byHandle = {};
  const noImageActive = [];
  while (path) {
    const { status, body, link } = await reqRetry("GET", path);
    if (status !== 200) throw new Error("fetch " + status);
    for (const p of JSON.parse(body).products) {
      byHandle[p.handle] = p;
      if (p.status === "active" && (!p.images || p.images.length === 0)) noImageActive.push(p.handle);
    }
    const m = link.match(/<https:\/\/[^>]+(\/admin[^>]+)>; rel="next"/);
    path = m ? m[1] : null;
    await sleep(550);
  }
  console.log("products:", Object.keys(byHandle).length, "| no-image actives:", noImageActive.length, noImageActive.join(","));

  // 2) image re-points
  let fixed = 0;
  for (const [handle, base] of Object.entries(IMAGE_FIXES)) {
    const p = byHandle[handle];
    if (!p) { ledger({ act: "imgfix", handle, ok: false, err: "not found" }); continue; }
    const src = `https://www.designerwallcoverings.com/cdn/shop/files/${base}_650_1024x1024.jpg?v=${IMG_VERSIONS[base]}`;
    const cur = p.images[0];
    if (cur && cur.src.includes(base.split("_")[0])) { ledger({ act: "imgfix", handle, ok: true, skip: "already correct" }); continue; }
    const add = await reqRetry("POST", `${API}/products/${p.id}/images.json`, { image: { src, alt: p.title } });
    await sleep(550);
    if (add.status !== 200 && add.status !== 201) { ledger({ act: "imgfix", handle, ok: false, err: add.status + add.body.slice(0, 150) }); console.log("FAIL add", handle, add.status); continue; }
    const newImg = JSON.parse(add.body).image;
    for (const old of p.images) {
      const del = await reqRetry("DELETE", `${API}/products/${p.id}/images/${old.id}.json`);
      await sleep(550);
      ledger({ act: "imgdel", handle, imageId: old.id, oldSrc: old.src, ok: del.status === 200 });
    }
    ledger({ act: "imgfix", handle, ok: true, newImageId: newImg.id, newSrc: newImg.src });
    fixed++; console.log("fixed", handle);
  }

  // 3) drafts: garbage + no-image actives
  const toDraft = [...new Set([...DRAFT_GARBAGE, ...noImageActive])];
  let drafted = 0;
  for (const handle of toDraft) {
    const p = byHandle[handle];
    if (!p) { ledger({ act: "draft", handle, ok: false, err: "not found" }); continue; }
    if (p.status !== "active") { ledger({ act: "draft", handle, ok: true, skip: "not active" }); continue; }
    const r = await reqRetry("PUT", `${API}/products/${p.id}.json`, { product: { id: p.id, status: "draft" } });
    await sleep(550);
    const ok = r.status === 200;
    ledger({ act: "draft", handle, id: p.id, title: p.title, reason: DRAFT_GARBAGE.includes(handle) ? "garbage-image" : "no-image", ok });
    if (ok) { drafted++; console.log("drafted", handle); } else console.log("FAIL draft", handle, r.status);
  }

  // 4) exact clones + Luxuria m↔lxpr + Decorator book dups, derived from audit JSON
  const audit = JSON.parse(fs.readFileSync(__dirname + "/pr-dup-images-2026-07-21.json"));
  const cloneDrafts = new Set();
  const strip = (t) => t.replace(/\s*\|\s*Phillipe Romano\s*$/i, "").trim().toLowerCase();
  for (const g of audit.colorway) {
    const titles = g.items.map(([t]) => strip(t));
    if (g.items.length === 2 && titles[0] === titles[1] && !/memo sample/.test(titles[0])) {
      // identical-title clone pair: keep the SKU-suffixed/longer handle, draft the other
      const [a, b] = g.items.map(([, h]) => h);
      const score = (h) => (/-(dwpr|lxpr|grs|grt|prt|prp|prw|pr)-?\d/.test(h) ? 2 : 0) + h.length / 100;
      cloneDrafts.add(score(a) >= score(b) ? b : a);
    }
    // Luxuria same-color m#### vs lxpr-#### -> draft the m#### handle
    if (titles.every((t) => t.startsWith("luxuria")) && titles[0] === titles[1]) {
      for (const [, h] of g.items) if (/-m\d+$/.test(h)) cloneDrafts.add(h);
    }
  }
  for (const g of audit.crossline) {
    for (const [t, h] of g.items) if (strip(t) === "decorator grasscloth vol. 2") cloneDrafts.add(h);
  }
  let clones = 0;
  for (const handle of cloneDrafts) {
    const p = byHandle[handle];
    if (!p || p.status !== "active") { ledger({ act: "clone-draft", handle, ok: true, skip: "missing/not-active" }); continue; }
    const r = await reqRetry("PUT", `${API}/products/${p.id}.json`, { product: { id: p.id, status: "draft" } });
    await sleep(550);
    const ok = r.status === 200;
    ledger({ act: "clone-draft", handle, id: p.id, title: p.title, ok });
    if (ok) { clones++; console.log("clone-drafted", handle); } else console.log("FAIL clone-draft", handle, r.status);
  }

  console.log(`\nDONE. image-fixed=${fixed} garbage/no-img drafted=${drafted} clone-drafted=${clones}`);
  fs.writeFileSync(__dirname + "/run-summary.json", JSON.stringify({ fixed, drafted, clones, noImageActive, cloneDrafts: [...cloneDrafts] }, null, 1));
})().catch((e) => { console.error("FATAL", e); process.exit(1); });