[object Object]

← back to Designer Wallcoverings

Fix front-facing Brewster/York leak: 26 products retitled+revendored to Malibu Wallpaper, 65 tag-scrubbed, 4 leak collections deleted

b142d6f52af5296abda19d9937153cb86daa8bdc · 2026-06-25 07:52:29 -0700 · Steve

Brewster/York are HARD-banned front-facing (feedback_brewster_york_never_front_facing).
Live Shopify had 26 active 'Brewster & York' products with '| Brewster & York' titles
+ vendor. Retitled to '| Malibu Wallcovering', vendor -> Malibu Wallpaper, leak tags
scrubbed. Excludes New York/Nueva York/Yorktown/York Weave place+pattern names.

Files touched

Diff

commit b142d6f52af5296abda19d9937153cb86daa8bdc
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jun 25 07:52:29 2026 -0700

    Fix front-facing Brewster/York leak: 26 products retitled+revendored to Malibu Wallpaper, 65 tag-scrubbed, 4 leak collections deleted
    
    Brewster/York are HARD-banned front-facing (feedback_brewster_york_never_front_facing).
    Live Shopify had 26 active 'Brewster & York' products with '| Brewster & York' titles
    + vendor. Retitled to '| Malibu Wallcovering', vendor -> Malibu Wallpaper, leak tags
    scrubbed. Excludes New York/Nueva York/Yorktown/York Weave place+pattern names.
---
 scripts/fix-brewster-york-leak.mjs | 120 +++++++++++++++++++++++++++++++++++++
 1 file changed, 120 insertions(+)

diff --git a/scripts/fix-brewster-york-leak.mjs b/scripts/fix-brewster-york-leak.mjs
new file mode 100644
index 00000000..c32163fe
--- /dev/null
+++ b/scripts/fix-brewster-york-leak.mjs
@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+// Fix front-facing Brewster/York leak — retitle + revendor the 12, scrub leaking
+// tags, delete the unpublished leak collections. Operates on LIVE Shopify.
+// DRY by default; APPLY=1 to write. Logs every before->after change.
+import fs from 'node:fs';
+
+const env = Object.fromEntries(
+  fs.readFileSync(new URL('../.env', import.meta.url), 'utf8')
+    .split('\n').filter(l => l.includes('=') && !l.trim().startsWith('#'))
+    .map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; })
+);
+const SHOP = env.SHOPIFY_STORE_DOMAIN;
+const TOKEN = env.SHOPIFY_ADMIN_TOKEN;
+const VER = env.SHOPIFY_ADMIN_API_VERSION || '2024-01';
+const APPLY = process.env.APPLY === '1';
+const HOUSE_VENDOR = 'Malibu Wallpaper';
+const HOUSE_SUFFIX = 'Malibu Wallcovering';
+
+// A segment/tag is a banned-brand leak ONLY in recognized brand forms.
+// Brewster always leaks; York only as a brand tag — never as a "New York" place name
+// or a junk n-gram fragment (e.g. "york at" from "New York at Night").
+const isLeak = s => {
+  const t = String(s).toLowerCase().trim();
+  if (/new york|nueva york|york weave/.test(t)) return false;       // place / pattern names
+  if (/\bbrewster\b/.test(t)) return true;                           // Brewster always
+  if (/^(series:\s*)?york$/.test(t)) return true;                    // "York" / "Series: York"
+  if (/york (grasscloth|contract|wallcover|wall\b)/.test(t)) return true;
+  return false;                                                      // stray 'york' fragments = not a leak
+};
+
+async function gql(query, variables = {}) {
+  const r = await fetch(`https://${SHOP}/admin/api/${VER}/graphql.json`, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+  });
+  const j = await r.json();
+  if (j.errors) throw new Error(JSON.stringify(j.errors));
+  return j.data;
+}
+
+async function searchProducts(queryStr) {
+  const out = [];
+  let cursor = null;
+  do {
+    const d = await gql(`query($q:String!,$c:String){products(first:100,query:$q,after:$c){
+      pageInfo{hasNextPage endCursor} nodes{id title vendor tags}}}`, { q: queryStr, c: cursor });
+    out.push(...d.products.nodes);
+    cursor = d.products.pageInfo.hasNextPage ? d.products.pageInfo.endCursor : null;
+  } while (cursor);
+  return out;
+}
+
+async function updateProduct(id, input, label) {
+  if (!APPLY) return console.log(`   DRY  ${label}`);
+  const d = await gql(`mutation($input:ProductInput!){productUpdate(input:$input){
+    product{id} userErrors{field message}}}`, { input: { id, ...input } });
+  const errs = d.productUpdate.userErrors;
+  console.log(errs.length ? `   ERR  ${label} :: ${JSON.stringify(errs)}` : `   ✓    ${label}`);
+}
+
+(async () => {
+  console.log(`\n=== ${APPLY ? 'APPLY' : 'DRY RUN'} — store ${SHOP} (api ${VER}) ===\n`);
+
+  // ---- Phase 1: the 12 vendor="Brewster & York" actives — title + vendor + tags
+  console.log('PHASE 1 — retitle + revendor "Brewster & York" actives');
+  const p1 = await searchProducts(`status:active vendor:'Brewster & York'`);
+  for (const p of p1) {
+    const parts = p.title.split(/\s*\|\s*/).filter(seg => !isLeak(seg));
+    let newTitle = parts.join(' | ');
+    if (!/malibu/i.test(newTitle)) newTitle += ` | ${HOUSE_SUFFIX}`;
+    const newTags = p.tags.filter(t => !isLeak(t));
+    await updateProduct(p.id, { title: newTitle, vendor: HOUSE_VENDOR, tags: newTags },
+      `${p.title}  ->  ${newTitle}  [vendor:${HOUSE_VENDOR}]  tags-${p.tags.length - newTags.length}`);
+  }
+  console.log(`  (${p1.length} products)\n`);
+
+  // ---- Phase 2: scrub leaking tags off OTHER-vendor actives (Series: York etc.)
+  console.log('PHASE 2 — scrub leaking york/brewster tags off other-vendor actives');
+  const seen = new Set(p1.map(p => p.id));
+  const tagged = [];
+  for (const term of ['Series: York', 'York Grasscloth', 'Brewster & York', 'tag:York', 'tag:Brewster']) {
+    for (const p of await searchProducts(`status:active tag:'${term.replace(/^tag:/, '')}'`)) {
+      if (!seen.has(p.id)) { seen.add(p.id); tagged.push(p); }
+    }
+  }
+  let scrubbed = 0;
+  for (const p of tagged) {
+    const newTags = p.tags.filter(t => !isLeak(t));
+    if (newTags.length === p.tags.length) continue; // nothing leaking (FP)
+    scrubbed++;
+    await updateProduct(p.id, { tags: newTags },
+      `[${p.vendor}] ${p.title.slice(0, 40)}  drop:[${p.tags.filter(isLeak).join(', ')}]`);
+  }
+  console.log(`  (${scrubbed} products scrubbed of ${tagged.length} candidates)\n`);
+
+  // ---- Phase 3: delete unpublished leak collections
+  console.log('PHASE 3 — delete unpublished leak collections');
+  const colGids = {
+    'York (803)': 'gid://shopify/Collection/298423287859',
+    'Brewster (0)': 'gid://shopify/Collection/298422894643',
+    'Nicolette Mayer Designs (214)': 'gid://shopify/Collection/167354630195',
+    'Nicolette Mayer (214)': 'gid://shopify/Collection/298506551347',
+  };
+  for (const [name, gid] of Object.entries(colGids)) {
+    // verify it's unpublished before delete
+    const d = await gql(`query($id:ID!){collection(id:$id){id title handle
+      resourcePublicationsCount{count}}}`, { id: gid });
+    const c = d.collection;
+    if (!c) { console.log(`   skip ${name} — not found`); continue; }
+    const pubs = c.resourcePublicationsCount?.count ?? 0;
+    if (pubs > 0) { console.log(`   SKIP ${name} — PUBLISHED (${pubs}) — not deleting`); continue; }
+    if (!APPLY) { console.log(`   DRY  delete ${name} (${c.handle}, unpublished)`); continue; }
+    const del = await gql(`mutation($input:CollectionDeleteInput!){collectionDelete(input:$input){
+      deletedCollectionId userErrors{message}}}`, { input: { id: gid } });
+    const e = del.collectionDelete.userErrors;
+    console.log(e.length ? `   ERR  ${name} :: ${JSON.stringify(e)}` : `   ✓    deleted ${name}`);
+  }
+  console.log(`\n=== done (${APPLY ? 'APPLIED' : 'dry'}) ===\n`);
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });

← 57e17efc harden roll-builder gqlR + longtail refund-on-abort net (clo  ·  back to Designer Wallcoverings  ·  Add dedicated Anna French scraper (AT/AF -> DWAT/DWAF, stagi 9e18ec7f →