← back to Tk10661 Product Seo

reorder-all.mjs

39 lines

// TK-10661 REORDER-ALL: for every target product (variants[0]==Sample), reorder so Sample is
// LAST via productVariantsBulkReorder. DRY-RUN default; --apply writes. Resumable (done-log).
// Rate-limited + THROTTLED backoff. Rollback: targets.jsonl holds original id->position; the
// companion rollback-all.mjs restores from the done-log.
import {readFileSync, appendFileSync, existsSync} from 'node:fs';
const txt=readFileSync(`${process.env.HOME}/Projects/secrets-manager/.env`,'utf8');
const env={}; for(const l of txt.split('\n')){const m=l.match(/^([A-Z0-9_]+)=(.*)$/); if(m) env[m[1]]=m[2].replace(/^["']|["']$/g,'');}
const STORE=env.SHOPIFY_STORE_DOMAIN, TOKEN=env.SHOPIFY_FULL_ACCESS_TOKEN, API='2024-10';
const APPLY=process.argv.includes('--apply');
const LIMIT=Number((process.argv.find(a=>a.startsWith('--limit='))||'').split('=')[1]||Infinity);
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function gql(q,v={}){for(let a=0;a<10;a++){try{const r=await fetch(`https://${STORE}/admin/api/${API}/graphql.json`,{method:'POST',headers:{'Content-Type':'application/json','X-Shopify-Access-Token':TOKEN},body:JSON.stringify({query:q,variables:v})});const j=await r.json();if(j.errors){if(/THROTTLED/i.test(JSON.stringify(j.errors))){await sleep(2500*(a+1));continue;}throw new Error(JSON.stringify(j.errors));}return j;}catch(e){if(a<9){await sleep(1500*(a+1));continue;}throw e;}}}
const DONE='data/done-reorder.jsonl';
const done=new Set(); if(existsSync(DONE)) for(const l of readFileSync(DONE,'utf8').split('\n')) if(l.trim()) done.add(JSON.parse(l).id);
const TFILE=process.argv.find(a=>a.endsWith('.jsonl'))||'data/targets.jsonl';
const targets=readFileSync(TFILE,'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
const MUT=`mutation($pid:ID!,$pos:[ProductVariantPositionInput!]!){ productVariantsBulkReorder(productId:$pid,positions:$pos){ userErrors{field message} } }`;
let done_n=0, skipped=0, err=0, applied=0;
for(const t of targets){
  if(done.has(t.id)) { continue; }
  const vs=t.variants.slice().sort((a,b)=>a.position-b.position);
  const nonSample=vs.filter(v=>!/^sample$/i.test((v.title||'').trim()));
  const sample=vs.filter(v=>/^sample$/i.test((v.title||'').trim()));
  if(!nonSample.length){ skipped++; continue; } // nothing to promote
  const ordered=[...nonSample,...sample];
  const positions=ordered.map((v,i)=>({id:v.id,position:i+1}));
  if(APPLY){
    const res=await gql(MUT,{pid:t.id,pos:positions});
    const ue=res.data?.productVariantsBulkReorder?.userErrors||[];
    if(ue.length){ err++; appendFileSync(DONE,JSON.stringify({id:t.id,error:ue})+'\n'); }
    else { applied++; appendFileSync(DONE,JSON.stringify({id:t.id,reordered:positions.map(p=>p.id),ok:true})+'\n'); }
    await sleep(220); // ~4-5/s
  }
  done_n++;
  if(done_n%500===0) process.stderr.write(`  processed ${done_n}/${targets.length}, applied ${applied}, err ${err}\n`);
  if(applied>=LIMIT){ process.stderr.write(`hit --limit=${LIMIT}\n`); break; }
}
console.log(JSON.stringify({mode:APPLY?'APPLY':'DRY-RUN',targets:targets.length,processed:done_n,applied,skipped_no_real_variant:skipped,errors:err},null,2));