[object Object]

← back to Dw Yolo Loop

cycle 65: cross-locale hreflang+canonical audit (0/58 dupe, en-ca/en-gb consolidate to en, negative-control validated)

d9679e12b6233c7421e7edbe1eed8ce729f59a39 · 2026-06-17 14:43:39 -0700 · Steve Abrams

Files touched

Diff

commit d9679e12b6233c7421e7edbe1eed8ce729f59a39
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 17 14:43:39 2026 -0700

    cycle 65: cross-locale hreflang+canonical audit (0/58 dupe, en-ca/en-gb consolidate to en, negative-control validated)
---
 .../hreflang-locale-audit.mjs                      | 104 +++++++++++++++++++++
 1 file changed, 104 insertions(+)

diff --git a/scripts/hreflang-locale-audit/hreflang-locale-audit.mjs b/scripts/hreflang-locale-audit/hreflang-locale-audit.mjs
new file mode 100644
index 0000000..1eaf8a5
--- /dev/null
+++ b/scripts/hreflang-locale-audit/hreflang-locale-audit.mjs
@@ -0,0 +1,104 @@
+// hreflang-locale-audit — READ-ONLY, $0. Cycle 65 (DTD-picked A, unanimous).
+// c63 (PDP canonical/robots) + c64 (collection/facet/tag canonicals) both CLEAN. This
+// audits the biggest REMAINING dupe vector both structurally excluded: the en / en-ca /
+// en-gb locale duplication the sitemap actively advertises. The two known Shopify
+// multi-locale failure modes:
+//   (1) DUPLICATE CONTENT — the en-ca/en-gb URL serves a 200 page that self-canonicals
+//       (Google indexes 3 copies of the same content), OR
+//   (2) BROKEN HREFLANG — the en page advertises en-CA/en-GB alternates that don't
+//       resolve to a reciprocal 200 self-referencing locale page (Google ignores them).
+// Recon found: en page emits 4 hreflang (x-default/en/en-CA/en-GB), but the en-ca/en-gb
+// URLs 302-REDIRECT to en. So the SAFE outcome (consolidation, no dupe) is the hypothesis
+// — this audit confirms it fleet-wide + sizes any real 200-dupe exposure.
+// Built-in NEGATIVE CONTROL: the en page is KNOWN to carry hreflang tags → assert the
+// parser extracts >0 of them (proves it's not blind to hreflang link tags).
+// EXCLUDES Phillip Jeffries.
+const BASE='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},redirect:'manual'});if(r.status===429){await sleep(1500*(a+1));continue;}return {status:r.status,loc:r.headers.get('location'),text:(r.status===200)?await r.text():''};}catch(e){await sleep(800*(a+1));}}return {status:0,loc:null,text:''};}
+const PJ=/phillip[- ]?jeffries|phillip-jeffries/i;
+function canonOf(html){const m=html.match(/<link[^>]+rel=["']canonical["'][^>]*href=["']([^"']+)["']/i);return m?m[1]:null;}
+function hreflangs(html){const out=[];
+  for(const m of html.matchAll(/<link[^>]+rel=["']alternate["'][^>]*hreflang=["']([^"']+)["'][^>]*href=["']([^"']+)["']/gi)) out.push({lang:m[1].toLowerCase(),href:m[2]});
+  for(const m of html.matchAll(/<link[^>]+hreflang=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["']/gi)) out.push({lang:m[1].toLowerCase(),href:m[2]});
+  // dedupe by lang
+  const seen=new Set(); return out.filter(x=>!seen.has(x.lang)&&seen.add(x.lang));
+}
+const rel=u=>{ try{ return new URL(u,BASE).pathname.replace(/\/$/,'').toLowerCase(); }catch{ return (u||'').toLowerCase(); } };
+
+// gather a stratified sample of product handles (from product sitemaps)
+const idx=await get(`${BASE}/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]); if(hs.length) handles.push(hs[Math.floor(hs.length/2)]); }
+handles=[...new Set(handles)].filter(h=>!PJ.test(h)).slice(0,30);
+console.log(`=== hreflang-locale-audit (READ-ONLY, $0) ===\nsampling ${handles.length} product handles across en/en-ca/en-gb (PJ excluded)\n`);
+
+let enSelfCanon=0, enHasHreflang=0, hreflangParserOk=0;
+let localeConsolidates=0, localeDupe200=0, localeCanonToEn=0, localeMissing=0;
+let reciprocalOk=0, reciprocalBroken=0;
+const problems=[]; const samples=[];
+let done=0;
+for(const h of handles){
+  const enUrl=`${BASE}/products/${h}`;
+  const en=await get(enUrl);
+  if(en.status!==200){ done++; continue; }
+  // en canonical
+  const ec=canonOf(en.text);
+  if(ec && rel(ec)===rel(enUrl)) enSelfCanon++;
+  // en hreflang set
+  const hl=hreflangs(en.text);
+  if(hl.length>0){ enHasHreflang++; hreflangParserOk++; }
+  const advCA=hl.find(x=>x.lang==='en-ca'); const advGB=hl.find(x=>x.lang==='en-gb');
+
+  // probe the en-ca + en-gb variant URLs themselves
+  for(const [loc,adv] of [['en-ca',advCA],['en-gb',advGB]]){
+    const lurl=`${BASE}/${loc}/products/${h}`;
+    const lr=await get(lurl);
+    if(lr.status>=300 && lr.status<400){
+      // consolidates to en? (redirect target = en URL)
+      if(lr.loc && rel(lr.loc)===rel(enUrl)) localeConsolidates++;
+      else { localeConsolidates++; } // any redirect away from a standalone locale page = consolidation
+    } else if(lr.status===200){
+      const lc=canonOf(lr.text);
+      if(lc && rel(lc)===rel(enUrl)){ localeCanonToEn++; } // 200 but canonical→en = consolidated via canonical (acceptable)
+      else if(lc && rel(lc)===rel(lurl)){ localeDupe200++; if(problems.length<30) problems.push({h,loc,issue:'DUPE-200-self-canonical',canon:rel(lc)}); } // real dup content
+      else { localeMissing++; if(problems.length<30) problems.push({h,loc,issue:'200-no/odd-canonical',canon:lc?rel(lc):'(none)'}); }
+      // reciprocity: a 200 locale page should carry a return hreflang to en + self
+      const lhl=hreflangs(lr.text); const hasReturn=lhl.some(x=>x.lang==='en'||x.lang==='x-default');
+      if(hasReturn) reciprocalOk++; else reciprocalBroken++;
+    } else { localeMissing++; }
+    await sleep(110);
+  }
+  if(samples.length<5) samples.push({h,enHreflang:hl.length,langs:hl.map(x=>x.lang).join('/')});
+  done++;
+  if(done%10===0) console.log(`  ${done}/${handles.length} (consolidate=${localeConsolidates} dupe200=${localeDupe200} canon→en=${localeCanonToEn})`);
+  await sleep(120);
+}
+
+const localeProbes=localeConsolidates+localeDupe200+localeCanonToEn+localeMissing;
+console.log(`\n=== RESULTS ===`);
+console.log(`en pages evaluated: ${done}`);
+console.log(`  en self-canonical: ${enSelfCanon}/${done}`);
+console.log(`  en pages carrying hreflang annotations: ${enHasHreflang}/${done}`);
+console.log(`\n-- NEGATIVE CONTROL (hreflang parser) --`);
+console.log(`  pages where parser extracted >0 hreflang tags: ${hreflangParserOk}/${done}  ${hreflangParserOk>0?'→ PASSED (parser sees hreflang link tags; a real annotation would be detected)':'→ FAILED/INCONCLUSIVE'}`);
+console.log(`\n-- en-ca / en-gb locale variant URLs (${localeProbes} probed) --`);
+console.log(`  CONSOLIDATES to en (3xx redirect → en) [SAFE, no dupe]: ${localeConsolidates}`);
+console.log(`  200 but canonical→en (consolidated via canonical) [acceptable]: ${localeCanonToEn}`);
+console.log(`  *** 200 self-canonical DUPLICATE CONTENT [the real risk]: ${localeDupe200} ***`);
+console.log(`  other/missing: ${localeMissing}`);
+console.log(`\n-- hreflang reciprocity (only meaningful for 200 locale pages) --`);
+console.log(`  return-tag present: ${reciprocalOk} | broken: ${reciprocalBroken}`);
+if(localeProbes>0){
+  const dupe=localeDupe200, p=dupe/localeProbes, se=Math.sqrt(p*(1-p)/localeProbes), lo=Math.max(0,p-1.96*se)*100, hi=Math.min(1,p+1.96*se)*100;
+  console.log(`\n*** locale DUPLICATE-CONTENT rate: ${dupe}/${localeProbes} = ${(100*p).toFixed(1)}%  (95% CI ${lo.toFixed(1)}–${hi.toFixed(1)}%, n=${localeProbes}) ***`);
+}
+if(problems.length){ console.log('\nproblems (first 15):'); problems.slice(0,15).forEach(x=>console.log(`  /${x.loc}/products/${x.h.slice(0,38)} → ${x.issue} canon=${x.canon}`)); }
+else console.log('\nno locale duplicate-content / canonical problems in sample.');
+console.log('\nsample (first 5):'); samples.forEach(s=>console.log(`  ${s.h.slice(0,40)} hreflang=${s.enHreflang} [${s.langs}]`));
+
+import fs from 'fs';
+fs.writeFileSync('/tmp/hreflang-locale-audit.json',JSON.stringify({ts:new Date().toISOString(),sampled:handles.length,evaluated:done,enSelfCanon,enHasHreflang,hreflangParserOk,localeConsolidates,localeCanonToEn,localeDupe200,localeMissing,reciprocalOk,reciprocalBroken,problems,samples},null,2));
+console.log('\nwrote /tmp/hreflang-locale-audit.json');

← 2c7c6b5 cycle 64: officer sign-off + in-cycle stacked-param & tag-pa  ·  back to Dw Yolo Loop  ·  cycle 65: officer sign-off + in-cycle collection-locale & si a5d6de2 →