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