← back to Patternbank Archive

scripts/discover.js

138 lines

#!/usr/bin/env node
/**
 * Discover phase: walk patternbank categories, enqueue PDP URLs into crawl_queue.
 *
 * Patternbank URL structure (confirmed 2026-05-13):
 *   - Top categories: /categories/<top-id-name>            e.g. /categories/1-womenswear
 *   - Subcategories:  /categories/<top>/<sub>              e.g. /categories/1-womenswear/10-floral
 *   - Pagination:     ?page=N&sort=recent&licence=all
 *   - PDP:            /<designer-slug>/designs/<id>-<slug>
 *
 * Usage:
 *   node scripts/discover.js                                   # discover top cats from root
 *   node scripts/discover.js --top=1-womenswear --max-pages=5
 *   node scripts/discover.js --cat=/categories/1-womenswear/10-floral --max-pages=10
 *   node scripts/discover.js --auto --max-pages=3              # walk every top × sub
 */
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const cheerio = require('cheerio');
const { withPage, sleep, UA } = require('../src/browser');
const db = require('../src/db');

const RATE = Number(process.env.RATE_LIMIT_MS || 2500);
const ORIGIN = 'https://patternbank.com';

function args() {
  const a = {};
  for (const arg of process.argv.slice(2)) {
    const m = arg.match(/^--([^=]+)=(.+)$/);
    if (m) a[m[1]] = m[2];
    else if (arg.startsWith('--')) a[arg.slice(2)] = true;
  }
  return a;
}

const PDP_RE = /^\/([A-Za-z0-9_-]+)\/designs\/(\d+-[a-z0-9-]+)\/?$/;

function pdpLinks($) {
  const urls = new Set();
  $('a[href]').each((_, el) => {
    const href = ($(el).attr('href') || '').split('?')[0].split('#')[0];
    const m = href.match(PDP_RE);
    if (m) urls.add(ORIGIN + href);
  });
  return [...urls];
}

function categoryLinks($) {
  // /categories/<top> and /categories/<top>/<sub>
  const tops = new Set(), subs = new Set();
  $('a[href]').each((_, el) => {
    const href = ($(el).attr('href') || '').split('?')[0];
    const mTop = href.match(/^\/categories\/(\d+-[a-z0-9-]+)$/);
    if (mTop) tops.add(href);
    const mSub = href.match(/^\/categories\/(\d+-[a-z0-9-]+)\/(\d+-[a-z0-9-]+)$/);
    if (mSub) subs.add(href);
  });
  return { tops: [...tops].map(p => ORIGIN + p), subs: [...subs].map(p => ORIGIN + p) };
}

async function fetchAndParse(url) {
  return withPage(async (page) => {
    const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
    // Patternbank renders product cards on initial HTML — short scroll to trigger any lazy
    await page.waitForTimeout(1500);
    for (let s = 0; s < 6; s++) {
      await page.evaluate(() => window.scrollBy(0, 2200));
      await page.waitForTimeout(400);
    }
    return { status: resp ? resp.status() : 0, html: await page.content(), finalUrl: page.url() };
  });
}

async function crawlListing(baseUrl, maxPages, runId) {
  const counters = { pages: 0, enqueued: 0, errors: 0 };
  for (let p = 1; p <= maxPages; p++) {
    const url = `${baseUrl}?licence=all&sort=recent&page=${p}`;
    console.log(`[discover] page ${p}/${maxPages}: ${url}`);
    let r;
    try { r = await fetchAndParse(url); }
    catch (e) {
      console.warn(`[discover] err ${e.message}`);
      counters.errors++; await db.markQueue(url, 0, e.message.slice(0,200)); break;
    }
    await db.markQueue(url, r.status, r.status>=400 ? `HTTP ${r.status}` : null);
    if (r.status >= 400) { counters.errors++; break; }
    const $ = cheerio.load(r.html);
    const pdps = pdpLinks($);
    for (const u of pdps) { await db.enqueue(u, 'pdp', 50); counters.enqueued++; }
    counters.pages++;
    console.log(`[discover]   page=${p} +${pdps.length} pdps (cumulative ${counters.enqueued})`);
    if (pdps.length === 0) break; // ran past end
    await sleep(RATE);
  }
  await db.finishRun(runId, {
    pages_fetched: counters.pages,
    patterns_seen: counters.enqueued,
    errors: counters.errors,
  });
  return counters;
}

async function discoverCategoryTree() {
  // Hit /categories/ root (or any category page) to discover all top × sub
  const r = await fetchAndParse(ORIGIN + '/categories/1-womenswear');
  const $ = cheerio.load(r.html);
  return categoryLinks($);
}

(async () => {
  const a = args();

  if (a.auto) {
    const maxPages = Number(a['max-pages'] || 3);
    console.log(`[discover] AUTO mode — walking every top×sub @ maxPages=${maxPages}`);
    const tree = await discoverCategoryTree();
    console.log(`[discover] discovered ${tree.tops.length} tops, ${tree.subs.length} subs`);
    const runId = await db.startRun('discover', `auto subs=${tree.subs.length} maxPages=${maxPages}`);
    let totalEnq = 0;
    for (const sub of tree.subs) {
      const c = await crawlListing(sub, maxPages, runId);
      totalEnq += c.enqueued;
      console.log(`[discover] sub=${sub} +${c.enqueued}`);
    }
    console.log(`[discover] AUTO done — totalEnqueued=${totalEnq}`);
    await db.pool.end(); return;
  }

  let start = a.cat
    || (a.top ? `${ORIGIN}/categories/${a.top}` : `${ORIGIN}/categories/1-womenswear/10-floral`);
  if (start.startsWith('/')) start = ORIGIN + start;
  const maxPages = Number(a['max-pages'] || 3);
  console.log(`[discover] start=${start} maxPages=${maxPages}`);
  const runId = await db.startRun('discover', `start=${start} maxPages=${maxPages}`);
  const counters = await crawlListing(start, maxPages, runId);
  console.log(`[discover] done`, counters);
  await db.pool.end();
})().catch(e => { console.error(e); process.exit(1); });