← back to Designer Wallcoverings

onboarding/sangetsu-lilycolor/lilycolor-feed-scraper.cjs

89 lines

#!/usr/bin/env node
/**
 * Lilycolor feed-first scraper (STAGING ONLY — OFFLINE).
 * Pulls the full shop.lilycolor.co.jp Shopify feed, normalizes each product to a DW
 * staging record, and writes JSONL to staging/lilycolor-staging.jsonl.
 *
 * HARD: writes ONLY to a local staging file. No Shopify, no dw_unified, no publish.
 * JA->EN translation + final pricing/dedup happen at the gated enrichment/activation stage.
 *
 * Usage: node lilycolor-feed-scraper.cjs [maxPages]   (default 30 pages = full catalog)
 */
const https = require('https');
const fs = require('fs');
const path = require('path');

const BASE = 'https://shop.lilycolor.co.jp/products.json';
const MAX_PAGES = parseInt(process.argv[2] || '30', 10);
const OUT_DIR = path.join(__dirname, 'staging');
const OUT = path.join(OUT_DIR, 'lilycolor-staging.jsonl');

function get(url) {
  return new Promise((resolve, reject) => {
    https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (DW-onboarding-recon)' } }, (res) => {
      let body = '';
      res.on('data', (c) => (body += c));
      res.on('end', () => { try { resolve(JSON.parse(body)); } catch (e) { reject(e); } });
    }).on('error', reject);
  });
}

// Pull a labelled spec out of the body_html spec table (有効巾 / 品番 / etc.)
function specFrom(html, label) {
  if (!html) return null;
  const re = new RegExp(`${label}[\\s\\S]{0,60}?<span>([^<]+)</span>`);
  const m = html.match(re);
  return m ? m[1].trim() : null;
}

function normalize(p) {
  const variants = p.variants || [];
  const sampleVar = variants.find((v) => /sample|サンプル/i.test(v.title || '') || /_{3}sample/.test(v.handle || ''));
  const productVar = variants.find((v) => v !== sampleVar) || variants[0] || {};
  const mfrSku = String((productVar.sku || '')).replace(/-nori$/i, '');
  const prefix = (mfrSku.match(/^([A-Za-z]+)/) || [])[1] || null;
  return {
    source: 'lilycolor',
    source_feed: 'shop.lilycolor.co.jp/products.json',
    shopify_source_id: p.id,
    handle: p.handle,
    title_ja: p.title,
    title_en: null,                 // TODO: JA->EN at enrichment stage (gated)
    mfr_sku: mfrSku || null,
    mfr_prefix: prefix,             // LW / LL / LV / LB / EC ... -> dedup key vs dw_sku_registry
    width: specFrom(p.body_html, '有効巾'),      // e.g. 92.5cm
    composition: /塩化ビニル/.test(p.body_html || '') ? 'Vinyl (塩化ビニル)' : null,
    fire_grade: (p.body_html || '').match(/F☆+|準不燃|不燃/)?.[0] || null,
    body_html_ja: p.body_html,
    images: (p.images || []).map((i) => i.src),
    has_sample_variant: !!sampleVar,
    sample_handle: sampleVar ? sampleVar.handle : null,
    vendor_raw: p.vendor || 'Lilycolor',
    product_type: p.product_type || null,
    // gates (all false until the gated stage runs):
    deduped: false, settlement_checked: false, cost_confirmed: false,
    activation_ready: false, status: 'staged-for-new',
  };
}

(async () => {
  if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });
  const out = fs.createWriteStream(OUT);
  let total = 0;
  const prefixCounts = {};
  for (let page = 1; page <= MAX_PAGES; page++) {
    const d = await get(`${BASE}?limit=250&page=${page}`);
    const ps = (d && d.products) || [];
    if (!ps.length) break;
    for (const p of ps) {
      const rec = normalize(p);
      if (rec.mfr_prefix) prefixCounts[rec.mfr_prefix] = (prefixCounts[rec.mfr_prefix] || 0) + 1;
      out.write(JSON.stringify(rec) + '\n');
      total++;
    }
    process.stderr.write(`page ${page}: +${ps.length} (staged ${total})\n`);
  }
  out.end();
  console.log(JSON.stringify({ staged: total, out: OUT, mfrPrefixCounts: prefixCounts }, null, 2));
})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });