← back to Wallco Ai
scripts/dw-stale-urls-push.js
192 lines
// 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/macstudio3/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); });