← 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
A reports/dw-stale-urls-push-A2-2026-05-19.mdA scripts/dw-stale-urls-head-check.jsA scripts/dw-stale-urls-push.js
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 →