← 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}`);