← back to Dw Yolo Loop
scripts/google-feed/apply-unpublish.mjs
64 lines
#!/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}`);