← back to Designerwallcoverings
scripts/banned-word-rewrite-plan.js
217 lines
#!/usr/bin/env node
/**
* banned-word-rewrite-plan.js (READ-ONLY / STAGE-ONLY)
*
* Builds the "Wallpaper"->"Wallcovering" rewrite PLAN + AI-preamble strip list
* from the existing live audit JSON. Writes NO data to Shopify. Stages a
* dry-run preview CSV only.
*
* Source : ~/.claude/yolo-queue/banned-word-live-audit-2026-06-15.json
* Output : data/price-sheets/banned-word-rewrite-plan-2026-06-15.csv
* {vendor, product_id, field, before, after, guard_flag}
*
* The audit JSON contains per-vendor COUNTS plus a representative SAMPLE set
* (it is NOT a full per-product dump). So the CSV is a dry-run PREVIEW built
* from the available samples + the 68-row preamble list, with the transform
* and guard logic applied exactly as the real push would apply it.
*/
'use strict';
const fs = require('fs');
const AUDIT = '/Users/stevestudio2/.claude/yolo-queue/banned-word-live-audit-2026-06-15.json';
const OUT = '/Users/stevestudio2/Projects/designerwallcoverings/data/price-sheets/banned-word-rewrite-plan-2026-06-15.csv';
const audit = JSON.parse(fs.readFileSync(AUDIT, 'utf8'));
// ---------------------------------------------------------------------------
// TRANSFORM SPEC — case-preserving Wallpaper->Wallcovering with guards
// ---------------------------------------------------------------------------
// Replace the standalone token "Wallpaper" only. Word-boundary + a negative
// lookahead that blocks the dangerous suffix/compound cases.
//
// Wallpaper -> Wallcovering (Title case)
// WALLPAPER -> WALLCOVERING (all caps)
// wallpaper -> wallcovering (lower)
//
// GUARDS — these must NOT be blind-replaced:
// 1. "wallpapered" -> verb/adjective inflection (e.g. "freshly wallpapered")
// 2. "wallpaper paste" -> product term, not the wall covering itself
// 3. "wallpaper steamer" -> tool term
// 4. "wallpaper glue"/"primer"/"removal"/"installer" -> install-supply terms
// 5. Vendor-name context -> "<word> Wallpaper" where Wallpaper is part of
// a proper brand name (Malibu Wallpaper, Scalamandre Wallpaper, etc.) OR
// the trailing "| <Vendor>" / "— <Vendor>" credit segment.
//
// Color/pattern names are preserved exactly — we only touch the literal token.
// Negative-lookahead suffixes that mean "do not replace this occurrence"
const SUFFIX_GUARD = /^(ed|ing|s\b)/i; // wallpapered / wallpapering
const TRAILING_NOUN_GUARD = /^\s+(paste|steamer|glue|primer|remover|removal|stripper|installer|installation|adhesive|brush|smoother|hanging|hanger)\b/i;
// Vendor names (from the live catalog) whose proper name literally contains
// "Wallpaper" — the token inside these must be preserved. Also covers the
// trailing "| <Vendor>" credit segment.
const VENDOR_NAMES_WITH_TOKEN = [
'Malibu Wallpaper', 'Scalamandre Wallpaper', 'Roberto Cavalli Wallpaper',
'Missoni Wallpaper', 'MC Escher Wallpaper', 'Laura Ashley Wallpaper',
'Wallpaper NYC', 'DW Exclusive Wallpaper', 'PS Removable Wallpaper',
'Apartment Wallpaper'
];
function casePreservedReplacement(match) {
if (match === match.toUpperCase()) return 'WALLCOVERING';
if (match === match.toLowerCase()) return 'wallcovering';
if (match[0] === match[0].toUpperCase()) return 'Wallcovering';
return 'wallcovering';
}
/**
* Apply the guarded transform to a single string.
* Returns { after, guardFlag } where guardFlag is '' if a clean replace
* happened, or a reason code if any occurrence was guarded/skipped.
*/
function transform(str) {
if (str == null) return { after: str, guardFlag: 'NULL_FIELD', changed: false };
let guardFlag = '';
let changed = false;
// First, protect vendor-name spans so the regex below never touches them.
// We blank them to a sentinel, transform, then restore.
const protectedSpans = [];
let work = str;
for (const vn of VENDOR_NAMES_WITH_TOKEN) {
let idx;
while ((idx = work.indexOf(vn)) !== -1) {
const token = ` ${protectedSpans.length} `;
protectedSpans.push(vn);
work = work.slice(0, idx) + token + work.slice(idx + vn.length);
guardFlag = guardFlag || 'VENDOR_NAME_TOKEN';
}
}
// Regex: the literal token "wallpaper" on a left word boundary.
const re = /\bwallpaper/gi;
const after = work.replace(re, (m, offset, full) => {
const rest = full.slice(offset + m.length);
// Suffix guard: wallpapered / wallpapering / wallpapers
if (SUFFIX_GUARD.test(rest)) {
guardFlag = guardFlag || 'SUFFIX_INFLECTION';
return m; // keep original
}
// Trailing-noun guard: wallpaper paste/steamer/glue...
if (TRAILING_NOUN_GUARD.test(rest)) {
guardFlag = guardFlag || 'INSTALL_SUPPLY_TERM';
return m;
}
changed = true;
return casePreservedReplacement(m);
});
// Restore protected vendor spans
let restored = after;
protectedSpans.forEach((vn, i) => {
restored = restored.replace(` ${i} `, vn);
});
return { after: restored, guardFlag, changed };
}
// ---------------------------------------------------------------------------
// AI-PREAMBLE strip signatures (body_html leaks)
// ---------------------------------------------------------------------------
// From the audit, the preamble snippets observed are:
// "```html" (markdown code-fence leak; Kravet/Scalamandre/Thibaut/etc.)
// "Here's the" (Versace)
// "here is the" (Versace)
// Plus the documented Thibaut AT10701 "The provided HTML is already in English…"
// These are leading-text artifacts to STRIP from the front of body_html, leaving
// the real HTML intact. Strip patterns (anchored at start, case-insensitive):
const PREAMBLE_STRIP_PATTERNS = [
/^\s*```html\s*/i, // opening code fence
/\s*```\s*$/i, // closing code fence
/^\s*here'?s the[^<]*?(?=<)/i, // "Here's the ... <"
/^\s*here is the[^<]*?(?=<)/i, // "here is the ... <"
/^\s*the provided html is already in english[^<]*?(?=<)/i, // Thibaut AT10701
];
function stripPreambleSignature(snippet) {
// For the CSV preview we only have the snippet, not the full body.
// Show the leading signature that would be stripped.
return PREAMBLE_STRIP_PATTERNS.map(p => p.source).join(' || ');
}
// ---------------------------------------------------------------------------
// Build CSV rows from the available SAMPLES (representative dry-run preview)
// ---------------------------------------------------------------------------
function csvEsc(v) {
if (v == null) v = '';
v = String(v);
if (/[",\n]/.test(v)) return '"' + v.replace(/"/g, '""') + '"';
return v;
}
const rows = [['vendor', 'product_id', 'field', 'before', 'after', 'guard_flag']];
// 1) Wallpaper title samples -> title rewrite preview
for (const s of audit.samples.wallpaper) {
// The audit flags which FIELDS contain the token (seoTitle/seoDesc/body).
// For the title-push preview we transform the visible product TITLE if it
// contains the token; otherwise we record the flagged field as the locus.
const titleHasToken = /\bwallpaper/i.test(s.title);
if (titleHasToken) {
const { after, guardFlag } = transform(s.title);
rows.push([s.vendor, s.id, 'title', s.title, after, guardFlag || (after === s.title ? 'NO_CHANGE' : '')]);
} else {
// token lives in seoTitle/seoDesc/body — title itself already compliant.
const fields = (s.fields || []).join('+');
rows.push([s.vendor, s.id, fields || 'meta', s.title, '(token in ' + fields + ', not visible title)', 'META_FIELD_ONLY']);
}
}
// 2) AI-preamble strip list -> body rewrite preview (separate fast-win batch)
for (const p of audit.samples.preamble) {
rows.push([
p.vendor, p.id, 'body',
'LEAK: ' + p.snippet + ' …',
'[strip leading preamble signature]',
'AI_PREAMBLE_STRIP'
]);
}
// 3) Malformed (doubled-vendor-pipe) — note only, not a Wallpaper rewrite
for (const m of audit.samples.malformed) {
rows.push([m.vendor, m.id, 'title', m.title, '[fix ' + m.reason + ']', 'MALFORMED_TITLE']);
}
const csv = rows.map(r => r.map(csvEsc).join(',')).join('\n') + '\n';
fs.writeFileSync(OUT, csv);
// ---------------------------------------------------------------------------
// Build the batched per-vendor plan (largest-first)
// ---------------------------------------------------------------------------
const vendors = Object.entries(audit.vendor_breakdown)
.map(([vendor, c]) => ({ vendor, ...c }))
.filter(v => v.wallpaper > 0)
.sort((a, b) => b.wallpaper - a.wallpaper);
const totalWp = vendors.reduce((s, v) => s + v.wallpaper, 0);
console.log('WALLPAPER REWRITE — batched per-vendor plan (largest-first)');
console.log('total wallpaper-flagged products (audit):', totalWp);
console.log('');
let cum = 0;
let batch = 1;
for (const v of vendors) {
cum += v.wallpaper;
const nameGuard = VENDOR_NAMES_WITH_TOKEN.includes(v.vendor) ? ' *** VENDOR-NAME GUARD ***' : '';
console.log(`B${String(batch).padStart(2,'0')} ${v.vendor.padEnd(28)} ${String(v.wallpaper).padStart(5)} (cum ${cum})${nameGuard}`);
batch++;
}
console.log('');
console.log('AI-preamble strip batch (fast-win, highest priority):', audit.violation_counts.ai_preamble_in_body, 'rows');
console.log('Malformed-title fixes:', audit.violation_counts.malformed_in_title || audit.violation_counts.malformed_title || 0);
console.log('');
console.log('CSV staged ->', OUT, '(' + (rows.length - 1) + ' preview rows)');
console.log('Cost: $0 (local, read-only, no Shopify writes)');