← back to Dw Yolo Loop

scripts/image-alt-audit/image-alt-audit-rendered.mjs

101 lines

// image-alt-audit-rendered — READ-ONLY, $0. Cycle 66 RE-MEASUREMENT (officer REVISE).
// The v1 measured product.json images[].alt = the STORED layer. The compliance officer
// proved (live, n=2) the theme constructs alt at RENDER time: a product that is 100%
// alt:null in products.json renders a MIX of populated / empty / missing-attribute alts.
// So WCAG/ADA exposure must be measured on RENDERED HTML, and the three alt states
// separated (empty alt="" is WCAG-CORRECT for decorative/zoom-pair images; only a
// product-content image with NO usable alt is a real §1.1.1 violation).
// METHOD: fetch rendered PDP HTML; extract <img> referencing cdn.shopify product media;
// group by normalized image URL (collapses zoom/lazy duplicate pairs); per UNIQUE
// content image record the BEST alt across its instances (populated > empty > missing).
// Classify each unique content image: POPULATED / EMPTY-alt / NO-alt-attribute.
// NEGATIVE CONTROL: print real populated alts (parser reads them) + confirm we can
// distinguish empty="" from a missing attribute on real tags.
// EXCLUDES Phillip Jeffries + UI chrome (svg/icons, non-/products/ media).
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();
// normalize a shopify cdn image URL: strip size suffix (_600x, _1024x1024) + query → identity of the underlying asset
function imgId(u){ try{ let p=new URL(u, WWW).pathname; p=p.replace(/_(\d+x\d*|\d*x\d+|small|medium|large|grande|pico|icon|thumb|compact|master)\b/gi,''); return p.toLowerCase(); }catch{ return (u||'').toLowerCase(); } }

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/3)); for(let i=0;i<hs.length&&handles.length<120;i+=step) handles.push(hs[i]); }
handles=[...new Set(handles)].filter(h=>!PJ.test(h)).slice(0,80);
console.log(`=== image-alt-audit-RENDERED (READ-ONLY, $0) ===\nre-measuring ${handles.length} live PDPs at the RENDERED layer (PJ excluded)\n`);

let prodEval=0, prodAllCovered=0, prodHasViolation=0, pjSkip=0, fetchErr=0;
let uImgTotal=0, uPopulated=0, uEmpty=0, uMissingAttr=0, uMeaningful=0;
const populatedSamples=[], emptySamples=[], missingSamples=[], problems=[];
let done=0;
for(const h of handles){
  const r=await get(`${WWW}/products/${h}`);
  if(r.status!==200){ fetchErr++; done++; continue; }
  if(PJ.test(h)){ pjSkip++; done++; continue; }
  // pull product title for meaningfulness check
  const title=norm((r.text.match(/<title>([^<]*)<\/title>/i)||[])[1]||'');
  const titleToks=title.split(' ').filter(t=>t.length>3);
  // all <img> tags referencing shopify product CDN media (exclude svg/icons/chrome)
  const tags=[...r.text.matchAll(/<img[^>]*>/gi)].map(m=>m[0])
    .filter(t=>/cdn\.shopify\.com\/s\/files|\/products\//i.test(t))
    .filter(t=>!/\.svg|icon|logo|close|chevron|arrow|spinner|loader/i.test(t));
  if(tags.length===0){ // no parseable product-media img in rendered HTML — skip (can't measure rendered layer)
    done++; await sleep(90); continue; }
  prodEval++;
  // group by underlying asset, keep BEST alt state per asset (populated > empty="" > missing-attr)
  const byAsset=new Map(); // id -> {state:'pop'|'empty'|'missing', alt}
  for(const t of tags){
    // resolve the asset url from src OR data-src OR srcset first url
    const su=(t.match(/\s(?:data-src|src)=["']([^"']+)["']/i)||[])[1] || (t.match(/srcset=["']([^"',\s]+)/i)||[])[1] || '';
    if(!/cdn\.shopify\.com|\/products\//i.test(su)) continue;
    const id=imgId(su);
    const hasAltAttr=/\salt\s*=/i.test(t);
    const altVal=hasAltAttr?((t.match(/\salt\s*=\s*["']([^"']*)["']/i)||[,''])[1]).trim():null;
    const state = altVal && altVal.length>0 ? 'pop' : (hasAltAttr ? 'empty' : 'missing');
    const prev=byAsset.get(id);
    const rank={pop:3,empty:2,missing:1};
    if(!prev || rank[state]>rank[prev.state]) byAsset.set(id,{state,alt:altVal||''});
  }
  if(byAsset.size===0){ done++; await sleep(90); continue; }
  let violation=false;
  for(const [id,info] of byAsset){
    uImgTotal++;
    if(info.state==='pop'){ uPopulated++; const na=norm(info.alt); if(titleToks.some(tk=>na.includes(tk))) uMeaningful++; if(populatedSamples.length<6) populatedSamples.push({h,alt:info.alt.slice(0,70)}); }
    else if(info.state==='empty'){ uEmpty++; if(emptySamples.length<6) emptySamples.push({h,id:id.slice(-40)}); }
    else { uMissingAttr++; violation=true; if(missingSamples.length<8) missingSamples.push({h,id:id.slice(-40)}); }
  }
  if(violation){ prodHasViolation++; problems.push({h,issue:`has image(s) with NO alt attribute`}); }
  else prodAllCovered++;
  done++;
  if(done%20===0) console.log(`  ${done}/${handles.length} (unique content imgs: pop=${uPopulated} empty=${uEmpty} no-attr=${uMissingAttr})`);
  await sleep(110);
}

console.log(`\n=== RESULTS (RENDERED layer, unique content images) ===`);
console.log(`PDPs with parseable product-media imgs: ${prodEval} | PJ-skip: ${pjSkip} | fetch-err: ${fetchErr} | unparseable(skipped): ${done-prodEval-pjSkip-fetchErr}`);
console.log(`unique content images classified: ${uImgTotal}`);
console.log(`\n-- three alt states (separated per officer) --`);
console.log(`  POPULATED alt: ${uImgTotal?(100*uPopulated/uImgTotal).toFixed(1):0}%  (${uPopulated})  [meaningful: ${uPopulated?(100*uMeaningful/uPopulated).toFixed(1):0}%]`);
console.log(`  EMPTY alt="" : ${uImgTotal?(100*uEmpty/uImgTotal).toFixed(1):0}%  (${uEmpty})  [WCAG-OK if decorative/zoom-pair]`);
console.log(`  *** NO alt ATTRIBUTE (genuine §1.1.1 risk): ${uImgTotal?(100*uMissingAttr/uImgTotal).toFixed(1):0}%  (${uMissingAttr}) ***`);
console.log(`\n-- product-level --`);
console.log(`  products where ALL content imgs have a usable (populated/empty) alt: ${prodAllCovered}/${prodEval}`);
console.log(`  products with >=1 NO-alt-attribute image: ${prodHasViolation}/${prodEval}`);
if(uImgTotal>0){
  const p=uMissingAttr/uImgTotal, se=Math.sqrt(p*(1-p)/uImgTotal), lo=Math.max(0,p-1.96*se)*100, hi=Math.min(1,p+1.96*se)*100;
  console.log(`\n*** RENDERED no-alt-attribute rate: ${uMissingAttr}/${uImgTotal} = ${(100*p).toFixed(1)}% (95% CI ${lo.toFixed(1)}–${hi.toFixed(1)}%, n=${uImgTotal}) ***`);
}
console.log(`\n-- NEGATIVE CONTROL --`);
console.log(`  populated (parser reads value):`); populatedSamples.forEach(s=>console.log(`    ${s.h.slice(0,32)} → "${s.alt}"`));
console.log(`  empty alt="" detected: ${uEmpty>0?'yes ('+emptySamples.length+' samples)':'none in sample'}`);
console.log(`  no-alt-attribute detected: ${uMissingAttr>0?'yes ('+missingSamples.length+' samples)':'none in sample'}`);
if(missingSamples.length) missingSamples.forEach(s=>console.log(`    NO-ATTR ${s.h.slice(0,40)}`));

import fs from 'fs';
fs.writeFileSync('/tmp/image-alt-audit-rendered.json',JSON.stringify({ts:new Date().toISOString(),handles:handles.length,prodEval,prodAllCovered,prodHasViolation,uImgTotal,uPopulated,uEmpty,uMissingAttr,uMeaningful,populatedSamples,emptySamples,missingSamples,problems},null,2));
console.log('\nwrote /tmp/image-alt-audit-rendered.json');