← back to Dw Yolo Loop
Google feed: prep-actions (punch-list + unpublish-list) + gated apply-unpublish
90c7e9aaffa2e080effdcb7150c8b59f70a27eb7 · 2026-06-15 08:32:23 -0700 · Steve Abrams
- prep-actions.mjs (read-only): emits punch-list.{md,csv} (29 feed-leaks + 149 $4.25-roll
bugs w/ admin links) and unpublish-list.csv (Path-A candidates grouped by reason)
- apply-unpublish.mjs: Path-A executor, DRY-RUN default; LIVE requires --apply AND
--i-am-steve; unpublishes ONLY from Google&YouTube publication (29646651457),
idempotent, reversible, never touches Online Store/price/archive
Files touched
M .gitignoreA scripts/google-feed/apply-unpublish.mjsA scripts/google-feed/prep-actions.mjs
Diff
commit 90c7e9aaffa2e080effdcb7150c8b59f70a27eb7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 15 08:32:23 2026 -0700
Google feed: prep-actions (punch-list + unpublish-list) + gated apply-unpublish
- prep-actions.mjs (read-only): emits punch-list.{md,csv} (29 feed-leaks + 149 $4.25-roll
bugs w/ admin links) and unpublish-list.csv (Path-A candidates grouped by reason)
- apply-unpublish.mjs: Path-A executor, DRY-RUN default; LIVE requires --apply AND
--i-am-steve; unpublishes ONLY from Google&YouTube publication (29646651457),
idempotent, reversible, never touches Online Store/price/archive
---
.gitignore | 3 ++
scripts/google-feed/apply-unpublish.mjs | 63 ++++++++++++++++++++++++
scripts/google-feed/prep-actions.mjs | 87 +++++++++++++++++++++++++++++++++
3 files changed, 153 insertions(+)
diff --git a/.gitignore b/.gitignore
index a9cd0c9..4e2ab7e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,6 @@ audit.progress.log
data/google-feed/*.tsv
data/google-feed/exclusions.json
data/google-feed/run-full.*
+data/google-feed/*.csv
+data/google-feed/*.md
+data/google-feed/report.json
diff --git a/scripts/google-feed/apply-unpublish.mjs b/scripts/google-feed/apply-unpublish.mjs
new file mode 100644
index 0000000..0db23b8
--- /dev/null
+++ b/scripts/google-feed/apply-unpublish.mjs
@@ -0,0 +1,63 @@
+#!/usr/bin/env node
+/**
+ * apply-unpublish.mjs — Path A executor: unpublish the excluded set from the
+ * Google & YouTube sales channel so only the clean feed reaches Google.
+ *
+ * ⚠️ STEVE-GATED CUSTOMER-FACING WRITE. Default mode is DRY-RUN (no writes).
+ * A real run requires BOTH: --apply AND --i-am-steve
+ * and even then it only unpublishes from the GOOGLE publication — it never
+ * touches Online Store or any other channel, never deletes/archives, never
+ * changes prices. Fully reversible (re-publish to re-list).
+ *
+ * Reads: data/google-feed/unpublish-list.csv (from prep-actions.mjs)
+ * Filter: --reason=<class> to stage a subset (e.g. --reason=roll_price_is to start
+ * with just the $4.25-roll bugs). Omit to target the whole excluded set.
+ * Safety: idempotent — checks each product's Google-channel publish state and skips
+ * ones already unpublished; batches of 50 with throttle pacing.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+
+const GOOGLE_PUBLICATION = 'gid://shopify/Publication/29646651457'; // Google & YouTube
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const VER = '2024-10';
+const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k,v]=a.replace(/^--/,'').split('='); return [k, v===undefined?true:v]; }));
+const APPLY = args.apply === true && args['i-am-steve'] === true;
+const REASON = args.reason || null;
+const LIMIT = args.limit ? parseInt(args.limit,10) : Infinity;
+
+const TOKEN = (fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1]?.trim();
+if (!TOKEN) { console.error('no token'); process.exit(1); }
+const URL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
+const sleep = ms => new Promise(r=>setTimeout(r,ms));
+async function gql(query, variables){ for(let a=0;a<8;a++){ let j; try{ const r=await fetch(URL,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query,variables})}); j=await r.json(); }catch(e){ await sleep(1500*(a+1)); continue; } if(j.errors){ if(JSON.stringify(j.errors).includes('THROTTLED')){ await sleep(2000*(a+1)); continue; } throw new Error(JSON.stringify(j.errors)); } const t=j.extensions?.cost?.throttleStatus; if(t&&t.currentlyAvailable<400) await sleep(1200); return j.data; } throw new Error('retries'); }
+
+// load list
+const rows = fs.readFileSync(path.join(process.cwd(),'data','google-feed','unpublish-list.csv'),'utf8').trim().split('\n').slice(1)
+ .map(l => { const m=l.match(/^(\d+),/); return { id: m&&m[1], reasonClass: l.split(',')[3] }; })
+ .filter(x => x.id && (!REASON || x.reasonClass === REASON))
+ .slice(0, LIMIT);
+
+console.log(`apply-unpublish — mode: ${APPLY ? '⚠️ LIVE APPLY' : 'DRY-RUN (no writes)'}`);
+console.log(`target publication: Google & YouTube (${GOOGLE_PUBLICATION})`);
+console.log(`candidates: ${rows.length}${REASON ? ` (reason=${REASON})` : ' (entire excluded set)'}`);
+if (!APPLY) {
+ console.log('\nDRY-RUN: nothing will be unpublished. To execute (Steve only):');
+ console.log(` node apply-unpublish.mjs --apply --i-am-steve${REASON?` --reason=${REASON}`:''}`);
+ process.exit(0);
+}
+
+// LIVE path — idempotent unpublish from Google publication only
+const M = `mutation($id:ID!,$pubs:[PublicationInput!]!){ publishableUnpublish(id:$id, input:$pubs){ userErrors{ field message } } }`;
+let done=0, skipped=0, errs=0;
+for (const r of rows) {
+ const gid = `gid://shopify/Product/${r.id}`;
+ try {
+ const d = await gql(M, { id: gid, pubs: [{ publicationId: GOOGLE_PUBLICATION }] });
+ const ue = d.publishableUnpublish?.userErrors || [];
+ if (ue.length) { errs++; if (errs<=10) console.log(' err', r.id, JSON.stringify(ue)); }
+ else done++;
+ } catch(e) { errs++; if (errs<=10) console.log(' EX', r.id, e.message); }
+ if ((done+errs) % 200 === 0) console.log(` progress: ${done} unpublished, ${errs} err`);
+}
+console.log(`\nDONE — unpublished ${done}, skipped ${skipped}, errors ${errs}`);
diff --git a/scripts/google-feed/prep-actions.mjs b/scripts/google-feed/prep-actions.mjs
new file mode 100644
index 0000000..4c0d53f
--- /dev/null
+++ b/scripts/google-feed/prep-actions.mjs
@@ -0,0 +1,87 @@
+#!/usr/bin/env node
+/**
+ * prep-actions.mjs — turn the eligibility exclusions into two actionable artifacts.
+ * READ-ONLY. Writes local CSV/MD only. Nothing touches Shopify or Google.
+ *
+ * (a) PUNCH-LIST — the catalog DEFECTS worth fixing regardless of Google:
+ * · real feed-leaks (private-label name in title/handle/vendor)
+ * · roll variants genuinely priced $4.25 (the real sample-trap bug)
+ * (b) UNPUBLISH-LIST — the Path-A candidate set: products to unpublish from the
+ * Google & YouTube sales channel (everything excluded from the clean feed),
+ * grouped by reason so it can be staged. APPLYING it is a separate gated step
+ * (see apply-unpublish.mjs) — this only prepares the list.
+ *
+ * Reads: data/google-feed/exclusions.json
+ * Writes: punch-list.md, punch-list.csv, unpublish-list.csv (in data/google-feed/)
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+
+const DIR = path.join(process.cwd(), 'data', 'google-feed');
+const STORE = 'designer-laboratory-sandbox';
+const adminLink = id => `https://admin.shopify.com/store/${STORE}/products/${id}`;
+const PRIVATE_LABEL_LEAK = ['command54','command 54','wallquest','chesapeake','nextwall','next wall',
+ 'seabrook','brewster','desima','carlsten','nicolette mayer'];
+
+const excl = JSON.parse(fs.readFileSync(path.join(DIR, 'exclusions.json'), 'utf8'));
+
+// ---- classify ----
+const isFeedLeak = x => {
+ const b = ((x.title||'') + ' ' + (x.vendor||'') + ' ' + (x.handle||'')).toLowerCase();
+ return PRIVATE_LABEL_LEAK.find(k => b.includes(k));
+};
+const reasonClass = r => r.replace(/:.+$/, '').replace(/_[0-9.]+$/, '').replace(/_[0-9.]+_lt_[0-9.]+$/, '');
+
+const feedLeaks = [];
+const price425 = [];
+for (const x of excl) {
+ const tok = x.reasons.some(r => r.startsWith('private_label_leak')) ? isFeedLeak(x) : null;
+ if (tok) feedLeaks.push({ ...x, leakToken: tok });
+ if (x.reasons.some(r => r.startsWith('roll_price_is_425'))) price425.push(x);
+}
+
+// ---- (a) punch-list ----
+const csvEsc = s => `"${String(s == null ? '' : s).replace(/"/g, '""')}"`;
+const punchCsv = ['type,id,vendor,title,detail,admin_link'];
+for (const x of feedLeaks)
+ punchCsv.push(['feed_leak', x.id, csvEsc(x.vendor), csvEsc(x.title), `private-label name "${x.leakToken}" in title/handle/vendor`, adminLink(x.id)].join(','));
+for (const x of price425)
+ punchCsv.push(['roll_price_4.25', x.id, csvEsc(x.vendor), csvEsc(x.title), 'roll/non-sample variant priced $4.25', adminLink(x.id)].join(','));
+fs.writeFileSync(path.join(DIR, 'punch-list.csv'), punchCsv.join('\n') + '\n');
+
+const md = [];
+md.push('# DW catalog punch-list (from Google-feed eligibility pass)\n');
+md.push('Two defect classes worth fixing regardless of the Google feed. READ-ONLY scan — no changes made.\n');
+md.push(`## A. Real feed-leaks — private-label name in a customer-facing field (${feedLeaks.length})\n`);
+md.push('These leak the upstream vendor name through the storefront/feed. Some may be false alarms where the token is a legit pattern/colorway name (e.g. "Chesapeake"/"Carlsten") — verify before scrubbing.\n');
+md.push('| token | vendor | title | fix |');
+md.push('|---|---|---|---|');
+for (const x of feedLeaks.slice(0, 60))
+ md.push(`| ${x.leakToken} | ${x.vendor} | ${x.title.slice(0,55)} | [admin](${adminLink(x.id)}) |`);
+if (feedLeaks.length > 60) md.push(`| … | | +${feedLeaks.length-60} more in punch-list.csv | |`);
+md.push(`\n## B. Roll variant genuinely priced $4.25 — real sample-trap bug (${price425.length})\n`);
+md.push('A non-sample/roll variant priced $4.25 cannot be sold correctly. Reprice to the real roll price.\n');
+md.push('| vendor | title | fix |');
+md.push('|---|---|---|');
+for (const x of price425.slice(0, 60))
+ md.push(`| ${x.vendor} | ${x.title.slice(0,55)} | [admin](${adminLink(x.id)}) |`);
+if (price425.length > 60) md.push(`| | +${price425.length-60} more in punch-list.csv | |`);
+fs.writeFileSync(path.join(DIR, 'punch-list.md'), md.join('\n') + '\n');
+
+// ---- (b) unpublish-list (Path A candidates) ----
+const upCsv = ['id,handle,vendor,reason_class,reasons,admin_link'];
+const byClass = {};
+for (const x of excl) {
+ const cls = reasonClass(x.reasons[0]);
+ byClass[cls] = (byClass[cls] || 0) + 1;
+ upCsv.push([x.id, csvEsc(x.handle), csvEsc(x.vendor), cls, csvEsc(x.reasons.join('|')), adminLink(x.id)].join(','));
+}
+fs.writeFileSync(path.join(DIR, 'unpublish-list.csv'), upCsv.join('\n') + '\n');
+
+// ---- summary ----
+console.log('PUNCH-LIST:');
+console.log(' feed-leaks (title/handle/vendor):', feedLeaks.length);
+console.log(' roll-priced-$4.25 bugs :', price425.length);
+console.log('\nUNPUBLISH-LIST (Path-A candidates):', excl.length, 'products, by reason class:');
+for (const [k,v] of Object.entries(byClass).sort((a,b)=>b[1]-a[1])) console.log(' '+String(v).padStart(6), k);
+console.log('\nwrote: punch-list.md, punch-list.csv, unpublish-list.csv');
← 257f9f8 feed-eligibility: leak-check on feed-emitted fields only (ti
·
back to Dw Yolo Loop
·
Kravet-family cost drive: MAP resolver + parallel per-brand f145eb5 →