← back to Dw Cowtan Recrawl

recrawl-cowtan.js

163 lines

#!/usr/bin/env node
// recrawl-cowtan.js — additive all_images + full-spec backfill for cowtan_tout.
//
// WHY THIS EXISTS: designs.cowtan.com re-platformed to an Alpine.js SPA after the
// Apr-30 crawl. The old `POST /Search` AJAX endpoint that scrape-cowtan.js depends on
// now returns HTTP 404, so the deployed scraper is broken AND it never captured
// all_images in the first place (it built a single image_url from StockCode).
//
// This driver uses the NEW, verified feed-first API:
//   GET /api/Product/Detail/<StockCode>  ->  JSON array of sibling colourways, each with
//     full specs (Width, RepeatV/H, Composition, Qualities, Price, InStock, Type) and
//     RoomShotPictureIDs (room-scene image IDs).
// Hi-res flatshot per colourway is built from the SPA's own template:
//   https://d2mq91o692rj7w.cloudfront.net/flatshots/2017-main/<urlSafeStockCode>_m.jpg
// (thumb = 2017-thumb/<code>_t.jpg). urlSafeStockCode = StockCode.replaceAll('/','-').
//
// all_images per row = JSON array of [ main flatshot, ...room-shot urls ] for THAT colourway.
// Each sibling colourway is its own mfr_sku row and gets its own all_images.
//
// ADDITIVE & IDEMPOTENT: only UPDATEs all_images / specs columns on existing rows
// (matched by mfr_sku). Never deletes, never inserts net-new SKUs, never archives.
// One Detail fetch covers a whole pattern (all its colourways), so we fetch by distinct
// ProductCode to minimise requests (~one call per pattern, not per colourway).
//
// USAGE (run ON Kamatera prod, gated — see pending-approval/cowtan_tout-recrawl.md):
//   node recrawl-cowtan.js --net-new --all-images           # backfill rows missing all_images
//   node recrawl-cowtan.js --net-new --all-images --limit 50 --dry-run   # safe probe
//
// ROOM-SHOT URL PATTERN: RoomShotPictureIDs was empty on the probed samples. Before the
// full prod run, confirm the room-shot CDN path on a SKU that HAS room shots and set
// ROOMSHOT_URL() below. Until confirmed, room shots are skipped (main flatshot still
// populates all_images, clearing the gate). This is the one item to verify when attended.

const https = require('https');

// ---- config -------------------------------------------------------------
const TABLE = process.env.COWTAN_TABLE || 'vendor_catalog';       // local: vendor_catalog (vendor_code filter); prod fleet table may be cowtan_tout_catalog
const VENDOR_CODE = 'cowtan_tout';
const CDN = 'https://d2mq91o692rj7w.cloudfront.net/flatshots/';
const API = 'https://designs.cowtan.com';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const CONCURRENCY = 3;        // polite — vendor is the same one we re-platformed off of
const SLEEP_MS = 350;

const argv = process.argv.slice(2);
const DRY = argv.includes('--dry-run');
const LIMIT = (() => { const i = argv.indexOf('--limit'); return i > -1 ? parseInt(argv[i + 1], 10) : 0; })();

// pg pool: prefer the fleet scraper-utils pool if present (prod), else local DATABASE_URL.
let pool;
try {
  ({ pool } = require('/root/DW-Agents/vendor-scrapers/scraper-utils'));
} catch (_) {
  const { Pool } = require('pg');
  pool = new Pool({ connectionString: process.env.DATABASE_URL || 'postgres://stevestudio2@localhost/dw_unified' });
}

// ---- helpers ------------------------------------------------------------
const sleep = (ms) => new Promise(r => setTimeout(r, ms + Math.floor(Math.random() * 200)));
const urlSafe = (code) => String(code).replaceAll('/', '-');
const mainFlatshot = (code) => CDN + '2017-main/' + urlSafe(code) + '_m.jpg';
// CONFIRM-WHEN-ATTENDED: room-shot URL builder. Returns [] until the pattern is verified.
const ROOMSHOT_URL = (picId) => null; // e.g. CDN + 'rooms/' + picId + '.jpg' — TBD, see header note.

function getJson(path, maxRedirects = 4) {
  return new Promise((resolve, reject) => {
    const req = https.request({ hostname: 'designs.cowtan.com', path, method: 'GET', timeout: 25000,
      headers: { 'User-Agent': UA, 'Accept': 'application/json, */*', 'X-Requested-With': 'XMLHttpRequest' } }, res => {
      if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location && maxRedirects > 0) {
        res.resume();
        const loc = res.headers.location.replace(API, '');
        return getJson(loc, maxRedirects - 1).then(resolve).catch(reject);
      }
      if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); }
      let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error('JSON ' + e.message)); } });
    });
    req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
    req.end();
  });
}

function buildAllImages(entry) {
  const imgs = [mainFlatshot(entry.StockCode)];
  for (const pid of (entry.RoomShotPictureIDs || [])) { const u = ROOMSHOT_URL(pid); if (u) imgs.push(u); }
  return [...new Set(imgs)];
}

function specJson(entry) {
  return {
    width: entry.Width || null, repeat_v: entry.RepeatV || null, repeat_h: entry.RepeatH || null,
    finish: entry.Finish || null, type: entry.Type || null, weight: entry.Weight || null,
    composition: entry.Composition || null, qualities: entry.Qualities || null,
    testing: entry.Testing || null, price: entry.Price || null, in_stock: entry.InStock ?? null,
    availability: entry.Availability || null, pattern_book: entry.PatternBook || null,
    brand_name: entry.BrandName || null, room_shot_picture_ids: entry.RoomShotPictureIDs || [],
  };
}

// ---- main ---------------------------------------------------------------
(async () => {
  // worklist: rows still missing all_images. We crawl by distinct ProductCode (pattern)
  // since one Detail call returns every colourway of that pattern.
  // Self-heal clause: also re-select rows whose all_images was previously written with a
  // 3rd-party share-link leak (pinterest/facebook), so a bad earlier run cures itself.
  // Images are built host-anchored to cowtan's own CDN, so a clean re-run overwrites junk.
  const contam = `all_images ILIKE '%pinterest%' OR all_images ILIKE '%facebook%'`;
  const where = `vendor_code = $1 AND (all_images IS NULL OR all_images = '' OR all_images = '[]' OR all_images = 'null' OR ${contam})`;
  const q = `SELECT DISTINCT split_part(mfr_sku,'-',1) AS product_code, min(mfr_sku) AS seed_sku
             FROM ${TABLE} WHERE ${where} GROUP BY 1 ORDER BY 1` + (LIMIT ? ` LIMIT ${LIMIT}` : '');
  const { rows } = await pool.query(q, [VENDOR_CODE]).catch(async (e) => {
    // fleet per-vendor table has no vendor_code column — fall back to plain table scan.
    if (/vendor_code/.test(e.message)) {
      const w2 = `(all_images IS NULL OR all_images='' OR all_images='[]' OR all_images='null' OR all_images ILIKE '%pinterest%' OR all_images ILIKE '%facebook%')`;
      return pool.query(`SELECT DISTINCT split_part(mfr_sku,'-',1) AS product_code, min(mfr_sku) AS seed_sku FROM ${TABLE} WHERE ${w2} GROUP BY 1 ORDER BY 1` + (LIMIT ? ` LIMIT ${LIMIT}` : ''));
    }
    throw e;
  });

  console.log(`Re-crawling ${rows.length} distinct cowtan patterns (table=${TABLE}, dry=${DRY})...`);
  let patterns = 0, skusUpdated = 0, errs = 0, idx = 0;

  async function worker() {
    while (idx < rows.length) {
      const i = idx++;
      const seed = rows[i].seed_sku;
      try {
        const detail = await getJson('/api/Product/Detail/' + encodeURIComponent(seed));
        const entries = Array.isArray(detail) ? detail : Object.values(detail);
        for (const e of entries) {
          if (!e || !e.StockCode) continue;
          const all = JSON.stringify(buildAllImages(e));
          const specs = JSON.stringify(specJson(e));
          if (DRY) { skusUpdated++; if (skusUpdated <= 5) console.log('  [dry]', e.StockCode, all); continue; }
          // additive UPDATE only — never insert/delete. Match by mfr_sku within this vendor.
          const r = await pool.query(
            `UPDATE ${TABLE} SET all_images = $1, specs = COALESCE(specs,'{}'::jsonb) || $2::jsonb, updated_at = NOW()
             WHERE mfr_sku = $3` + (/vendor_catalog/.test(TABLE) ? ` AND vendor_code = '${VENDOR_CODE}'` : ''),
            [all, specs, e.StockCode]
          ).catch(async (err) => {
            // table may not have a jsonb specs column — degrade to all_images-only.
            if (/column .*specs/.test(err.message)) {
              return pool.query(`UPDATE ${TABLE} SET all_images = $1, updated_at = NOW() WHERE mfr_sku = $2` + (/vendor_catalog/.test(TABLE) ? ` AND vendor_code='${VENDOR_CODE}'` : ''), [all, e.StockCode]);
            }
            throw err;
          });
          if (r.rowCount) skusUpdated += r.rowCount;
        }
        patterns++;
        if (patterns % 50 === 0) console.log(`  ${patterns}/${rows.length} patterns | ${skusUpdated} skus updated | ${errs} errs`);
        await sleep(SLEEP_MS);
      } catch (e) {
        errs++;
        if (errs % 10 === 0) console.error(`  err at ${seed}: ${e.message}`);
        await sleep(800);
      }
    }
  }

  await Promise.all(Array.from({ length: CONCURRENCY }, worker));
  console.log(`\nDONE. patterns=${patterns} sku_rows_updated=${skusUpdated} errors=${errs}`);
  await (pool.end ? pool.end() : Promise.resolve());
})().catch(e => { console.error('FATAL', e); (pool.end ? pool.end() : Promise.resolve()).finally(() => process.exit(1)); });