← back to Dw Yolo Loop

scripts/robots-sitemap/robots-sitemap-canary.mjs

136 lines

// robots-sitemap-canary — read-only crawl-directive + sitemap health across the
// core DW web fleet. A single accidental site-wide `Disallow: /` or a missing/
// stale sitemap silently suppresses organic indexing fleet-wide — invisible to
// uptime/TLS canaries (the site is UP and the cert is valid; Google just stops
// indexing it). Nothing else checks this. Pure read-only GET of /robots.txt +
// /sitemap.xml per host.
//
//   node robots-sitemap-canary.mjs [--stale 120] [--hosts a.com,b.com]
// READ-ONLY (HTTP GET only, no writes). $0.
import fs from 'node:fs';

const args = process.argv.slice(2);
const STALE = parseInt(args.find((_,i,a)=>a[i-1]==='--stale')||'120',10)||120; // days; sitemap lastmod older than this = WARN
const HOSTS = (args.find((_,i,a)=>a[i-1]==='--hosts') || [
  'www.designerwallcoverings.com','apartmentwallpaper.com','philipperomano.com',
  'novasuede.com','wallco.ai','architecturalwallcoverings.com','thesetdecorator.com',
  'corkwallcovering.com','silkwallpaper.com','linenwallpaper.com',
  'raffiawallpaper.com','glitterwallpaper.com','hospitalitywallcoverings.com','grasscloth.com',
].join(',')).split(',').map(h=>h.trim()).filter(Boolean);
const OUT=`${process.env.HOME}/.claude/yolo-queue/robots-sitemap-2026-06-16.json`;
const MD =`${process.env.HOME}/.claude/yolo-queue/robots-sitemap-2026-06-16.md`;

async function get(url){
  const ctrl=new AbortController(); const t=setTimeout(()=>ctrl.abort(),12000);
  try{
    const r=await fetch(url,{signal:ctrl.signal,redirect:'follow',headers:{'User-Agent':'DW-robots-sitemap-canary/1.0'}});
    const body=await r.text();
    return { status:r.status, body, finalUrl:r.url };
  }catch(e){ return { status:0, body:'', err:String(e.message).slice(0,40) }; }
  finally{ clearTimeout(t); }
}

// Is GOOGLEBOT blocked site-wide? robots.txt precedence: a crawler obeys the
// MOST SPECIFIC matching User-agent group, so an explicit `User-agent: Googlebot`
// group overrides `User-agent: *`. We evaluate from Googlebot's perspective —
// that's the only thing that matters for indexing. (Avoids the false positive
// where a trailing `User-agent: * / Disallow: /` coexists with an explicit
// Googlebot allow group, e.g. legacy/parked robots.txt files.)
function blocksGooglebot(robots){
  const lines=robots.split(/\r?\n/).map(l=>l.replace(/#.*$/,'').trim()).filter(Boolean);
  // collect Disallow directives per user-agent group
  const groups=new Map(); // ua(lowercased) -> array of disallow values
  let cur=[];
  for(const l of lines){
    const m=l.match(/^user-agent:\s*(.+)$/i);
    if(m){ const ua=m[1].trim().toLowerCase(); if(!groups.has(ua)) groups.set(ua,[]); cur=groups.get(ua); continue; }
    const d=l.match(/^disallow:\s*(.*)$/i);
    if(d && cur) cur.push(d[1].trim());
  }
  // Googlebot uses its own group if present, else the * group.
  const eff = groups.has('googlebot') ? groups.get('googlebot') : (groups.get('*')||[]);
  return eff.some(v=>v==='/'); // Disallow: /  → entire site blocked for Googlebot
}
function sitemapsFromRobots(robots){
  return [...robots.matchAll(/^sitemap:\s*(\S+)/gim)].map(m=>m[1].trim());
}
function newestLastmod(xml){
  const mods=[...xml.matchAll(/<lastmod>([^<]+)<\/lastmod>/gi)].map(m=>Date.parse(m[1].trim())).filter(n=>!isNaN(n));
  if(!mods.length) return null;
  return Math.max(...mods);
}

async function checkHost(host){
  const base=`https://${host}`;
  const r={ host, flags:[] };
  // --- robots.txt ---
  const rob=await get(`${base}/robots.txt`);
  r.robots_status=rob.status;
  if(rob.status!==200){ r.flags.push('robots_missing'); }
  else {
    r.robots_blocks_all = blocksGooglebot(rob.body);
    if(r.robots_blocks_all) r.flags.push('ROBOTS_BLOCKS_GOOGLEBOT');
    // resolve declared Sitemap: URLs against base (they may be relative, e.g. "/sitemap.xml")
    r.robots_sitemaps = sitemapsFromRobots(rob.body).map(u=>{ try{ return new URL(u, base).href; }catch{ return null; } }).filter(Boolean);
    r.robots_declares_sitemap = r.robots_sitemaps.length>0;
  }
  // --- sitemap.xml (prefer one declared in robots, else default) ---
  const smUrl = (r.robots_sitemaps && r.robots_sitemaps[0]) || `${base}/sitemap.xml`;
  r.sitemap_url=smUrl;
  const sm=await get(smUrl);
  r.sitemap_status=sm.status;
  if(sm.status!==200){ r.flags.push('sitemap_missing'); }
  else {
    const xml=sm.body;
    const isXml=/^\s*(<\?xml|<urlset|<sitemapindex)/i.test(xml);
    r.sitemap_valid_xml=isXml;
    r.sitemap_is_index=/<sitemapindex/i.test(xml);
    r.sitemap_entries=(xml.match(/<loc>/gi)||[]).length;
    if(!isXml){ r.flags.push('sitemap_invalid_xml'); }
    else {
      if(r.sitemap_entries===0) r.flags.push('sitemap_empty');
      const nm=newestLastmod(xml);
      if(nm){ const days=Math.floor((Date.now()-nm)/86400000); r.sitemap_newest_lastmod_days=days; if(days>STALE) r.flags.push('sitemap_stale'); }
      else if(!r.sitemap_is_index){ r.flags.push('sitemap_no_lastmod'); }
    }
    if(r.robots_status===200 && !r.robots_declares_sitemap) r.flags.push('sitemap_not_in_robots');
  }
  // --- verdict ---
  const hard=['ROBOTS_BLOCKS_GOOGLEBOT','sitemap_missing','sitemap_invalid_xml','sitemap_empty'];
  const unreachable = rob.status===0 && sm.status===0;
  if(unreachable) r.verdict='UNKNOWN';
  else if(r.flags.some(f=>hard.includes(f))) r.verdict='FAIL';
  else if(r.flags.length) r.verdict='WARN';
  else r.verdict='PASS';
  return r;
}

(async()=>{
  const results=[];
  for(const h of HOSTS) results.push(await checkHost(h));
  const fail=results.filter(r=>r.verdict==='FAIL');
  const warn=results.filter(r=>r.verdict==='WARN');
  const unk=results.filter(r=>r.verdict==='UNKNOWN');
  const verdict = fail.length?'FAIL':warn.length?'WARN':'PASS';

  const report={ generated_at:new Date().toISOString(), checked:results.length, stale_days:STALE,
    verdict, fail:fail.length, warn:warn.length, unknown:unk.length, results };
  fs.writeFileSync(OUT, JSON.stringify(report,null,2));

  const emoji=verdict==='FAIL'?'🔴':verdict==='WARN'?'🟠':'🟢';
  let md=`# robots.txt / sitemap.xml fleet canary — ${new Date().toISOString().slice(0,16)}\n\n`;
  md+=`Checked **${results.length}** core fleet hosts. **READ-ONLY (HTTP GET only), \$0.** Stale threshold: sitemap newest lastmod > ${STALE}d.\n\n`;
  md+=`## ${emoji} ${verdict} — ${fail.length} FAIL · ${warn.length} WARN · ${unk.length} UNKNOWN\n\n`;
  md+=`| Host | Verdict | robots | blocks-googlebot | sitemap | entries | newest lastmod | flags |\n|---|:--:|:--:|:--:|:--:|---:|---:|---|\n`;
  for(const r of results){
    md+=`| ${r.host} | ${r.verdict} | ${r.robots_status||'—'} | ${r.robots_blocks_all?'🔴 YES':'no'} | ${r.sitemap_status||'—'}${r.sitemap_is_index?' (idx)':''} | ${r.sitemap_entries??'—'} | ${r.sitemap_newest_lastmod_days!=null?r.sitemap_newest_lastmod_days+'d':'—'} | ${r.flags.join(', ')||'—'} |\n`;
  }
  md+=`\n_Flags: ROBOTS_BLOCKS_GOOGLEBOT = Googlebot's effective robots group (its own if present, else \`User-agent: *\`) has \`Disallow: /\` — suppresses ALL Google indexing (hard fail). sitemap_missing/invalid/empty = hard fail. sitemap_stale/no_lastmod/not_in_robots = warn. (idx) = sitemapindex (child sitemaps carry the lastmods). Relative \`Sitemap:\` URLs in robots are resolved against the host base. UNKNOWN = host unreachable (parked/off-fleet)._\n`;
  fs.writeFileSync(MD,md);

  console.log(`[robots-sitemap] ${emoji} ${verdict} · checked=${results.length} · FAIL=${fail.length} WARN=${warn.length} UNKNOWN=${unk.length}`);
  for(const r of [...fail,...warn]) console.log(`  ${r.verdict==='FAIL'?'🔴':'🟠'} ${r.host}: ${r.flags.join(', ')}`);
  console.log(`Report: ${MD}`);
  process.exit(verdict==='FAIL'?2:0);
})().catch(e=>{console.error('FATAL',e.message);process.exit(1);});