← back to Tk10630 Sku Suffix Canary

prune-redirects.mjs

43 lines

// Prune DEAD-TARGET redirects (target /products/<handle> no longer exists) to free
// space under the 100k cap. DRY-RUN by default; --apply deletes. Resumable log.
// Cody-guarded: strip ?query before the productByHandle test (else a live ?variant
// target is wrongly flagged dead). Only /products/ targets are ever considered.
import { readFileSync } from 'node:fs';
import { gql } from './shopify.mjs';
import { appendFileSync, existsSync } from 'node:fs';
const APPLY = process.argv.includes('--apply');
const LIMIT = Number((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || Infinity);
const DONE = 'done-prune.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 sleep = ms => new Promise(r => setTimeout(r, ms));
async function q(query, v) { for (let a = 0; ; a++) { try { return (await gql(query, v)).data; } catch (e) { if (/THROTTLED|<html/i.test(e.message) && a < 8) { await sleep(2000 * (a + 1)); continue; } throw e; } } }

const DEL = `mutation($id:ID!){ urlRedirectDelete(id:$id){ deletedUrlRedirectId userErrors{ message } } }`;
let cursor = null, scanned = 0, dead = 0, deleted = 0, err = 0, checkedAlive = 0;
const cacheAlive = new Map();
while (true) {
  const d = await q(`query($c:String){ urlRedirects(first:250, after:$c){ pageInfo{ hasNextPage endCursor } nodes{ id path target } } }`, { c: cursor });
  for (const r of d.urlRedirects.nodes) {
    scanned++;
    if (!/^\/products\//.test(r.target)) continue;         // only product targets
    if (done.has(r.id)) continue;
    const handle = r.target.replace(/^\/products\//, '').split('?')[0].split('#')[0]; // strip ?variant / #
    let alive = cacheAlive.get(handle);
    if (alive === undefined) { const pd = await q(`query{ productByHandle(handle:"${handle}"){ id } }`); alive = !!pd.productByHandle; cacheAlive.set(handle, alive); checkedAlive++; }
    if (alive) continue;
    dead++;
    if (APPLY) {
      const del = await q(DEL, { id: r.id });
      if (del.urlRedirectDelete.userErrors.length) { err++; }
      else { deleted++; appendFileSync(DONE, JSON.stringify({ id: r.id, path: r.path }) + '\n'); }
    }
    if (dead >= LIMIT) { console.log(`[prune] hit --limit=${LIMIT}`); cursor = null; break; }
  }
  if (dead >= LIMIT) break;
  if (!d.urlRedirects.pageInfo.hasNextPage) break;
  cursor = d.urlRedirects.pageInfo.endCursor;
  if (scanned % 2500 === 0) process.stderr.write(`  scanned ${scanned}, dead-targets ${dead}, deleted ${deleted}\n`);
}
console.log(JSON.stringify({ mode: APPLY ? 'APPLY' : 'DRY-RUN', scanned, product_targets_checked: checkedAlive, dead_prunable: dead, deleted, errors: err }, null, 2));