← back to Designerwallcoverings
scripts/bannedword-title-preview/bannedword-title-preview.mjs
107 lines
// bannedword-title-preview — PREVIEW-ONLY remediation for the banned word
// 'Wallpaper' in live product titles. Generates the rewrite transform and its
// edge-case census so Steve can approve the (gated) bulk rewrite with eyes on
// real before->after pairs. WRITES NOTHING to Shopify.
//
// Core insight surfaced by the data: a NAIVE global Wallpaper->Wallcovering would
// corrupt brand names for vendors whose NAME contains "Wallpaper" (Malibu Wallpaper,
// Scalamandre Wallpaper, etc.) — there "Wallpaper" sits only in the "| <brand>"
// suffix and is the legitimate vendor name, NOT a banned-word violation.
//
// GUARDED transform: split title on " | " into [descriptor, brand-suffix]; replace
// 'Wallpaper'->'Wallcovering' ONLY in the descriptor; leave the brand suffix intact.
// - descriptor has the word -> CLEAN rewrite
// - word only in brand suffix -> NO-CHANGE (false positive; brand name)
// - doubled result / plural -> NEEDS-REVIEW
//
// Source: dw_unified mirror (READ-ONLY). NB the mirror shows ~1,508 ACTIVE titles
// with the word vs the storefront audit's 25,131 — the mirror undercounts (stale/
// partial title sync), so the actual bulk rewrite must target LIVE Shopify. This is
// a representative transform+edge-case preview, not the final worklist.
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
const PSQL = [ '/opt/homebrew/opt/postgresql@14/bin/psql', '/usr/local/opt/postgresql@14/bin/psql', 'psql' ]
.find(p => { try { execFileSync(p, ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } }) || 'psql';
const DB = process.env.DW_UNIFIED_URL || 'postgresql:///dw_unified?host=/tmp';
const OUT = `${process.env.HOME}/.claude/yolo-queue/bannedword-title-preview-2026-06-16.json`;
const MD = `${process.env.HOME}/.claude/yolo-queue/bannedword-title-preview-2026-06-16.md`;
function q(sql) {
const out = execFileSync(PSQL, [DB, '-At', '-F', '\t', '-c', sql], { encoding: 'utf8', maxBuffer: 128 * 1024 * 1024 });
return out.trim() ? out.trim().split('\n').map(r => r.split('\t')) : [];
}
const rows = q(`select vendor, mfr_sku, title from shopify_products
where status='ACTIVE' and title ~* 'wallpaper' order by vendor, mfr_sku`)
.map(r => ({ vendor: r[0], sku: r[1], title: r[2] }));
const RE = /\bwallpaper\b/gi;
const repl = (s) => s.replace(RE, (m) => m[0] === 'W' ? 'Wallcovering' : 'wallcovering');
const cats = { clean: [], brand_only_nochange: [], needs_review: [] };
const byVendor = {};
for (const r of rows) {
byVendor[r.vendor] = (byVendor[r.vendor] || 0) + 1;
const parts = r.title.split(' | ');
const brand = parts.length > 1 ? parts.slice(1).join(' | ') : '';
const desc = parts[0];
const descHas = RE.test(desc); RE.lastIndex = 0;
let newTitle, cat;
if (!descHas) { newTitle = r.title; cat = 'brand_only_nochange'; }
else {
const newDesc = repl(desc);
newTitle = parts.length > 1 ? [newDesc, ...parts.slice(1)].join(' | ') : newDesc;
// needs-review heuristics
if (/wallcovering\s+wallcovering/i.test(newTitle) || /\bwallpapers\b/i.test(r.title) || /\bwallcovering\b.*\bwallpaper\b/i.test(r.title)) cat = 'needs_review';
else cat = 'clean';
}
const rec = { vendor: r.vendor, sku: r.sku, before: r.title, after: newTitle, brand };
cats[cat].push(rec);
}
const sampleAcross = (arr, n) => {
const byV = {}; for (const x of arr) (byV[x.vendor] ||= []).push(x);
const out = []; const keys = Object.keys(byV);
let i = 0; while (out.length < n && keys.length) { const k = keys[i % keys.length]; if (byV[k].length) out.push(byV[k].shift()); else { keys.splice(i % keys.length, 1); continue; } i++; }
return out;
};
const out = {
generated_at: new Date().toISOString(),
source: 'dw_unified mirror (READ-ONLY)',
mirror_count: rows.length,
storefront_audit_count: 25131,
note: 'mirror undercounts live titles; bulk rewrite must target live Shopify, not this mirror',
transform: "GUARDED: replace \\bWallpaper\\b -> Wallcovering ONLY in the descriptor (text before ' | '); brand suffix preserved",
category_counts: { clean: cats.clean.length, brand_only_nochange: cats.brand_only_nochange.length, needs_review: cats.needs_review.length },
by_vendor: Object.entries(byVendor).sort((a,b)=>b[1]-a[1]),
samples_clean: sampleAcross(cats.clean, 30),
samples_brand_only: sampleAcross(cats.brand_only_nochange, 12),
samples_needs_review: cats.needs_review.slice(0, 20),
};
fs.writeFileSync(OUT, JSON.stringify(out, null, 2));
let md = `# Banned-word 'Wallpaper' title rewrite — PREVIEW (report-only)\n\n`;
md += `**$0, READ-ONLY.** No Shopify writes. Source: dw_unified mirror.\n\n`;
md += `⚠️ **Count caveat:** mirror shows **${rows.length}** ACTIVE titles with the word; the storefront audit found **25,131**. The mirror undercounts (stale/partial title sync) — the actual bulk rewrite MUST run against LIVE Shopify, not the mirror. This is a representative transform + edge-case preview.\n\n`;
md += `## Guarded transform\n\`${out.transform}\`\n\n`;
md += `## Category census (of ${rows.length} mirror rows)\n`;
md += `| Category | Count | Meaning |\n|---|---:|---|\n`;
md += `| ✅ CLEAN rewrite | ${cats.clean.length} | word is in the descriptor → safe Wallpaper→Wallcovering |\n`;
md += `| 🚫 BRAND-ONLY (no change) | ${cats.brand_only_nochange.length} | word ONLY in the brand suffix (e.g. \\| Malibu Wallpaper) → DO NOT rewrite (legit vendor name) |\n`;
md += `| 🟠 NEEDS REVIEW | ${cats.needs_review.length} | doubled 'Wallcovering Wallcovering', plural 'Wallpapers', or mixed |\n\n`;
md += `> **The critical guard:** a naive global replace would corrupt ${cats.brand_only_nochange.length} titles' brand names (Malibu Wallpaper → Malibu Wallcovering, etc.). The guarded transform leaves the brand suffix untouched.\n\n`;
md += `## Affected by vendor\n| Vendor | Count |\n|---|---:|\n`;
for (const [v, c] of out.by_vendor) md += `| ${v} | ${c} |\n`;
md += `\n## CLEAN before→after (sample)\n`;
for (const s of out.samples_clean.slice(0, 20)) md += `- \`${s.before}\`\n → \`${s.after}\`\n`;
md += `\n## BRAND-ONLY — DO NOT rewrite (sample)\n`;
for (const s of out.samples_brand_only) md += `- \`${s.before}\` _(word is the brand)_\n`;
if (cats.needs_review.length) { md += `\n## NEEDS REVIEW (sample)\n`; for (const s of out.samples_needs_review) md += `- \`${s.before}\` → \`${s.after}\`\n`; }
md += `\n_Gated: the bulk rewrite (live Shopify, via shopify_api_queue {id,title}) needs Steve's approval. This preview + the brand-suffix guard is the spec._\n`;
fs.writeFileSync(MD, md);
console.log(`[bannedword-title-preview] mirror=${rows.length}: clean=${cats.clean.length} brand-only-nochange=${cats.brand_only_nochange.length} needs-review=${cats.needs_review.length}`);
console.log(`Report: ${MD}`);