← back to Designer Wallcoverings

onboarding/sangetsu-lilycolor/lily-pricebook-parser.cjs

115 lines

#!/usr/bin/env node
/**
 * Lilycolor trade price-book parser (OFFLINE / read-only).
 * Parses the `pdftotext -layout` output of each wallcovering price book
 * (WILL / LIGHT / V-wall / MATERIALS / Import Selection) into one staging row
 * PER SKU, expanding the book's SKU-RANGE rows (e.g. "LW- 1059~1064") into
 * individual SKUs and capturing ALL specs: list price, m2 price, width, repeat,
 * functions, and fire ratings (不燃/準不燃/難燃 + cert numbers).
 *
 * The price is Japan LIST price (商品価格), NOT DW confirmed net cost — every row
 * is marked cost_confirmed:false / status 'staged-for-new' (Activation Gate intact).
 *
 * HARD: writes ONLY to local staging. No Shopify, no dw_unified, no publish.
 *   node lily-pricebook-parser.cjs            # parse all books in pricebooks/*.txt
 *   node lily-pricebook-parser.cjs WILL       # parse one book (debug, prints sample)
 */
const fs = require('fs');
const path = require('path');

const BOOK_DIR = path.join(__dirname, 'pricebooks');
const OUT = path.join(__dirname, 'staging', 'lilycolor-pricebook-staging.jsonl');

// function tokens (JA -> EN) seen in the 機能性/その他 columns
const FUNC = {
  'トップコート': 'topcoat', '表面強化': 'surface-reinforced', '消臭': 'deodorizing',
  '防かび': 'anti-mold', '抗菌': 'antibacterial', '通気性': 'breathable',
  '吸放湿': 'humidity-control', 'ホツレ止': 'fray-resistant', '防汚': 'stain-resistant',
  '耐クラック': 'crack-resistant', 'マイナスイオン': 'negative-ion', '蓄光': 'luminous',
};
const fullToHalf = (s) => s.replace(/[0-9]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0xFEE0));

// Expand a 品番 range token like "1059~1064" or "1111・1112" or "1058" into [1059..1064] etc.
function expandNums(raw) {
  const out = [];
  for (const grp of fullToHalf(raw).split(/[・,,、\s]+/).filter(Boolean)) {
    const r = grp.match(/^(\d+)[~〜~](\d+)$/);
    if (r) {
      const a = parseInt(r[1], 10), b = parseInt(r[2], 10), w = r[1].length;
      // end token may be abbreviated (e.g. 1059~64) — re-pad against the start
      const end = r[2].length < r[1].length ? parseInt(r[1].slice(0, r[1].length - r[2].length) + r[2], 10) : b;
      for (let n = a; n <= end && n - a < 200; n++) out.push(String(n).padStart(w, '0'));
    } else if (/^\d+$/.test(grp)) {
      out.push(grp);
    }
  }
  return out;
}

function parseBook(book, txt) {
  const rows = [];
  for (const line of txt.split('\n')) {
    // a product row: optional page#, then PREFIX(- ) NUMBERS, then unit (m/m2/枚/本), then two ¥ prices
    const m = line.match(/^\s*(?:(\d{1,3})\s+)?([A-Z]{2,4})-?\s*([\d0-9][\d0-9~〜~・,,、\s]*?)\s+(?:特\s+)?(m2?|m²|m²?|枚|本)\s+([\d,]+)\s+([\d,]+)/);
    if (!m) continue;
    const [, page, prefix, numraw, unit, price, m2price] = m;
    const nums = expandNums(numraw);
    if (!nums.length) continue;
    const rest = line.slice(m[0].length);
    const width = (line.match(/(\d{2}(?:\.\d)?)\s*cm/) || [])[1] || null;
    // repeat: two integers (タテ ヨコ) appearing after the 規格 field, or — / —
    const rep = rest.match(/\b(\d{1,3}(?:\.\d)?)\s+(\d{1,3}(?:\.\d)?)\b/);
    const functions = Object.keys(FUNC).filter((k) => line.includes(k)).map((k) => FUNC[k]);
    const fires = [...line.matchAll(/(不燃|準不燃|難燃)\s*([NQR]M-\d{3,4})/g)].map((f) => `${f[1]} ${f[2]}`);
    const antiMold = /防かび[\s\S]{0,3}○/.test(line) || functions.includes('anti-mold');
    for (const n of nums) {
      const sku = `${prefix}${n}`;
      rows.push({
        source: 'lilycolor',
        source_catalog: book,                 // WILL / LIGHT / V-wall / MATERIALS / Import Selection
        mfr_sku: sku,
        mfr_prefix: prefix,
        sku_range_raw: `${prefix}- ${numraw.trim()}`,
        unit: unit.replace('²', '2').replace('m', 'm'),
        list_price_yen: parseInt(price.replace(/,/g, ''), 10),
        m2_price_yen: parseInt(m2price.replace(/,/g, ''), 10),
        width: width ? `${width}cm` : null,
        repeat_tate_cm: rep ? parseFloat(rep[1]) : null,
        repeat_yoko_cm: rep ? parseFloat(rep[2]) : null,
        functions,
        anti_mold: antiMold,
        fire_ratings: [...new Set(fires)],
        page: page ? parseInt(page, 10) : null,
        // gates — all false; list price is NOT confirmed net cost
        deduped: false, settlement_checked: false, cost_confirmed: false,
        activation_ready: false, status: 'staged-for-new',
      });
    }
  }
  return rows;
}

function main() {
  const only = process.argv[2];
  const books = fs.readdirSync(BOOK_DIR).filter((f) => f.endsWith('.txt')).map((f) => f.replace('.txt', ''));
  const all = [];
  for (const book of books) {
    if (only && book.toLowerCase() !== only.toLowerCase()) continue;
    const rows = parseBook(book, fs.readFileSync(path.join(BOOK_DIR, `${book}.txt`), 'utf8'));
    all.push(...rows);
    process.stderr.write(`${book}: ${rows.length} SKUs (${new Set(rows.map((r) => r.mfr_prefix)).size} prefixes)\n`);
  }
  // de-dup SKUs WITHIN the pricebook set (a SKU can appear on multiple pages)
  const seen = new Map();
  for (const r of all) if (!seen.has(r.mfr_sku)) seen.set(r.mfr_sku, r);
  const uniq = [...seen.values()];
  if (!only) {
    fs.writeFileSync(OUT, uniq.map((r) => JSON.stringify(r)).join('\n') + '\n');
    console.log(JSON.stringify({ totalRows: all.length, uniqueSkus: uniq.length, books, out: OUT }, null, 2));
  } else {
    console.log(JSON.stringify(uniq.slice(0, 8), null, 2));
    console.log(`\n${only}: ${uniq.length} unique SKUs`);
  }
}
main();