← back to Dw Launches

hash-images.js

71 lines

'use strict';
/*
 * Content-hash product images so the media library can dedup BYTE-IDENTICAL
 * photos that share the same picture under different filenames (colorway
 * reuploads: "Shagreen - Como Blue" / "- Olivine" / "- Gold" are the same file).
 *
 * Pulls image_urls from dw_unified (optionally filtered by --vendor), fetches
 * each with bounded concurrency, md5s the bytes, and merges into
 * data/img-hashes.json: { "<image_url>": "<md5>" }. Idempotent — skips urls
 * already hashed unless --force.
 *
 * Usage:
 *   node hash-images.js --vendor zoffany
 *   node hash-images.js                 # whole catalog (heavy)
 *   node hash-images.js --vendor kravet --force
 */
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { q } = require('./db');

const STORE = path.join(__dirname, 'data', 'img-hashes.json');
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
const CONC = 12;

function load() { try { return JSON.parse(fs.readFileSync(STORE, 'utf8')); } catch { return {}; } }
function save(m) {
  if (!fs.existsSync(path.dirname(STORE))) fs.mkdirSync(path.dirname(STORE), { recursive: true });
  fs.writeFileSync(STORE, JSON.stringify(m, null, 0));
}

async function md5(url) {
  const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'image/*,*/*' } });
  if (!r.ok) throw new Error('http ' + r.status);
  const buf = Buffer.from(await r.arrayBuffer());
  return crypto.createHash('md5').update(buf).digest('hex');
}

(async () => {
  const args = process.argv.slice(2);
  const vendorIdx = args.indexOf('--vendor');
  const vendor = vendorIdx >= 0 ? args[vendorIdx + 1] : null;
  const force = args.includes('--force');

  const params = [];
  let where = `image_url ilike 'http%'`;
  if (vendor) { params.push('%' + vendor + '%'); where += ` and vendor ilike $${params.length}`; }
  const rows = (await q(`select distinct image_url from shopify_products where ${where}`, params)).rows;
  const map = load();
  const todo = rows.map(r => r.image_url).filter(u => force || !map[u]);
  console.log(`[hash] ${vendor || 'ALL'}: ${rows.length} images, ${todo.length} to hash (${rows.length - todo.length} cached)`);

  let done = 0, ok = 0, fail = 0, i = 0;
  async function worker() {
    while (i < todo.length) {
      const url = todo[i++];
      try { map[url] = await md5(url); ok++; }
      catch { fail++; }
      if (++done % 50 === 0) { save(map); process.stdout.write(`  ${done}/${todo.length}\r`); }
    }
  }
  await Promise.all(Array.from({ length: CONC }, worker));
  save(map);

  // report dup stats for the queried set
  const hashes = rows.map(r => map[r.image_url]).filter(Boolean);
  const uniq = new Set(hashes).size;
  console.log(`\n[done] hashed ok:${ok} fail:${fail} · ${hashes.length} images → ${uniq} unique photos (${hashes.length - uniq} duplicates)`);
  process.exit(0);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });