[object Object]

← back to Dw Yolo Loop

cycle 66: image alt-text audit — 26.8% images empty alt (WCAG+image-SEO), premise verified (null→rendered alt='')

c740b1de7db9c1f0d547ca064b2c73c4faac79bc · 2026-06-17 15:23:39 -0700 · Steve Abrams

Files touched

Diff

commit c740b1de7db9c1f0d547ca064b2c73c4faac79bc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 17 15:23:39 2026 -0700

    cycle 66: image alt-text audit — 26.8% images empty alt (WCAG+image-SEO), premise verified (null→rendered alt='')
---
 scripts/image-alt-audit/image-alt-audit.mjs | 95 +++++++++++++++++++++++++++++
 1 file changed, 95 insertions(+)

diff --git a/scripts/image-alt-audit/image-alt-audit.mjs b/scripts/image-alt-audit/image-alt-audit.mjs
new file mode 100644
index 0000000..15847a0
--- /dev/null
+++ b/scripts/image-alt-audit/image-alt-audit.mjs
@@ -0,0 +1,95 @@
+// image-alt-audit — READ-ONLY, $0. Cycle 66 (DTD-picked B, unanimous).
+// After 4 clean SEO-indexability cycles (c59/c63/c64/c65), pivot to a GENUINELY
+// uncovered surface: image alt-text coverage. Dual value — image-SEO (a wallcoverings
+// catalog where imagery IS the product → Google Images traffic) AND ADA/WCAG
+// accessibility (real legal-exposure surface). Source of truth = product.json
+// images[].alt (what Steve controls + what the theme renders). Per product/image we
+// measure: has >=1 image; image alt populated vs empty/missing; alt "meaningful"
+// (contains the SKU or a title token, not just brand boilerplate); duplicate alt
+// across a product's images (image-SEO weakness, not an a11y failure).
+// Recon: alt is auto-constructed (SKU + pattern name + brand) → expect decent coverage.
+// NEGATIVE CONTROL: print actual alt VALUES — confirm the parser flags a genuinely
+// empty/null alt as empty AND a populated alt as populated (not blind either way).
+// EXCLUDES Phillip Jeffries.
+const WWW='https://www.designerwallcoverings.com';
+const UA='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+async function get(u){for(let a=0;a<4;a++){try{const r=await fetch(u,{headers:{'User-Agent':UA}});if(r.status===429){await sleep(1500*(a+1));continue;}return {status:r.status,text:await r.text()};}catch(e){await sleep(800*(a+1));}}return {status:0,text:''};}
+const PJ=/phillip[- ]?jeffries|phillip-jeffries/i;
+const norm=s=>(s||'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim();
+
+// stratified handle pool across all non-locale product sitemaps
+const idx=await get(`${WWW}/sitemap.xml`);
+const prodMaps=[...idx.text.matchAll(/<loc>([^<]+sitemap_products_\d+\.xml[^<]*)<\/loc>/g)].map(m=>m[1].replace(/&amp;/g,'&')).filter(u=>!/\/en-(ca|gb)\//.test(u));
+let handles=[];
+for(const sm of prodMaps){
+  const x=await get(sm);
+  const hs=[...x.text.matchAll(/<loc>[^<]*\/products\/([^<\/?]+)/g)].map(m=>m[1]);
+  const step=Math.max(1,Math.floor(hs.length/8));
+  for(let i=0;i<hs.length && handles.length<360;i+=step) handles.push(hs[i]);
+}
+handles=[...new Set(handles)].filter(h=>!PJ.test(h)).slice(0,300);
+console.log(`=== image-alt-audit (READ-ONLY, $0) ===\nsampling ${handles.length} live products across ${prodMaps.length} sitemaps (PJ excluded)\n`);
+
+let prodEval=0, prodNoImage=0, prodAllAlt=0, prodSomeMissing=0, prodAllMissing=0;
+let imgTotal=0, imgAlt=0, imgEmpty=0, imgMeaningful=0, imgDupAlt=0;
+let pjSkip=0, fetchErr=0;
+const emptySamples=[], populatedSamples=[], problems=[];
+let done=0;
+for(const h of handles){
+  const r=await get(`${WWW}/products/${h}.json`);
+  let prod; try{ prod=JSON.parse(r.text).product; }catch{ fetchErr++; done++; continue; }
+  if(!prod){ fetchErr++; done++; continue; }
+  if(PJ.test(prod.vendor||'')||PJ.test(h)){ pjSkip++; done++; continue; }
+  const imgs=prod.images||[];
+  prodEval++;
+  if(imgs.length===0){ prodNoImage++; problems.push({h,issue:'NO-IMAGE'}); done++; await sleep(70); continue; }
+  const sku=norm(prod.variants&&prod.variants[0]&&prod.variants[0].sku);
+  const titleToks=norm(prod.title).split(' ').filter(t=>t.length>3);
+  let missing=0, present=0; const altsSeen=new Set();
+  for(const im of imgs){
+    imgTotal++;
+    const alt=(im.alt==null?'':String(im.alt)).trim();
+    if(alt.length===0){ imgEmpty++; missing++; if(emptySamples.length<8) emptySamples.push({h,id:im.id,raw:JSON.stringify(im.alt)}); }
+    else {
+      imgAlt++; present++;
+      if(populatedSamples.length<5) populatedSamples.push({h,alt:alt.slice(0,70)});
+      const na=norm(alt);
+      const meaningful = (sku && na.includes(sku)) || titleToks.some(t=>na.includes(t));
+      if(meaningful) imgMeaningful++;
+      if(altsSeen.has(na)) imgDupAlt++; else altsSeen.add(na);
+    }
+  }
+  if(missing===0) prodAllAlt++;
+  else if(present===0){ prodAllMissing++; problems.push({h,issue:`ALL-${imgs.length}-IMG-NO-ALT`}); }
+  else { prodSomeMissing++; problems.push({h,issue:`${missing}/${imgs.length}-IMG-NO-ALT`}); }
+  done++;
+  if(done%50===0) console.log(`  ${done}/${handles.length} (img alt ${imgAlt}/${imgTotal}; no-image prods ${prodNoImage})`);
+  await sleep(70);
+}
+
+console.log(`\n=== RESULTS ===`);
+console.log(`products evaluated: ${prodEval} | PJ-skipped: ${pjSkip} | fetch/parse-err: ${fetchErr}`);
+console.log(`\n-- product-level --`);
+console.log(`  products with 0 images: ${prodNoImage}`);
+console.log(`  all images have alt (healthy): ${prodAllAlt}`);
+console.log(`  some images missing alt: ${prodSomeMissing}`);
+console.log(`  ALL images missing alt (a11y fail): ${prodAllMissing}`);
+console.log(`\n-- image-level (${imgTotal} images) --`);
+console.log(`  alt populated: ${imgAlt}  (${imgTotal?(100*imgAlt/imgTotal).toFixed(1):0}%)`);
+console.log(`  alt EMPTY/missing: ${imgEmpty}  (${imgTotal?(100*imgEmpty/imgTotal).toFixed(1):0}%)`);
+console.log(`  populated alt that is MEANINGFUL (SKU/title token): ${imgMeaningful}/${imgAlt}  (${imgAlt?(100*imgMeaningful/imgAlt).toFixed(1):0}% of populated)`);
+console.log(`  duplicate alt within same product (image-SEO weakness): ${imgDupAlt}`);
+if(imgTotal>0){
+  const p=imgEmpty/imgTotal, se=Math.sqrt(p*(1-p)/imgTotal), lo=Math.max(0,p-1.96*se)*100, hi=Math.min(1,p+1.96*se)*100;
+  console.log(`\n*** image alt-MISSING rate: ${imgEmpty}/${imgTotal} = ${(100*p).toFixed(1)}%  (95% CI ${lo.toFixed(1)}–${hi.toFixed(1)}%, n=${imgTotal}) ***`);
+}
+console.log(`\n-- NEGATIVE CONTROL (parser reads alt values, not blind) --`);
+console.log(`  populated-alt samples (detected as populated):`); populatedSamples.forEach(s=>console.log(`    ${s.h.slice(0,34)} → "${s.alt}"`));
+if(emptySamples.length){ console.log(`  empty-alt samples (raw value, detected as empty):`); emptySamples.forEach(s=>console.log(`    ${s.h.slice(0,34)} img=${s.id} raw=${s.raw}`)); }
+else console.log(`  (no empty-alt images in sample → cannot show an empty detection; populated detection is proven above)`);
+if(problems.length){ console.log(`\nproblems (first 15):`); problems.slice(0,15).forEach(x=>console.log(`  ${x.issue}: ${x.h.slice(0,48)}`)); }
+
+import fs from 'fs';
+fs.writeFileSync('/tmp/image-alt-audit.json',JSON.stringify({ts:new Date().toISOString(),sampled:handles.length,prodEval,prodNoImage,prodAllAlt,prodSomeMissing,prodAllMissing,imgTotal,imgAlt,imgEmpty,imgMeaningful,imgDupAlt,pjSkip,fetchErr,emptySamples,populatedSamples,problems},null,2));
+console.log('\nwrote /tmp/image-alt-audit.json');

← a5d6de2 cycle 65: officer sign-off + in-cycle collection-locale & si  ·  back to Dw Yolo Loop  ·  cycle 66: officer REVISE — rendered-layer re-measure (0/46 W c983851 →