← back to Dwjs Consolidation 2026 04 23

momentum-scrape/scrape.js

102 lines

// Parallel Playwright scraper: visit each /products/<sku> on Momentum, extract L-code, save to PG.
// 8 contexts × 1 page each, queue-driven, resume-safe (skips rows where lcode IS NOT NULL).
const { chromium } = require('playwright');
const { Pool } = require('pg');

const CONCURRENCY = parseInt(process.env.CONC || '8', 10);
const DELAY_MS = parseInt(process.env.DELAY || '300', 10);
const pool = new Pool({ database: 'dw_unified', user: 'stevestudio2', host: '/tmp' });

let processed = 0, ok = 0, fail = 0, notFound = 0;
const start = Date.now();

async function fetchTodo() {
  const r = await pool.query(`
    SELECT momentum_sku FROM momentum_colorways
    WHERE momentum_sku IS NOT NULL AND momentum_sku <> '' AND lcode IS NULL
    ORDER BY momentum_sku
  `);
  return r.rows.map(r => r.momentum_sku);
}

async function worker(workerId, browser, queue) {
  const ctx = await browser.newContext({
    viewport: { width: 1280, height: 800 },
    userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36',
  });
  const page = await ctx.newPage();
  // Block heavy assets — we only need HTML for the SKU
  await page.route('**/*', route => {
    const t = route.request().resourceType();
    if (['image','font','media','stylesheet'].includes(t)) return route.abort();
    return route.continue();
  });

  while (queue.length) {
    const sku = queue.shift();
    if (!sku) break;
    const url = `https://www.momentumtextilesandwalls.com/products/${sku}`;
    try {
      const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 25000 });
      const status = resp?.status() || 0;
      if (status === 404) {
        await pool.query(`UPDATE momentum_colorways SET lcode = '__NOT_FOUND__', lcode_scraped_at = now() WHERE momentum_sku = $1`, [sku]);
        notFound++;
      } else if (status >= 200 && status < 400) {
        // Wait for "SKU:" text + alphanumeric code to be hydrated
        let lcode = null;
        for (let attempt = 0; attempt < 2; attempt++) {
          try { await page.waitForFunction(() => /SKU:\s*[A-Z0-9][A-Z0-9-]+/i.test(document.body.innerText), { timeout: 12000 }); } catch {}
          lcode = await page.evaluate(() => {
            const matches = [...document.body.innerText.matchAll(/SKU:\s*([A-Z0-9][A-Z0-9-]+)/gi)].map(m => m[1].trim().toUpperCase());
            // Prefer hyphenated short code (the L-code) if present; otherwise return whatever printed SKU exists
            const shortCodes = matches.filter(c => c.includes('-') && !/^\d+$/.test(c));
            return shortCodes[0] || matches[0] || null;
          }).catch(() => null);
          if (lcode) break;
          if (attempt === 0) await page.waitForTimeout(2000);
        }
        if (lcode) {
          await pool.query(`UPDATE momentum_colorways SET lcode = $1, lcode_scraped_at = now() WHERE momentum_sku = $2`, [lcode, sku]);
          ok++;
        } else {
          await pool.query(`UPDATE momentum_colorways SET lcode = '__NO_SKU__', lcode_scraped_at = now() WHERE momentum_sku = $1`, [sku]);
          fail++;
        }
      } else {
        fail++;
      }
    } catch (e) {
      fail++;
    }
    processed++;
    if (processed % 25 === 0) {
      const elapsed = (Date.now() - start) / 1000;
      const rate = (processed / elapsed).toFixed(1);
      const remaining = queue.length;
      const eta = remaining > 0 ? Math.round(remaining / parseFloat(rate)) : 0;
      console.log(`  [w${workerId}] processed=${processed} ok=${ok} miss=${notFound} fail=${fail} | rate=${rate}/s | remaining=${remaining} | ETA=${Math.round(eta/60)}m`);
    }
    if (DELAY_MS) await page.waitForTimeout(DELAY_MS + Math.random() * 200);
  }

  await page.close();
  await ctx.close();
}

(async () => {
  console.log(`Loading work queue from PG...`);
  const queue = await fetchTodo();
  console.log(`Queue: ${queue.length} momentum SKUs to scrape`);
  console.log(`Concurrency: ${CONCURRENCY} workers, delay=${DELAY_MS}ms\n`);

  const browser = await chromium.launch({ headless: true });
  const workers = Array.from({ length: CONCURRENCY }, (_, i) => worker(i + 1, browser, queue));
  await Promise.all(workers);
  await browser.close();
  await pool.end();

  const elapsed = ((Date.now() - start) / 1000).toFixed(1);
  console.log(`\nDONE. processed=${processed} ok=${ok} not_found=${notFound} fail=${fail} | ${elapsed}s`);
})();