← 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
A scripts/alt-text-coverage/alt-text-coverage-audit.mjs
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 →