[object Object]

← back to Wallco Ai

report: dw-stale-urls push-A2 (470 products healed on storefront)

e085a0631476baa7a89b10edaaa601134923ed99 · 2026-05-19 21:39:31 -0700 · SteveStudio2

Files touched

Diff

commit e085a0631476baa7a89b10edaaa601134923ed99
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 19 21:39:31 2026 -0700

    report: dw-stale-urls push-A2 (470 products healed on storefront)
---
 reports/dw-stale-urls-push-A2-2026-05-19.md |  86 +++++++++++++
 scripts/dw-stale-urls-head-check.js         |  57 +++++++++
 scripts/dw-stale-urls-push.js               | 191 ++++++++++++++++++++++++++++
 3 files changed, 334 insertions(+)

diff --git a/reports/dw-stale-urls-push-A2-2026-05-19.md b/reports/dw-stale-urls-push-A2-2026-05-19.md
new file mode 100644
index 0000000..c91a7dd
--- /dev/null
+++ b/reports/dw-stale-urls-push-A2-2026-05-19.md
@@ -0,0 +1,86 @@
+# DW Stale URLs — Push A2 (2026-05-19)
+
+Parking-lot item: **dw-stale-urls substep A** (push verified image-URL swaps to Shopify).
+
+## Inputs
+
+- Staging table: `dw_unified.image_url_swap_proposals_2026_05_19` (4,989 rows, 625 with `new_url` populated).
+- Shopify store: `designer-laboratory-sandbox.myshopify.com` (API `2026-01`).
+- Token: `SHOPIFY_ADMIN_TOKEN` from `~/Projects/secrets-manager/.env`.
+
+## Pre-flight filters
+
+| Filter | Rows |
+|---|---|
+| Initial swap candidates (`new_url IS NOT NULL`) | 625 |
+| Competitor-CDN rejected (`source_table='connie_competitor_catalog'` → `(competitor-removed)`) | -102 |
+| HEAD-check failed (404; → `(dead-on-check)`) | -4 |
+| **Final push set** | **519** |
+
+HEAD-check ran at concurrency=20 with an 8 s timeout, GET-range fallback for 405/403. Stored on the staging table as `head_check_status int` + `head_checked_at timestamptz`. 519/523 returned HTTP 200; 4 returned 404 and were dropped.
+
+## Backup
+
+```sql
+CREATE TABLE shopify_image_url_backup_2026_05_19 AS
+SELECT id, shopify_id, handle, image_url
+FROM shopify_products sp
+WHERE EXISTS (
+  SELECT 1 FROM image_url_swap_proposals_2026_05_19 p
+  WHERE p.product_id = sp.id AND p.new_url IS NOT NULL
+);
+```
+
+Row count: **519** (matches push set).
+
+## Push outcomes
+
+| Outcome | Count |
+|---|---|
+| **Succeeded** (Shopify image POST + set as primary) | **53** |
+| **Matched** (Shopify primary already equalled `new_url` — no Shopify call needed, local mirror synced) | **417** |
+| **Skipped — 404 on Shopify** (product deleted-on-Shopify; row left untouched) | **49** |
+| **Failed** | **0** |
+| **Aborted batches** | 0 |
+| **Delete warnings** (old broken image found and removed) | 0 |
+
+- 470 local-mirror rows (`shopify_products.image_url`) updated to match the new URL.
+- 49 product IDs are stale on the local mirror — they were deleted from Shopify and need separate cleanup (out of scope for this push).
+- Elapsed: **642 s** (~10 min 42 s) for 519 rows across 11 batches of 50 with a 5 s pause between batches. Per-call delay 550 ms; no 429s observed; no exponential backoff triggered.
+
+## Push strategy (per row)
+
+1. `GET /admin/api/2026-01/products/{id}/images.json`
+2. If 404 → mark `skipped_404` (product gone from Shopify).
+3. If primary image `src == new_url` → `match` (Shopify already correct).
+4. Else: `POST .../images.json` with `{image:{src:new_url, position:1}}`, then `PUT /products/{id}.json` with `{product:{id, image:{id:new_image_id}}}` to set as primary, then `DELETE` any image whose `src == old_url`.
+5. After ok/match → `UPDATE shopify_products SET image_url=new_url WHERE id=product_id` (local mirror sync).
+
+## Sample diffs (10 random successful pushes)
+
+| product_id | old_url | new_url |
+|---|---|---|
+| 21527 | …/products/br… | …/files/61028… |
+| 21550 | …/products/co… | …/files/61079… |
+| 11790 | …/products/ae… | https://marburg.com/wp-content/uploads/2025/07/47221-1.webp |
+| 21689 | …/products/ca… | …/files/61042… |
+| 17230 | …/products/29… | …/files/7821-… |
+| 21554 | …/products/co… | …/files/61083… |
+| 21528 | …/products/br… | …/files/61029… |
+| 21697 | …/products/ca… | …/files/61051… |
+| 21551 | …/products/co… | …/files/61080… |
+| 21539 | …/products/Ce… | …/files/61055… |
+
+Bulk of swaps move from Shopify's older `/products/<hash>.jpg` URL pattern (legacy Shopify CDN) to the newer `/files/<slug>.jpg` pattern. 11 of the 519 swap to external vendor CDNs (e.g. `marburg.com`).
+
+## Files
+
+- HEAD-check script: `~/Projects/wallco-ai/scripts/dw-stale-urls-head-check.js`
+- Push script: `~/Projects/wallco-ai/scripts/dw-stale-urls-push.js`
+- Results JSON: `/tmp/dw-stale-urls/push-results.json` (full per-row outcome list)
+- Backup table: `dw_unified.shopify_image_url_backup_2026_05_19`
+
+## Follow-ups
+
+- **49 deleted-on-Shopify products** still occupy rows in `shopify_products` with broken `image_url`. Suggest a `sweep-deleted-shopify-products` job to soft-delete those local rows.
+- **4,364 rows still without a `new_url`** in the proposals table — need vendor re-scrape (out of scope for substep A, queued as substep B / parking-lot continuation).
diff --git a/scripts/dw-stale-urls-head-check.js b/scripts/dw-stale-urls-head-check.js
new file mode 100644
index 0000000..c7e1743
--- /dev/null
+++ b/scripts/dw-stale-urls-head-check.js
@@ -0,0 +1,57 @@
+// HEAD-verify all swap candidates in parallel (concurrency=20)
+// Persists head_check_status + head_checked_at to staging table.
+const { Client } = require('pg');
+
+const CONCURRENCY = 20;
+const TIMEOUT_MS = 8000;
+
+async function headCheck(url) {
+  const ctrl = new AbortController();
+  const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
+  try {
+    let res = await fetch(url, { method: 'HEAD', signal: ctrl.signal, redirect: 'follow' });
+    // Some CDNs reject HEAD with 405 — fall back to GET range
+    if (res.status === 405 || res.status === 403) {
+      const ctrl2 = new AbortController();
+      const t2 = setTimeout(() => ctrl2.abort(), TIMEOUT_MS);
+      try {
+        res = await fetch(url, { method: 'GET', signal: ctrl2.signal, redirect: 'follow', headers: { Range: 'bytes=0-0' } });
+      } finally { clearTimeout(t2); }
+    }
+    return res.status;
+  } catch (e) {
+    return 0; // network/timeout failure
+  } finally {
+    clearTimeout(t);
+  }
+}
+
+async function worker(client, queue, progress) {
+  while (queue.length > 0) {
+    const row = queue.shift();
+    if (!row) break;
+    const status = await headCheck(row.new_url);
+    await client.query(
+      'UPDATE image_url_swap_proposals_2026_05_19 SET head_check_status=$1, head_checked_at=NOW() WHERE id=$2',
+      [status, row.id]
+    );
+    progress.done++;
+    if (status === 200) progress.ok++;
+    if (progress.done % 25 === 0) console.log(`[${progress.done}/${progress.total}] ok=${progress.ok}`);
+  }
+}
+
+(async () => {
+  const client = new Client();
+  await client.connect();
+  const { rows } = await client.query(
+    "SELECT id, new_url FROM image_url_swap_proposals_2026_05_19 WHERE new_url IS NOT NULL AND head_check_status IS NULL"
+  );
+  console.log(`To check: ${rows.length}`);
+  const queue = [...rows];
+  const progress = { done: 0, ok: 0, total: rows.length };
+  const workers = Array.from({ length: CONCURRENCY }, () => worker(client, queue, progress));
+  await Promise.all(workers);
+  console.log(`DONE: ${progress.done} checked, ${progress.ok} returned 200`);
+  await client.end();
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/dw-stale-urls-push.js b/scripts/dw-stale-urls-push.js
new file mode 100644
index 0000000..e38fd6a
--- /dev/null
+++ b/scripts/dw-stale-urls-push.js
@@ -0,0 +1,191 @@
+// Push image swaps to Shopify for the verified-200 set.
+// Strategy per row:
+//   1. GET product images.json
+//   2. If product 404 → mark skipped (deleted-on-shopify)
+//   3. If primary image src already == new_url → MATCH, just sync local mirror
+//   4. Else POST new image with src=new_url, then PUT product { image: { id: new_image_id } } to set as primary
+//      Then DELETE any image whose src equals the original old_url (if found).
+//
+// Batch=50, pause 5s between. Abort on 3 consecutive failures (not counting 404-skipped or MATCH).
+// Rate: 2 calls/sec safe; we add 500ms between Shopify calls.
+// Retries on 429: exponential backoff (2s, 4s, 8s) up to 3x.
+
+const { Client } = require('pg');
+const fs = require('fs');
+const path = require('path');
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2026-01';
+const TOKEN = (() => {
+  const envPath = '/Users/stevestudio2/Projects/secrets-manager/.env';
+  const txt = fs.readFileSync(envPath, 'utf8');
+  const m = txt.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m);
+  if (!m) throw new Error('SHOPIFY_ADMIN_TOKEN missing');
+  return m[1].trim();
+})();
+
+const BATCH = 50;
+const BATCH_PAUSE_MS = 5000;
+const PER_CALL_DELAY_MS = 550; // ~1.8/sec
+const ABORT_AFTER_CONSEC = 3;
+
+const DRY_RUN = process.argv.includes('--dry-run');
+const LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? parseInt(process.argv[i+1], 10) : null; })();
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function shopify(method, urlPath, body) {
+  for (let attempt = 0; attempt < 4; attempt++) {
+    const res = await fetch(`https://${SHOP}/admin/api/${API}${urlPath}`, {
+      method,
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+      body: body ? JSON.stringify(body) : undefined,
+    });
+    if (res.status === 429) {
+      const wait = (2 ** attempt) * 2000;
+      console.log(`  429 — backing off ${wait}ms`);
+      await sleep(wait);
+      continue;
+    }
+    let json = null;
+    try { json = await res.json(); } catch (e) {}
+    return { status: res.status, json };
+  }
+  return { status: 429, json: null };
+}
+
+function gidToNumeric(gid) {
+  const m = String(gid).match(/(\d+)$/);
+  return m ? m[1] : null;
+}
+
+async function processOne(row, results) {
+  const numericId = gidToNumeric(row.shopify_id);
+  if (!numericId) {
+    results.failed.push({ id: row.id, reason: 'no-numeric-id', product_id: row.product_id });
+    return 'fail';
+  }
+  // 1. GET images
+  const got = await shopify('GET', `/products/${numericId}/images.json`);
+  await sleep(PER_CALL_DELAY_MS);
+  if (got.status === 404) {
+    results.skipped_404.push({ id: row.id, product_id: row.product_id, handle: row.handle });
+    return 'skip';
+  }
+  if (got.status !== 200) {
+    results.failed.push({ id: row.id, product_id: row.product_id, reason: `GET-images-${got.status}` });
+    return 'fail';
+  }
+  const images = got.json?.images || [];
+  images.sort((a, b) => a.position - b.position);
+  const primary = images[0];
+
+  // 2. MATCH check
+  if (primary && primary.src === row.new_url) {
+    results.matched.push({ id: row.id, product_id: row.product_id });
+    return 'match';
+  }
+
+  if (DRY_RUN) {
+    results.would_push.push({ id: row.id, product_id: row.product_id, old: row.old_url, new: row.new_url });
+    return 'dry';
+  }
+
+  // 3. POST new image
+  const posted = await shopify('POST', `/products/${numericId}/images.json`, { image: { src: row.new_url, position: 1 } });
+  await sleep(PER_CALL_DELAY_MS);
+  if (posted.status !== 200 && posted.status !== 201) {
+    results.failed.push({ id: row.id, product_id: row.product_id, reason: `POST-image-${posted.status}`, detail: JSON.stringify(posted.json).slice(0, 200) });
+    return 'fail';
+  }
+  const newImageId = posted.json?.image?.id;
+  if (!newImageId) {
+    results.failed.push({ id: row.id, product_id: row.product_id, reason: 'no-new-image-id' });
+    return 'fail';
+  }
+
+  // 4. Set as primary via product.image = { id: newImageId }
+  const putRes = await shopify('PUT', `/products/${numericId}.json`, { product: { id: parseInt(numericId, 10), image: { id: newImageId } } });
+  await sleep(PER_CALL_DELAY_MS);
+  if (putRes.status !== 200) {
+    results.failed.push({ id: row.id, product_id: row.product_id, reason: `PUT-primary-${putRes.status}`, detail: JSON.stringify(putRes.json).slice(0, 200) });
+    return 'fail';
+  }
+
+  // 5. Optional: delete any image whose src equals old_url
+  for (const img of images) {
+    if (img.src === row.old_url && img.id !== newImageId) {
+      const del = await shopify('DELETE', `/products/${numericId}/images/${img.id}.json`);
+      await sleep(PER_CALL_DELAY_MS);
+      if (del.status !== 200) {
+        results.delete_warnings.push({ id: row.id, product_id: row.product_id, image_id: img.id, status: del.status });
+      }
+    }
+  }
+
+  results.succeeded.push({ id: row.id, product_id: row.product_id, new_image_id: newImageId, old: row.old_url, new: row.new_url });
+  return 'ok';
+}
+
+(async () => {
+  const client = new Client();
+  await client.connect();
+  const limClause = LIMIT ? `LIMIT ${LIMIT}` : '';
+  const { rows } = await client.query(`
+    SELECT p.id, p.product_id, p.handle, p.old_url, p.new_url, p.source_table, sp.shopify_id
+    FROM image_url_swap_proposals_2026_05_19 p
+    JOIN shopify_products sp ON sp.id = p.product_id
+    WHERE p.new_url IS NOT NULL AND p.head_check_status = 200
+    ORDER BY p.id
+    ${limClause}
+  `);
+  console.log(`Push set: ${rows.length}${DRY_RUN ? ' (DRY RUN)' : ''}`);
+
+  const results = {
+    succeeded: [], matched: [], skipped_404: [],
+    failed: [], delete_warnings: [], would_push: [],
+    started_at: new Date().toISOString(),
+  };
+  let consecFails = 0;
+  let aborted = false;
+
+  for (let i = 0; i < rows.length; i += BATCH) {
+    const batch = rows.slice(i, i + BATCH);
+    console.log(`\n=== Batch ${Math.floor(i / BATCH) + 1} (rows ${i + 1}-${Math.min(i + BATCH, rows.length)}) ===`);
+    for (const row of batch) {
+      const outcome = await processOne(row, results);
+      if (outcome === 'fail') {
+        consecFails++;
+        if (consecFails >= ABORT_AFTER_CONSEC) {
+          console.error(`!! ${ABORT_AFTER_CONSEC} consecutive failures — aborting`);
+          aborted = true;
+          break;
+        }
+      } else {
+        consecFails = 0;
+      }
+      // Sync local mirror for ok/match/dry
+      if ((outcome === 'ok' || outcome === 'match') && !DRY_RUN) {
+        try {
+          await client.query('UPDATE shopify_products SET image_url=$1 WHERE id=$2', [row.new_url, row.product_id]);
+        } catch (e) {
+          console.error(`  local mirror update failed for product_id=${row.product_id}: ${e.message}`);
+        }
+      }
+    }
+    if (aborted) break;
+    console.log(`  Progress: ok=${results.succeeded.length} match=${results.matched.length} 404=${results.skipped_404.length} fail=${results.failed.length}`);
+    if (i + BATCH < rows.length) {
+      console.log(`  pausing ${BATCH_PAUSE_MS}ms...`);
+      await sleep(BATCH_PAUSE_MS);
+    }
+  }
+
+  results.ended_at = new Date().toISOString();
+  results.aborted = aborted;
+  const outPath = '/tmp/dw-stale-urls/push-results.json';
+  fs.writeFileSync(outPath, JSON.stringify(results, null, 2));
+  console.log(`\nFINAL: ok=${results.succeeded.length} match=${results.matched.length} skipped404=${results.skipped_404.length} fail=${results.failed.length} aborted=${aborted}`);
+  console.log(`Results → ${outPath}`);
+  await client.end();
+})().catch(e => { console.error(e); process.exit(1); });

← 9fb3d02 decision: dw-stale-urls trim-B → leave-it (negative cache is  ·  back to Wallco Ai  ·  report: dw-stale-urls rescrape pass (4 vendors, 806 products 079feef →