← back to Dw Yolo Loop
scripts/google-feed/prep-actions.mjs
88 lines
#!/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');