[object Object]

← back to Gmc Titlefix

TK-11233 B3: umbrella hi-res canary (Lee Jofa/GP&J Baker/Mulberry/Groundworks) — reuses proven Kravet apply/rollback/self-heal, joins kravet_catalog by mfr_sku/dw_sku, --vendor filter, apply hard-gated

5eddebdf36893f3f844679110fec455b2a2a3816 · 2026-09-05 00:06:41 -0700 · Steve

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2rTwx5vYLo99ZJySsnts

Files touched

Diff

commit 5eddebdf36893f3f844679110fec455b2a2a3816
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Sep 5 00:06:41 2026 -0700

    TK-11233 B3: umbrella hi-res canary (Lee Jofa/GP&J Baker/Mulberry/Groundworks) — reuses proven Kravet apply/rollback/self-heal, joins kravet_catalog by mfr_sku/dw_sku, --vendor filter, apply hard-gated
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01VX2rTwx5vYLo99ZJySsnts
---
 tk11233-umbrella-hires-canary.mjs | 208 ++++++++++++++++++++++++++++++++++++++
 1 file changed, 208 insertions(+)

diff --git a/tk11233-umbrella-hires-canary.mjs b/tk11233-umbrella-hires-canary.mjs
new file mode 100644
index 0000000..465f4bb
--- /dev/null
+++ b/tk11233-umbrella-hires-canary.mjs
@@ -0,0 +1,208 @@
+#!/usr/bin/env node
+// TK-11233 Bucket-3 — Kravet-family HI-RES image re-fetch canary.
+//
+// PROBLEM: DW downsized Kravet-family masters to ~400px on ingest, so Google flags
+//   image_too_small_for_high_resolution (12,777 warns) → free/enhanced-listing demotion.
+//   The vendor originals live on Kravet's Brandfolder DAM (cdn.brandfolder.io) and are
+//   MOSTLY 2500–4361px — but NOT ALL (verified 1/5 sample was 427px). So we re-fetch the
+//   ORIGINAL and re-upload ONLY when the original actually clears the bar. This naturally
+//   excludes Phillipe Romano / Koroseal (different catalog tables, genuinely-capped originals).
+//
+// SAFETY / REVERSIBILITY:
+//   * DEFAULT = read-only enumerate. Measures served vs original, classifies, writes a
+//     candidate list + rollback map. Fires NOTHING.
+//   * --apply is HARD-GATED: refuses unless BOTH `--apply` AND env TK11233_APPROVED=1.
+//     Apply ADDS the hi-res image as new media and moves it to position 1 — it NEVER deletes
+//     the existing master. Rollback = delete the newly-added media + restore prior order,
+//     recorded per-product in the run-log. The original image is never destroyed.
+//
+// Usage:
+//   node tk11233-kravet-hires-canary.mjs                 # canary enumerate (default N=25), read-only
+//   node tk11233-kravet-hires-canary.mjs --limit 100     # bigger read-only enumeration
+//   node tk11233-kravet-hires-canary.mjs --apply         # REFUSES unless TK11233_APPROVED=1
+//   TK11233_APPROVED=1 node ... --apply --limit 25       # gated apply (do NOT run without approval)
+//   node tk11233-kravet-hires-canary.mjs --rollback <run-log.jsonl>   # undo an apply run
+
+import { execSync } from 'child_process';
+import fs from 'fs';
+import os from 'os';
+
+const SHOP = 'designer-laboratory-sandbox';
+const MIN_HIRES = parseInt((process.argv.find(a=>a==='--min') ? process.argv[process.argv.indexOf('--min')+1] : '1500'), 10);  // require original ≥N px on long edge (Google bar 1024)
+const APPLY_CAP = parseInt((process.argv.find(a=>a==='--cap') ? process.argv[process.argv.indexOf('--cap')+1] : '0'), 10);      // hard cap on # applied this run (0 = all)
+const SERVED_MAX_OK = 1024;      // served ≥1024 = already fine, skip
+const MAX_HIRES = 5000;          // originals >~5000px (>25MP) fail Shopify media processing — skip up front (4803² OK, 6000² FAILED)
+const LIMIT = parseInt((process.argv.find(a=>a==='--limit') ? process.argv[process.argv.indexOf('--limit')+1] : '25'), 10);
+const APPLY = process.argv.includes('--apply');
+const ROLLBACK = process.argv.includes('--rollback') ? process.argv[process.argv.indexOf('--rollback')+1] : null;
+const OUT = new URL('./data/tk11233-kravet-hires-canary.json', import.meta.url).pathname;
+const RUNLOG = new URL(`./data/tk11233-kravet-hires-apply-${new Date().toISOString().replace(/[:.]/g,'-')}.jsonl`, import.meta.url).pathname;
+
+const shopTok = (fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
+  .match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m) || [])[1];
+if (!shopTok) { console.error('missing SHOPIFY_FULL_ACCESS_TOKEN'); process.exit(2); }
+
+async function shopify(query, variables) {
+  const r = await fetch(`https://${SHOP}.myshopify.com/admin/api/2024-10/graphql.json`, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': shopTok, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(60000) });
+  const j = await r.json();
+  if (j.errors) throw new Error('shopify gql: ' + JSON.stringify(j.errors));
+  return j.data;
+}
+
+// measure remote image on the long edge via curl + sips (local, $0)
+function dims(url) {
+  try {
+    const tmp = `${os.tmpdir()}/tk11233-${Math.random().toString(36).slice(2)}.img`;
+    execSync(`curl -s -m 30 -o ${tmp} ${JSON.stringify(url)}`, { stdio: 'ignore' });
+    const out = execSync(`sips -g pixelWidth -g pixelHeight ${tmp} 2>/dev/null || true`).toString();
+    fs.unlinkSync(tmp);
+    const w = +(out.match(/pixelWidth:\s*(\d+)/)||[])[1] || 0;
+    const h = +(out.match(/pixelHeight:\s*(\d+)/)||[])[1] || 0;
+    return { w, h, long: Math.max(w, h) };
+  } catch { return { w: 0, h: 0, long: 0 }; }
+}
+
+// UMBRELLA VARIANT (TK-11233 B3 extension): the live Kravet-umbrella brands (Lee Jofa,
+// GP&J Baker, Mulberry, Groundworks, Cole&Son…) are DWKK-prefixed and were downsized into
+// our own Shopify CDN on ingest, but their big brandfolder originals live in kravet_catalog
+// matched by mfr_sku/dw_sku (NOT shopify_product_id, which is why the Kravet canary missed
+// them). Same DAM, same apply/rollback/self-heal logic — only the JOIN + vendor filter differ.
+const DEFAULT_VENDORS = ['Lee Jofa','GP & J Baker','Mulberry','Groundworks','Cole & Son','Threads'];
+function argVendors() {
+  const vs = [];
+  process.argv.forEach((a,i)=>{ if (a==='--vendor') vs.push(process.argv[i+1]); });
+  return vs.length ? vs : DEFAULT_VENDORS;
+}
+function pickCandidates(limit) {
+  const OFFSET = parseInt((process.argv.find(a=>a==='--offset') ? process.argv[process.argv.indexOf('--offset')+1] : '0'), 10);
+  const ORDER = OFFSET > 0 || process.argv.includes('--ordered') ? 'shopify_id' : 'random()';  // deterministic when paginating
+  const vendorList = argVendors().map(v => `'${v.replace(/'/g,"''")}'`).join(',');
+  // DISTINCT ON collapses the many-catalog-rows-per-product match to one (largest-URL) brandfolder original.
+  const sql = `SELECT shopify_id, vendor, mfr_sku, orig_url FROM (
+      SELECT DISTINCT ON (sp.shopify_id) sp.shopify_id, sp.vendor, c.mfr_sku, c.image_url AS orig_url
+      FROM shopify_products sp
+      JOIN kravet_catalog c ON (c.mfr_sku=sp.mfr_sku OR c.dw_sku=sp.dw_sku)
+      WHERE sp.status='ACTIVE' AND c.image_url ~ 'brandfolder' AND sp.shopify_id IS NOT NULL
+        AND sp.vendor IN (${vendorList})
+      ORDER BY sp.shopify_id, char_length(c.image_url) DESC
+    ) q ORDER BY ${ORDER} LIMIT ${limit} OFFSET ${OFFSET};`.replace(/\s+/g,' ').trim();
+  const raw = execSync(`psql "host=/tmp dbname=dw_unified" -tA -F'\t' -c ${JSON.stringify(sql)}`).toString().trim();
+  if (!raw) return [];
+  return raw.split('\n').map(l => { const [gid, vendor, sku, orig] = l.split('\t'); const pid = (gid.match(/(\d+)$/)||[])[1]; return { pid, gid, vendor, sku, orig }; });
+}
+
+async function primaryMedia(pid) {
+  const gid = `gid://shopify/Product/${pid}`;
+  const d = await shopify(`query($id:ID!){ product(id:$id){ id title
+      media(first:10){ nodes{ id mediaContentType
+        ... on MediaImage { image { url width height } } } } } }`, { id: gid });
+  const p = d.product; if (!p) return null;
+  const imgs = p.media.nodes.filter(n => n.mediaContentType === 'IMAGE');
+  return { gid, title: p.title, first: imgs[0] || null, mediaCount: imgs.length };
+}
+
+async function enumerate() {
+  const cands = pickCandidates(LIMIT);
+  const needs = [], origSmall = [], alreadyOk = [], tooLarge = [], errors = [];
+  const rollbackMap = [];
+  for (const c of cands) {
+    try {
+      const m = await primaryMedia(c.pid);
+      if (!m || !m.first) { errors.push({ ...c, why: 'no-media' }); continue; }
+      // served size Google sees = the master's Shopify image width/height (feed image_url)
+      const servedLong = Math.max(m.first.image.width || 0, m.first.image.height || 0);
+      const od = dims(c.orig);
+      const row = { ...c, gid: m.gid, title: m.title, media_id: m.first.id,
+        served_long: servedLong, served: `${m.first.image.width}x${m.first.image.height}`,
+        orig_long: od.long, orig: `${od.w}x${od.h}`, orig_url: c.orig };
+      if (servedLong >= SERVED_MAX_OK) { alreadyOk.push(row); }
+      else if (od.long > MAX_HIRES) { tooLarge.push({ ...row, note: `original >${MAX_HIRES}px (~>25MP) — Shopify would fail processing; skip/downscale later` }); }
+      else if (od.long >= MIN_HIRES) { needs.push(row);
+        rollbackMap.push({ gid: m.gid, old_primary_media_id: m.first.id, old_primary_src: m.first.image.url, old_media_count: m.mediaCount }); }
+      else { origSmall.push({ ...row, note: 'original-also-small → excluded (defer to upscale program)' }); }
+    } catch (e) { errors.push({ ...c, why: String(e).slice(0,120) }); }
+  }
+  const report = {
+    ticket: 'TK-11233', bucket: 'B3-kravet-hires', checked_at: new Date().toISOString(),
+    scope: 'ACTIVE Kravet-family products in kravet_catalog with Brandfolder originals',
+    thresholds: { served_ok_px: SERVED_MAX_OK, min_original_px: MIN_HIRES },
+    sample: cands.length,
+    counts: { needs_hires: needs.length, original_also_small_excluded: origSmall.length, original_too_large_excluded: tooLarge.length, already_ok: alreadyOk.length, errors: errors.length },
+    needs_hires: needs, original_also_small_excluded: origSmall, original_too_large_excluded: tooLarge, already_ok: alreadyOk, errors,
+    rollback_map: rollbackMap,
+    apply_gate: 'HARD-GATED — requires --apply AND env TK11233_APPROVED=1; apply ADDS new media (never deletes the original); reversible via --rollback <run-log>.'
+  };
+  fs.writeFileSync(OUT, JSON.stringify(report, null, 2));
+  console.log(`\n=== TK-11233 Kravet hi-res canary — sample ${cands.length} ===`);
+  console.log(`  NEEDS_HIRES (served<${SERVED_MAX_OK} & orig≥${MIN_HIRES}) : ${needs.length}`);
+  console.log(`  ORIG_ALSO_SMALL (excluded, defer)                        : ${origSmall.length}`);
+  console.log(`  ALREADY_OK (served≥${SERVED_MAX_OK})                          : ${alreadyOk.length}`);
+  console.log(`  ERRORS                                                   : ${errors.length}`);
+  console.log(`  → report: ${OUT}`);
+  needs.slice(0,8).forEach(r => console.log(`    + ${r.vendor} ${r.sku}  served=${r.served} orig=${r.orig}`));
+  origSmall.slice(0,5).forEach(r => console.log(`    - EXCLUDE ${r.vendor} ${r.sku}  orig=${r.orig}`));
+  console.log(`\ncost: $0 (Shopify reads + local sips). Nothing fired.`);
+  return report;
+}
+
+async function apply() {
+  if (!APPLY || process.env.TK11233_APPROVED !== '1') {
+    console.error('\n⛔ GATED: apply refused. Requires BOTH --apply AND env TK11233_APPROVED=1.');
+    console.error('   This writes customer-facing product media → must carry a logged TK-11248 approval first.');
+    process.exit(3);
+  }
+  const rpt = await enumerate();
+  const targets = APPLY_CAP > 0 ? rpt.needs_hires.slice(0, APPLY_CAP) : rpt.needs_hires;
+  console.log(`\n=== APPLY (gated, approved) — min=${MIN_HIRES}px, adding hi-res primary to ${targets.length} of ${rpt.needs_hires.length} candidates (cap=${APPLY_CAP||'none'}) ===`);
+  for (const r of targets) {
+    try {
+      const cm = await shopify(`mutation($pid:ID!,$media:[CreateMediaInput!]!){
+        productCreateMedia(productId:$pid, media:$media){ media{ id status } mediaUserErrors{ field message } } }`,
+        { pid: r.gid, media: [{ originalSource: r.orig_url, mediaContentType: 'IMAGE', alt: r.title }] });
+      const newId = cm.productCreateMedia.media?.[0]?.id;
+      const errs = cm.productCreateMedia.mediaUserErrors;
+      if (!newId || errs.length) { fs.appendFileSync(RUNLOG, JSON.stringify({ ...r, ok:false, errs })+'\n'); continue; }
+      // poll processing status; self-heal on FAILED so we never leave a broken primary
+      let st = 'PROCESSING', tries = 0;
+      while ((st === 'PROCESSING' || st === 'UPLOADED') && tries++ < 12) {
+        await new Promise(r=>setTimeout(r,3000));
+        const q = await shopify(`query($id:ID!){ node(id:$id){ ... on MediaImage{ status } } }`, { id: newId });
+        st = q.node?.status || st;
+      }
+      if (st !== 'READY') {
+        // FAILED/timeout → remove the bad media (original stays primary), log as not-applied
+        await shopify(`mutation($ids:[ID!]!,$pid:ID!){ productDeleteMedia(mediaIds:$ids,productId:$pid){ deletedMediaIds } }`, { ids:[newId], pid:r.gid });
+        fs.appendFileSync(RUNLOG, JSON.stringify({ ...r, ok:false, self_healed:true, status:st, note:`media ${st} → removed, original ${r.media_id} kept as primary` })+'\n');
+        console.log(`  ⚠ ${r.sku}  ${st} (orig too large / processing fail) → reverted`);
+        continue;
+      }
+      // READY → move new media to position 0 (primary) — reversible reorder
+      await shopify(`mutation($id:ID!,$moves:[MoveInput!]!){ productReorderMedia(id:$id, moves:$moves){ job{ id } userErrors{ message } } }`,
+        { id: r.gid, moves: [{ id: newId, newPosition: '0' }] });
+      fs.appendFileSync(RUNLOG, JSON.stringify({ gid:r.gid, sku:r.sku, ok:true,
+        added_media_id:newId, old_primary_media_id:r.media_id, old_primary_src:r.served,
+        undo:`delete media ${newId} + restore ${r.media_id} to position 0` })+'\n');
+      console.log(`  ✓ ${r.sku}  +hi-res ${r.orig}`);
+    } catch (e) { fs.appendFileSync(RUNLOG, JSON.stringify({ ...r, ok:false, err:String(e).slice(0,160) })+'\n'); }
+  }
+  console.log(`\nrun-log (rollback source): ${RUNLOG}`);
+}
+
+async function rollback(logPath) {
+  const lines = fs.readFileSync(logPath,'utf8').trim().split('\n').map(l=>JSON.parse(l)).filter(r=>r.ok && r.added_media_id);
+  console.log(`=== ROLLBACK ${lines.length} added media (deleting new, restoring original order) ===`);
+  for (const r of lines) {
+    try {
+      await shopify(`mutation($mediaIds:[ID!]!,$pid:ID!){ productDeleteMedia(mediaIds:$mediaIds, productId:$pid){ deletedMediaIds mediaUserErrors{ message } } }`,
+        { mediaIds:[r.added_media_id], pid:r.gid });
+      console.log(`  ↩ ${r.sku}  removed ${r.added_media_id}`);
+    } catch(e){ console.error(`  ✗ ${r.sku} ${String(e).slice(0,100)}`); }
+  }
+}
+
+if (ROLLBACK) rollback(ROLLBACK);
+else if (APPLY) apply();
+else enumerate();

← 5f8a35c auto-data-snapshot: 2026-09-04T23:48:35 (1 data files) — dat  ·  back to Gmc Titlefix  ·  Apply and verify approved GMC residual canaries with scoped 791ac44 →