← back to Designerwallcoverings

scripts/alt-text-coverage/alt-text-coverage-audit.mjs

130 lines

// alt-text-coverage-audit — read-only Shopify GraphQL audit of image ALT-TEXT
// coverage per vendor. The image-QUALITY companion to the c26 metafield audit:
// c26 proved image PRESENCE is ~100% across repped vendors, but presence != quality.
// Alt text is the highest-leverage image-quality attribute — it drives Google
// Images discovery (organic traffic for a visual wallcovering catalog) AND is a
// WCAG accessibility requirement. One read-only count, dual payoff.
//
// Per ACTIVE product (capped at first:IMG images/product): all-alt / some-alt /
// no-alt; plus an image-level alt %. Vendors tagged repped from vendor_registry.
//
//   node alt-text-coverage-audit.mjs [--min 85] [--page 100] [--img 10]
// READ-ONLY (GraphQL queries only, no mutations). $0.
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';

const args=process.argv.slice(2);
const MIN =parseInt(args.find((_,i,a)=>a[i-1]==='--min')||'85',10)||85;   // WARN threshold: repped vendor all-alt% below this
const PAGE=parseInt(args.find((_,i,a)=>a[i-1]==='--page')||'100',10)||100;
const IMG =parseInt(args.find((_,i,a)=>a[i-1]==='--img')||'10',10)||10;   // images sampled per product (gallery cap)
const SHOP='designer-laboratory-sandbox.myshopify.com', VERSION='2024-10';
const ENV=fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8');
const TOKEN=(ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1];
if(!TOKEN){console.error('no SHOPIFY_ADMIN_TOKEN');process.exit(1);}
const URL=`https://${SHOP}/admin/api/${VERSION}/graphql.json`;
const OUT=`${process.env.HOME}/.claude/yolo-queue/alt-text-coverage-2026-06-16.json`;
const MD =`${process.env.HOME}/.claude/yolo-queue/alt-text-coverage-2026-06-16.md`;
const sleep=ms=>new Promise(r=>setTimeout(r,ms));

function reppedTags(){
  const PSQL='/opt/homebrew/opt/postgresql@14/bin/psql', DB='postgresql:///dw_unified?host=/tmp';
  let rows=[];
  try{
    const out=execFileSync(PSQL,[DB,'-At','-F','\t','-c',
      "select lower(vendor_name), is_active, is_private_label, coalesce(discount_confirmed,false) from vendor_registry;"],{encoding:'utf8'});
    rows=out.trim().split('\n').filter(Boolean).map(l=>l.split('\t'));
  }catch(e){ console.error('warn: vendor_registry read failed ('+e.message.slice(0,40)+')'); }
  const m=new Map();
  for(const [name,act,pl,disc] of rows) m.set(name,{repped: act==='t'&&pl!=='t'&&disc==='t', private_label: pl==='t'});
  return m;
}

async function gql(q,v){
  for(let a=0;a<8;a++){
    let res; try{ res=await fetch(URL,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})}); }
    catch(e){ await sleep(2000*(a+1)); continue; }
    if(res.status===429){ await sleep(2000*(a+1)); continue; }
    const j=await res.json();
    if(j.errors){ if(JSON.stringify(j.errors).match(/THROTTLED|exceeded/i)){ await sleep(2500*(a+1)); continue;} throw new Error(JSON.stringify(j.errors).slice(0,200)); }
    if((j.extensions?.cost?.throttleStatus?.currentlyAvailable??2000)<300) await sleep(1500);
    return j.data;
  }
  throw new Error('exhausted retries');
}

const QUERY=`query($cursor:String){
  products(first:${PAGE}, after:$cursor, query:"status:active"){
    pageInfo{ hasNextPage endCursor }
    nodes{ vendor images(first:${IMG}){ nodes{ altText } } }
  }
}`;

function blank(){return {n:0, withImg:0, allAlt:0, someAlt:0, noAlt:0, imgTot:0, imgAlt:0};}

(async()=>{
  const tags=reppedTags();
  const byVendor=new Map();
  let cursor=null, total=0, pages=0; const t0=Date.now();
  while(true){
    const d=await gql(QUERY,{cursor}); const c=d.products;
    for(const p of c.nodes){
      total++;
      const v=p.vendor||'(none)';
      if(!byVendor.has(v)) byVendor.set(v,blank());
      const s=byVendor.get(v); s.n++;
      const imgs=p.images.nodes; if(!imgs.length) continue;
      s.withImg++;
      const withAlt=imgs.filter(i=>i.altText && i.altText.trim()).length;
      s.imgTot+=imgs.length; s.imgAlt+=withAlt;
      if(withAlt===imgs.length) s.allAlt++; else if(withAlt>0) s.someAlt++; else s.noAlt++;
    }
    pages++;
    if(pages%50===0) process.stderr.write(`  ...${pages} pages, ${total} products, ${byVendor.size} vendors\n`);
    if(!c.pageInfo.hasNextPage) break;
    cursor=c.pageInfo.endCursor;
  }
  const elapsed=((Date.now()-t0)/1000).toFixed(1);
  const pct=(x,n)=>n?Math.round(1000*x/n)/10:0;

  const vendors=[...byVendor.entries()].map(([vendor,s])=>{
    const t=tags.get(vendor.toLowerCase())||null;
    return { vendor, repped:t?.repped??null, private_label:t?.private_label??null,
      active_products:s.n, with_image:s.withImg,
      all_alt_pct:pct(s.allAlt,s.withImg), no_alt_pct:pct(s.noAlt,s.withImg),
      some_alt_pct:pct(s.someAlt,s.withImg), img_alt_pct:pct(s.imgAlt,s.imgTot),
      products_no_alt:s.noAlt };
  }).sort((a,b)=>b.active_products-a.active_products);

  const reppedV=vendors.filter(v=>v.repped===true);
  const gaps=reppedV.filter(v=>v.with_image>=50 && v.all_alt_pct<MIN).sort((a,b)=>b.products_no_alt-a.products_no_alt);

  const report={ generated_at:new Date().toISOString(), scanned_active:total, pages, elapsed_s:+elapsed,
    images_per_product_cap:IMG, warn_below_pct:MIN, vendor_count:vendors.length,
    repped_vendor_count:reppedV.length, repped_gap_count:gaps.length, repped_gaps:gaps, vendors };
  fs.writeFileSync(OUT, JSON.stringify(report,null,2));

  const sum=(arr,k)=>arr.reduce((a,v)=>a+v[k]*v.with_image,0);
  const totR=reppedV.reduce((a,v)=>a+v.with_image,0)||1;
  const wAllAlt=Math.round(10*sum(reppedV,'all_alt_pct')/totR)/10;
  const wImgAlt=Math.round(10*sum(reppedV,'img_alt_pct')/totR)/10;

  let md=`# Shopify image ALT-TEXT coverage audit (repped vendors) — ${new Date().toISOString().slice(0,16)}\n\n`;
  md+=`**READ-ONLY (GraphQL, no writes), \$0.** Scanned **${total}** ACTIVE products in ${elapsed}s (${pages} pages, ${IMG} images/product cap). `;
  md+=`Image-QUALITY companion to the c26 presence audit (presence was ~100%; this measures whether those images are discoverable + accessible). Alt text → Google Images SEO + WCAG.\n\n`;
  md+=`Repped = vendor_registry is_active AND NOT private_label AND discount_confirmed. Repped vendors with images: **${reppedV.length}** (${totR} products).\n\n`;
  md+=`## Weighted repped coverage\n| Axis | Coverage |\n|---|---:|\n| products with ALL images alt'd | ${wAllAlt}% |\n| image-level (every image) alt'd | ${wImgAlt}% |\n\n`;
  md+=`## 🔴 Repped vendors below ${MIN}% all-alt (≥50 products w/ images) — ${gaps.length}\n`;
  md+=`| Vendor | Products w/img | all-alt% | no-alt% | img-level alt% | products w/ NO alt |\n|---|---:|---:|---:|---:|---:|\n`;
  for(const v of gaps) md+=`| ${v.vendor} | ${v.with_image} | ${v.all_alt_pct} | ${v.no_alt_pct} | ${v.img_alt_pct} | ${v.products_no_alt} |\n`;
  md+=`\n## All vendors (top 50 by active count)\n`;
  md+=`| Vendor | Repped | Products w/img | all-alt% | no-alt% | img-level alt% |\n|---|:--:|---:|---:|---:|---:|\n`;
  for(const v of vendors.slice(0,50)) md+=`| ${v.vendor} | ${v.repped===true?'✓':v.repped===false?'·':'?'} | ${v.with_image} | ${v.all_alt_pct} | ${v.no_alt_pct} | ${v.img_alt_pct} |\n`;
  md+=`\n_all-alt% = products where EVERY image (up to ${IMG}) has non-empty alt. no-alt% = products with images but zero alt. img-level alt% = fraction of all images carrying alt. Galleries >${IMG} images are sampled at ${IMG} (may slightly overstate all-alt)._\n`;
  fs.writeFileSync(MD,md);

  console.log(`[alt-text-coverage] scanned=${total} vendors=${vendors.length} repped=${reppedV.length} gaps=${gaps.length}`);
  console.log(`  weighted repped: all-alt ${wAllAlt}% · img-level ${wImgAlt}%`);
  console.log(`Report: ${MD}`);
  process.exit(0);
})().catch(e=>{console.error('FATAL',e.message);process.exit(1);});