← 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); });