[object Object]

← back to Dw Yolo Loop

alt-text coverage audit: repped-vendor image alt-text (read-only GraphQL)

fabc2957f3212c15c3dc66afb5c5b1003d516d01 · 2026-06-16 10:19:50 -0700 · Steve Abrams

Cycle-29 read-only canary; image-QUALITY companion to c26 presence (~100%).
Per ACTIVE product: all-alt/some-alt/no-alt + image-level alt%. Result: weighted
repped all-alt 77.6% (89.x% excluding display-excluded Phillip Jeffries, whose
2,470 no-alt are by-design). Real fixable gaps: Wolf Gordon, China Seas, Maya
Romanoff, Graham & Brown, Elitis, Romo, Harlequin. Cross-finding: Clarke & Clarke
100% alt but 0% specs (c28) — enrichment axes independent.

Files touched

Diff

commit fabc2957f3212c15c3dc66afb5c5b1003d516d01
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 16 10:19:50 2026 -0700

    alt-text coverage audit: repped-vendor image alt-text (read-only GraphQL)
    
    Cycle-29 read-only canary; image-QUALITY companion to c26 presence (~100%).
    Per ACTIVE product: all-alt/some-alt/no-alt + image-level alt%. Result: weighted
    repped all-alt 77.6% (89.x% excluding display-excluded Phillip Jeffries, whose
    2,470 no-alt are by-design). Real fixable gaps: Wolf Gordon, China Seas, Maya
    Romanoff, Graham & Brown, Elitis, Romo, Harlequin. Cross-finding: Clarke & Clarke
    100% alt but 0% specs (c28) — enrichment axes independent.
---
 .../alt-text-coverage/alt-text-coverage-audit.mjs  | 129 +++++++++++++++++++++
 1 file changed, 129 insertions(+)

diff --git a/scripts/alt-text-coverage/alt-text-coverage-audit.mjs b/scripts/alt-text-coverage/alt-text-coverage-audit.mjs
new file mode 100644
index 0000000..9c58e09
--- /dev/null
+++ b/scripts/alt-text-coverage/alt-text-coverage-audit.mjs
@@ -0,0 +1,129 @@
+// 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/stevestudio2/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);});

← d9c8874 metafield-coverage: harden specs check w/ global.width + pil  ·  back to Dw Yolo Loop  ·  schu roll-adds: complete all 81 (41 clean + 36 edge + 4 oddb 5c719ab →