← back to Fentucci Naturals

scripts/normalize.js

150 lines

#!/usr/bin/env node
// Normalize raw Tokiwa scrape → data/products.json
// - mfr_sku cleaned from image alt (real Tokiwa code, INTERNAL ONLY)
// - deterministic pattern grouping (series) per category
// - deterministic Fentucci Naturals rename: pattern series → Italian coastal town
//   (data/rename-map.json is the committed stable mapping; existing entries are never reassigned)
// - DW SKUs DWFN-100001+ assigned in (category, mfr_sku) sort order
const fs = require('fs');
const path = require('path');

const ROOT = path.join(__dirname, '..');
const RAW = path.join(ROOT, 'data', 'raw');

const MATERIAL_BY_CATEGORY = {
  'raffia': 'Raffia', 'sisal': 'Sisal', 'abaca': 'Abaca', 'bamboo': 'Bamboo',
  'hyacinth': 'Water Hyacinth', 'fine-arrowroot': 'Fine Arrowroot', 'heavy-jute': 'Heavy Jute',
  'washi': 'Washi Paper', 'washi2': 'Washi Paper', 'japanese-silk': 'Japanese Silk',
  'japanese-silk2': 'Japanese Silk', 'metal-leaf': 'Metal Leaf', 'metalleaf2': 'Metal Leaf',
  'metal-leaf-3': 'Metal Leaf', 'mica': 'Mica', 'glassbeads': 'Glass Beads',
  'paperweave': 'Paperweave', 'denim': 'Denim', 'textile': 'Textile',
  'wood-veneer': 'Wood Veneer', 'gildingwashi': 'Gilding Washi',
};

// Italian coastal cities/towns — assignment order is fixed; never reshuffle.
const TOWNS = [
  'Portofino','Amalfi','Positano','Sorrento','Rimini','Vernazza','Manarola','Riomaggiore',
  'Corniglia','Monterosso','Camogli','Rapallo','Chiavari','Portovenere','Lerici','Levanto',
  'Alassio','Sanremo','Bordighera','Cervo','Noli','Varigotti','Savona','Zoagli','Recco',
  'Nervi','Bogliasco','Sori','Ravello','Praiano','Maiori','Minori','Cetara','Atrani',
  'Vietri','Salerno','Capri','Ischia','Procida','Taormina','Cefalu','Siracusa','Tropea',
  'Scilla','Gallipoli','Otranto','Polignano','Monopoli','Trani','Vieste','Ancona','Pesaro',
  'Senigallia','Numana','Sirolo','Grado','Caorle','Ravenna','Cesenatico','Cattolica',
  'Riccione','Sperlonga','Gaeta','Terracina','Anzio','Nettuno','Ladispoli','Orbetello',
  'Talamone','Viareggio','Sestri','Loano','Imperia','Ventimiglia','Celle','Albisola',
  'Arenzano','Bonassola','Moneglia','Deiva','Framura','Tellaro','Fiascherino','Ameglia',
];

// Towns that word-boundary-collide with pattern names in the EXISTING live/draft
// Fentucci family on the DW store (748 titles, checked 2026-07-22 by
// scripts/retown-collisions.py). nextTown() must never hand these out — they'd
// duplicate a live pattern name. Re-derive with retown-collisions.py --dry-run.
const LIVE_COLLIDING_TOWNS = new Set([
  'Portofino','Amalfi','Positano','Sorrento','Rimini','Camogli','Lerici',
  'Sanremo','Bordighera','Noli','Savona','Ravello','Atrani','Salerno','Capri',
  'Ischia','Procida','Taormina','Siracusa','Tropea','Scilla','Gallipoli',
  'Otranto','Ancona','Pesaro','Ravenna','Gaeta',
  // candidate-pool towns retown-collisions.py also found colliding:
  'Cervia','Chioggia','Fano','Ostuni','Anzio',
]);

// junk/hero alts that are not products (no mfr code exists — skip, never "Unknown")
const SKIP_ALTS = new Set([
  '11717431_890431071037161_121594246500198', // gildingwashi facebook hero
  '風景1', 'hito', 'stones02', 'Bark and Woodchip', 'WS312',
]);

function cleanSku(alt) {
  let s = alt.trim().replace(/\.(jpe?g|png|webp)$/i, '').trim();
  s = s.replace(/(_ps|_item|_up|_image|_s|-up)$/i, '');
  // photo-variant / secondary-code suffixes: RR02-01_F18 → RR02-01
  const us = s.indexOf('_');
  if (us > 2 && /^[A-Z]+\d*-\d+$/i.test(s.slice(0, us))) s = s.slice(0, us);
  return s.trim();
}

// deterministic pattern-series extraction
function seriesOf(sku) {
  let m;
  if ((m = sku.match(/^(.*\d)([A-D])$/))) return m[1];                       // PD-1949A → PD-1949
  if ((m = sku.match(/^(005-\d+-003)-\d+$/))) return m[1];                   // gildingwashi 005-801-003-310
  if ((m = sku.match(/^([A-Z -]+NO)\.\d+$/i))) return m[1].replace(/ NO$/i, ''); // J-SILK NO.5 → J-SILK
  if ((m = sku.match(/^([A-Za-z ]+)#\d+$/))) return m[1].trim();             // Silver Line#3 → Silver Line
  if ((m = sku.match(/^([A-Z]+\d+)-\d+$/i))) return m[1];                    // RR02-01 → RR02
  if ((m = sku.match(/^([A-Z]+(?:-[A-Z]+)?)-(\d{5})$/i))) return `${m[1]}-${m[2].slice(0, 3)}`; // A-18001 → A-180
  if ((m = sku.match(/^([A-Z]+(?:-[A-Z]+)?)-(\d{4})$/i))) return `${m[1]}-${m[2].slice(0, 2)}`; // GB-1037 → GB-10
  if ((m = sku.match(/^([A-Z]{1,4})(\d{4})$/i))) return m[1] + m[2].slice(0, 2); // KP3120 → KP31
  if ((m = sku.match(/^(\d{4})$/))) return sku.slice(0, 2);                  // 5601 → 56
  if ((m = sku.match(/^([A-Za-z]+(?:-[A-Za-z]+)?)[-# ]?\d+$/))) return m[1]; // FL-001 → FL, FE-PW001 → FE-PW
  return sku; // singleton pattern
}

// load or seed the stable rename map
const mapPath = path.join(ROOT, 'data', 'rename-map.json');
const renameMap = fs.existsSync(mapPath) ? JSON.parse(fs.readFileSync(mapPath)) : {};

const items = [];
const flags = [];
for (const f of fs.readdirSync(RAW).sort()) {
  if (f.startsWith('_')) continue;
  const d = JSON.parse(fs.readFileSync(path.join(RAW, f)));
  if (d.kind !== 'category') continue;
  for (const it of d.items) {
    const sku = cleanSku(it.alt);
    if (SKIP_ALTS.has(sku)) { flags.push({ category: d.category, alt: it.alt, reason: 'hero/non-product skipped' }); continue; }
    if (!sku || sku.length > 24) { flags.push({ category: d.category, alt: it.alt, reason: 'unusable alt skipped' }); continue; }
    items.push({
      mfr_sku: sku,
      source_category: d.category,
      source_page: d.source_page,
      image_url: it.image_url, // original full-res (transform params already stripped)
      source_alt: it.alt,
    });
  }
}

// dedupe mfr_sku across categories (same SKU can appear on two pages, e.g. GB codes) — first (sorted) category wins
items.sort((a, b) => a.source_category.localeCompare(b.source_category) || a.mfr_sku.localeCompare(b.mfr_sku));
const bySku = new Map();
for (const it of items) {
  if (bySku.has(it.mfr_sku)) flags.push({ category: it.source_category, alt: it.source_alt, reason: `duplicate mfr_sku (kept in ${bySku.get(it.mfr_sku).source_category})` });
  else bySku.set(it.mfr_sku, it);
}
const products = [...bySku.values()];

// pattern grouping + town assignment (stable: existing map entries never change)
const patternKeys = [...new Set(products.map(p => `${p.source_category}|${seriesOf(p.mfr_sku)}`))].sort();
const usedTowns = new Set(Object.values(renameMap).map(v => v.town));
let ti = 0;
function nextTown() {
  while (ti < TOWNS.length && (usedTowns.has(TOWNS[ti]) || LIVE_COLLIDING_TOWNS.has(TOWNS[ti]))) ti++;
  if (ti < TOWNS.length) return TOWNS[ti++];
  // overflow: Roman-suffixed second pass
  const n = Object.keys(renameMap).length;
  return `${TOWNS[n % TOWNS.length]} ${['II','III','IV'][Math.floor(n / TOWNS.length) - 1] || 'II'}`;
}
for (const key of patternKeys) {
  if (!renameMap[key]) { const t = nextTown(); renameMap[key] = { town: t }; usedTowns.add(t); }
}
fs.writeFileSync(mapPath, JSON.stringify(renameMap, null, 2));

// DW SKUs: DWFN-100001+ in (category, mfr_sku) order — already sorted above
let seq = 100001;
for (const p of products) {
  const key = `${p.source_category}|${seriesOf(p.mfr_sku)}`;
  p.pattern_series = seriesOf(p.mfr_sku);
  p.pattern_key = key;
  p.pattern_name = renameMap[key].town;
  p.material = MATERIAL_BY_CATEGORY[p.source_category];
  p.brand = 'Fentucci Naturals';
  p.dw_sku = `DWFN-${seq++}`;
  p.image_local = `images/${p.mfr_sku.replace(/[^A-Za-z0-9.-]+/g, '_')}.jpg`;
}

fs.writeFileSync(path.join(ROOT, 'data', 'products.json'), JSON.stringify(products, null, 2));
fs.writeFileSync(path.join(ROOT, 'data', 'normalize-flags.json'), JSON.stringify(flags, null, 2));
console.log(`products: ${products.length}, patterns: ${patternKeys.length}, flags: ${flags.length}`);
const perCat = {};
for (const p of products) perCat[p.source_category] = (perCat[p.source_category] || 0) + 1;
console.log(perCat);