← 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
A scripts/fix-brewster-york-leak.mjs
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 →