← back to Fabricut Landing
scripts/localize-images.js
183 lines
#!/usr/bin/env node
/**
* Fabricut image LOCALIZER — TK-10823.
*
* Downloads every S3-hotlinked Fabricut image referenced by fabricut_catalog
* (image_url + all_images) into public/img/fabricut/, preferring the FULL-RES
* product_images/<id>.jpg (900x1200) over the thumbnails/<id>@2x.jpg (600px)
* for the primary swatch, and localizes the real _stylized_secondary room shots
* too (verified HTTP 200 — NOT dead). Then rewrites fabricut_catalog.image_url
* and all_images to PUBLIC DW absolute URLs (DTD verdict B):
* https://fabricut.designerwallcoverings.com/img/fabricut/<file>
* so the value works for BOTH the microsite AND the Shopify daily-post (Shopify
* fetches images by public absolute URL — a bare relative path would break it).
*
* Fully reversible: writes data/image-restore-map.json (old S3 url -> new DW url,
* per column, per dw_sku) BEFORE any DB write, and only rewrites rows whose old
* values are recorded. Re-runnable (idempotent): rows already on the DW domain
* are skipped; missing local files are (re)downloaded.
*
* node scripts/localize-images.js # download only, no DB write (dry)
* node scripts/localize-images.js --apply # download + rewrite DB columns
*
* $0 (local PG + plain HTTPS GETs to the vendor's public S3; no paid API).
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const { Pool } = require('pg');
const APPLY = process.argv.includes('--apply');
const ROOT = path.join(__dirname, '..');
const IMG_DIR = path.join(ROOT, 'public', 'img', 'fabricut');
const MAP_OUT = path.join(ROOT, 'data', 'image-restore-map.json');
const PUBLIC_BASE = 'https://fabricut.designerwallcoverings.com/img/fabricut';
const S3_RE = /s3\.us-east-1\.amazonaws\.com/i;
const PW = (() => {
try { const env = fs.readFileSync(require('os').homedir() + '/Projects/secrets-manager/.env', 'utf8');
const m = env.match(/^DW_ADMIN_DB_PASSWORD=(.*)$/m); if (m) return m[1].replace(/^["']|["']$/g, '').trim(); } catch {}
return process.env.PGPASSWORD || '';
})();
const pool = new Pool(fs.existsSync('/tmp/.s.PGSQL.5432')
? { host: '/tmp', database: 'dw_unified' }
: { host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PW });
fs.mkdirSync(IMG_DIR, { recursive: true });
// deterministic local filename from an S3 url: keep the vendor's own image id +
// a suffix that disambiguates thumbnail / full / secondary so nothing collides.
function localName(url) {
const u = decodeURIComponent(url.split('?')[0]);
const base = u.split('/').pop(); // e.g. 4234602@2x.jpg or 4234602.jpg
const id = base.replace(/@2x/i, '').replace(/\.[a-z]+$/i, '');
if (/secondary_images/i.test(u)) return `${id}__room.jpg`;
if (/\/thumbnails\//i.test(u)) return `${id}__thumb.jpg`;
return `${id}.jpg`; // full-res product_images/<id>.jpg
}
function fullResFor(thumbUrl) {
// thumbnails/<id>@2x.jpg -> product_images/<id>.jpg (900x1200)
return thumbUrl.replace(/\/thumbnails\/([^@/]+)@2x(\.[a-z]+)/i, '/$1$2');
}
function get(url, dest) {
return new Promise((resolve) => {
const req = https.get(url, res => {
if (res.statusCode !== 200) { res.resume(); return resolve({ ok: false, code: res.statusCode }); }
const ct = res.headers['content-type'] || '';
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
// guard against S3 XML error bodies masquerading as 200
if (buf.length < 512 || /^<\?xml/i.test(buf.slice(0, 16).toString())) return resolve({ ok: false, code: 'notimg' });
fs.writeFileSync(dest, buf);
resolve({ ok: true, bytes: buf.length, ct });
});
});
req.on('error', () => resolve({ ok: false, code: 'err' }));
req.setTimeout(30000, () => { req.destroy(); resolve({ ok: false, code: 'timeout' }); });
});
}
async function ensureLocal(url, cache, stats) {
const name = localName(url);
const dest = path.join(IMG_DIR, name);
const dwUrl = `${PUBLIC_BASE}/${name}`;
if (cache.has(url)) return cache.get(url);
if (fs.existsSync(dest) && fs.statSync(dest).size > 512) { cache.set(url, dwUrl); stats.cached++; return dwUrl; }
const r = await get(url, dest);
if (!r.ok) { stats.failed++; stats.failUrls.push(`${r.code} ${url}`); cache.set(url, null); return null; }
stats.downloaded++; stats.bytes += r.bytes;
cache.set(url, dwUrl);
return dwUrl;
}
async function main() {
const { rows } = await pool.query(`
SELECT dw_sku, image_url, all_images FROM fabricut_catalog
WHERE full_scraped = true AND product_url ~ 'fabricut.com'
AND (image_url ~ 's3.us-east' OR all_images ~ 's3.us-east')
ORDER BY dw_sku`);
console.log(`[localize] ${rows.length} rows with S3 hotlinks | apply=${APPLY}`);
const cache = new Map();
const stats = { downloaded: 0, cached: 0, failed: 0, bytes: 0, failUrls: [] };
const restore = []; // { dw_sku, image_url:{old,new}, all_images:{old,new} }
for (const r of rows) {
const rec = { dw_sku: r.dw_sku };
// --- image_url (primary): prefer FULL-RES over the thumbnail ---
if (r.image_url && S3_RE.test(r.image_url)) {
let srcUrl = r.image_url;
if (/\/thumbnails\//i.test(srcUrl)) {
const full = fullResFor(srcUrl);
// try full-res first; if it 404s we fall back to the thumbnail
const fname = path.join(IMG_DIR, localName(full));
const dwFull = await ensureLocal(full, cache, stats);
srcUrl = dwFull ? full : srcUrl;
}
const dw = await ensureLocal(srcUrl, cache, stats);
if (dw) rec.image_url = { old: r.image_url, new: dw };
}
// --- all_images (full-res primary + real room shot, comma-joined) ---
if (r.all_images && S3_RE.test(r.all_images)) {
const parts = r.all_images.split(',').map(s => s.trim()).filter(Boolean);
const newParts = [];
let anyLocal = false;
for (const p of parts) {
if (!S3_RE.test(p)) { newParts.push(p); continue; }
const dw = await ensureLocal(p, cache, stats);
if (dw) { newParts.push(dw); anyLocal = true; }
else { anyLocal = true; stats.droppedDead = (stats.droppedDead || 0) + 1; } // DROP genuinely-dead S3 (403) — never keep a hotlink
}
if (anyLocal) rec.all_images = { old: r.all_images, new: newParts.join(',') };
}
if (rec.image_url || rec.all_images) restore.push(rec);
if ((stats.downloaded + stats.cached) % 200 === 0 && (stats.downloaded + stats.cached) > 0)
console.log(` ...images processed dl=${stats.downloaded} cached=${stats.cached} fail=${stats.failed}`);
}
// MERGE into the canonical restore-map — NEVER clobber prior reversibility records.
// A steady-state daily run finds 0 S3 rows; it must not wipe the original 1325-row map.
// Keyed by dw_sku; a row's mapping updates only if this run actually localized it.
let prior = { restore: [] };
try { prior = JSON.parse(fs.readFileSync(MAP_OUT, 'utf8')); } catch {}
const byKey = new Map((prior.restore || []).map(r => [r.dw_sku, r]));
for (const rec of restore) {
const ex = byKey.get(rec.dw_sku) || { dw_sku: rec.dw_sku };
// keep the OLDEST recorded `old` (the true original S3 url), update `new`
if (rec.image_url) ex.image_url = { old: (ex.image_url && ex.image_url.old) || rec.image_url.old, new: rec.image_url.new };
if (rec.all_images) ex.all_images = { old: (ex.all_images && ex.all_images.old) || rec.all_images.old, new: rec.all_images.new };
byKey.set(rec.dw_sku, ex);
}
const merged = [...byKey.values()];
fs.writeFileSync(MAP_OUT, JSON.stringify({
built_at: new Date().toISOString(), last_run_applied: APPLY, public_base: PUBLIC_BASE,
rows: merged.length, last_run_localized: restore.length,
download_stats: { ...stats, failUrls: stats.failUrls.slice(0, 50) },
restore: merged,
}, null, 2));
console.log(`[localize] downloaded=${stats.downloaded} cached=${stats.cached} failed=${stats.failed} ` +
`bytes=${(stats.bytes / 1e6).toFixed(1)}MB | restore-map: ${restore.length} rows -> ${MAP_OUT}`);
if (stats.failed) console.log(` first failures:\n ${stats.failUrls.slice(0, 8).join('\n ')}`);
if (!APPLY) { console.log('[localize] DRY — no DB write. Re-run with --apply to rewrite columns.'); await pool.end(); return; }
// --- rewrite DB columns (Mac2-canonical fabricut_catalog staging write, reversible via restore-map) ---
let wrote = 0;
for (const rec of restore) {
const sets = [], vals = [rec.dw_sku]; let i = 2;
if (rec.image_url) { sets.push(`image_url = $${i++}`); vals.push(rec.image_url.new); }
if (rec.all_images) { sets.push(`all_images = $${i++}`); vals.push(rec.all_images.new); }
if (!sets.length) continue;
await pool.query(`UPDATE fabricut_catalog SET ${sets.join(', ')}, updated_at = now() WHERE dw_sku = $1`, vals);
wrote++;
}
console.log(`[localize] APPLIED — rewrote ${wrote} rows to DW public URLs.`);
await pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });