← back to Fentucci Naturals

scripts/scrape.js

95 lines

#!/usr/bin/env node
// Fentucci Naturals — Tokiwa USA (INTERNAL vendor name only, never customer-facing)
// $0 plain-fetch scrape of Wix Thunderbolt SSR pages on www.tokiwausa.com.
// Product/swatch data is server-rendered: <img src="https://static.wixstatic.com/media/<uri>/..." alt="<mfr-sku>.jpg">
// (The "warmupData" JSON on this site is empty of gallery items — SSR img tags are the real feed.)
// Emits data/raw/<category>.json

const fs = require('fs');
const path = require('path');

const BASE = 'https://www.tokiwausa.com';

// 21 product category pages (confirmed complete against the /product index page 2026-07-22)
const CATEGORIES = [
  'raffia', 'sisal', 'abaca', 'bamboo', 'hyacinth', 'fine-arrowroot',
  'heavy-jute', 'washi', 'washi2', 'japanese-silk', 'japanese-silk2',
  'metal-leaf', 'metalleaf2', 'metal-leaf-3', 'mica', 'glassbeads',
  'paperweave', 'denim', 'textile', 'wood-veneer', 'gildingwashi',
];

// Collection catalog pages — Pro Gallery lookbook spreads, scraped for completeness
// (items generally lack SKU-named alts; anything SKU-named is captured)
const COLLECTIONS = [
  'reflectionvol1', 'reflectionsvol2', 'greenbookcollection',
  'glassbeadscollection', 'textilecollection',
];

// site-chrome / non-product alts to drop
const JUNK_ALTS = new Set(['', 'Logo.JPG', 'Logo', 'Facebook', 'Instagram']);

function extractItems(html) {
  const items = [];
  const numericItems = [];
  const re = /<img[^>]*src="https:\/\/static\.wixstatic\.com\/media\/([^"\/]+)\/[^"]*"[^>]*alt="([^"]*)"[^>]*>/g;
  let m;
  while ((m = re.exec(html)) !== null) {
    const uri = m[1];
    const alt = m[2];
    if (JUNK_ALTS.has(alt)) continue;
    const base = alt.replace(/\.(jpe?g|png|webp)$/i, '');
    // heroes/banners: "_edited" crops and "Untitled"
    if (/_edited/i.test(base)) continue;
    if (/^untitled/i.test(base)) continue;
    if (/^\d{1,3}$/.test(base)) {
      // numeric filename — real SKU may live in an adjacent "XXX NO.n" rich-text label
      numericItems.push({ uri, alt, num: parseInt(base, 10) });
      continue;
    }
    items.push({ uri, alt, image_url: `https://static.wixstatic.com/media/${uri}` });
  }
  // Label-pairing pass (japanese-silk pattern): page carries rich-text labels like
  // "J-SILK NO.5" and images named "5.jpg" — alt number maps 1:1 to label number.
  if (numericItems.length) {
    const labels = [...new Set([...html.matchAll(/>([A-Z][A-Z -]{1,20}NO\.\s?(\d+))</g)].map(x => x[1]))];
    const byNum = new Map(labels.map(l => [parseInt(l.match(/NO\.\s?(\d+)/)[1], 10), l]));
    for (const it of numericItems) {
      const label = byNum.get(it.num);
      if (label) items.push({ uri: it.uri, alt: label, image_url: `https://static.wixstatic.com/media/${it.uri}`, paired_from: it.alt });
      // numeric image with no matching label = banner/hero — skip (never "Unknown")
    }
  }
  // dedupe by uri (same swatch can appear twice, e.g. og:image + grid)
  const seen = new Set();
  return items.filter(it => (seen.has(it.uri) ? false : (seen.add(it.uri), true)));
}

async function fetchPage(slug) {
  const url = `${BASE}/${slug}`;
  const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' } });
  if (!res.ok) throw new Error(`${slug}: HTTP ${res.status}`);
  return { url, html: await res.text() };
}

(async () => {
  const outDir = path.join(__dirname, '..', 'data', 'raw');
  fs.mkdirSync(outDir, { recursive: true });
  const summary = [];
  for (const slug of [...CATEGORIES, ...COLLECTIONS]) {
    try {
      const { url, html } = await fetchPage(slug);
      const items = extractItems(html);
      const kind = COLLECTIONS.includes(slug) ? 'collection' : 'category';
      const payload = { source_page: url, category: slug, kind, scraped_at: new Date().toISOString(), items };
      fs.writeFileSync(path.join(outDir, `${slug}.json`), JSON.stringify(payload, null, 2));
      summary.push({ slug, kind, items: items.length });
      console.log(`${slug} (${kind}): ${items.length} items`);
    } catch (e) {
      summary.push({ slug, error: e.message });
      console.error(`${slug}: FAILED — ${e.message}`);
    }
    await new Promise(r => setTimeout(r, 800)); // be polite
  }
  fs.writeFileSync(path.join(outDir, '_summary.json'), JSON.stringify(summary, null, 2));
})();