← 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();