#!/usr/bin/env node
import k from"node:fs/promises";import Z from"node:path";import b from"node:path";import F from"node:os";import{fileURLToPath as v}from"node:url";var B={"A — AI":"ai","B — Devtools":"devtools","C — Web Platform":"web","D — Security":"security","E — Repos & Releases":"repos","Cross-Domain Discovery":"cross"},W=b.dirname(v(import.meta.url)),$=b.resolve(W,"..","references","sources.md");function P(e){return e&&(e==="~"?F.homedir():e.startsWith("~/")?b.join(F.homedir(),e.slice(2)):e)}function C(e){return b.resolve(P(e))}function D(e,t="cross"){if(!e)return t;if(/^Cross[-\s]Domain/i.test(e))return"cross";for(let[o,n]of Object.entries(B))if(e===o||e.startsWith(o))return n;return t}async function T(e,t,o){let n=new Array(e.length),i=0;async function a(){for(;;){let c=i++;if(c>=e.length)return;n[c]=await o(e[c],c)}}return await Promise.all(Array.from({length:Math.min(t,e.length)},()=>a())),n}var A={"24h":1440*60*1e3,"48h":2880*60*1e3,"7d":10080*60*1e3,"14d":336*60*60*1e3,"30d":720*60*60*1e3};function L(e){let t=e.split(/\r?\n/),o=[],n=new Set,i="Unknown",a="",c=!1,s=!1,r=-1,l=!1;function u(d){let m=String(d||"").replace(/`/g,"").trim(),f=m.match(/\((https?:\/\/[^\s)]+)\)/);if(f)return f[1];let y=m.match(/https?:\/\/\S+/);return y?y[0].replace(/[),.;]+$/,""):/^[a-z0-9][\w.-]+\.[a-z]{2,}(\/\S*)?$/.test(m)?`https://${m}`:""}function h(d,m){if(!d)return;let f=`${i}::${d}`;n.has(f)||(n.add(f),o.push({section:i,domain:D(i),url:d,line:m}))}function g(d){return[...String(d||"").matchAll(/\b([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\b/g)].map(m=>m[1])}function S(d){return`https://github.com/${d}/releases.atom`}return t.forEach((d,m)=>{let f=d.trim(),y=d.match(/^##\s+(.+)$/);y&&(i=y[1].trim(),a="",r=-1,l=!1);let R=d.match(/^###\s+(.+)$/);if(R&&(a=R[1].trim(),r=-1,l=!1),/^### RSS Feeds/.test(f)){c=!0,s=!1;return}if(c&&/^```/.test(f)){c=!1,s=!0;return}if(s&&/^```/.test(f)){s=!1;return}if(s){f.startsWith("#")||h(u(f),m+1);return}if(f.startsWith("|")){let w=f.replace(/^\|/,"").replace(/\|$/,"").split("|").map(p=>p.trim()),E=w.findIndex(p=>/^RSS$/i.test(p.replace(/`/g,"").trim()));if(E>=0){r=E,l=!1;return}if(r>=0&&!l&&w.every(p=>/^[-:\s]+$/.test(p))){l=!0;return}if(r>=0&&l&&w.length>r){let p=w[r].replace(/`/g,"").trim();p&&p!=="—"&&p!=="-"&&h(u(p),m+1)}return}if(r>=0&&(r=-1,l=!1),/^Release Feed Pattern/i.test(a))for(let w of g(d))h(S(w),m+1)}),o}function j(e){return String(e||"").replace(/<!\[CDATA\[([\s\S]*?)]]>/g,"$1")}function q(e){return j(e).replace(/<[^>]*>/g," ")}function _(e){return String(e||"").replace(/&lt;/g,"<").replace(/&gt;/g,">").replace(/&amp;/g,"&").replace(/&quot;/g,'"').replace(/&#39;/g,"'").replace(/&#x27;/g,"'").replace(/&#(\d+);/g,(t,o)=>String.fromCharCode(Number(o)))}function M(e){return _(q(e)).replace(/\s+/g," ").trim()}function O(e,t){return/(application|text)\/(atom\+xml|rss\+xml|xml)/i.test(e||"")?!0:/<(rss|feed|rdf:rdf)(\s|>)/i.test(t)||/<(entry|item)(\s|>)/i.test(t)}function I(e){let t=String(e||"").match(/<title[^>]*>([\s\S]*?)<\/title>/i);return t?M(t[1]).slice(0,200):""}function z(e){return String(e||"").match(/<(item|entry)(\s|>)[\s\S]*?<\/(item|entry)>/gi)||[]}function x(e,t){let o=String(t||"").replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),n=new RegExp(`<${o}(\\s[^>]*)?>([\\s\\S]*?)<\\/${o}>`,"i"),i=String(e||"").match(n);return i?M(i[2]):""}function J(e){let t=Date.parse(String(e||""));return Number.isNaN(t)?null:new Date(t)}async function N(e,t=1024*1024){if(!e.body)return"";let o=e.body.getReader(),n=new TextDecoder,i=0,a="";try{for(;i<t;){let{done:c,value:s}=await o.read();if(c)break;let r=t-i,l=s.byteLength>r?s.subarray(0,r):s;if(i+=l.byteLength,a+=n.decode(l,{stream:!0}),l.byteLength<s.byteLength)break}}finally{try{await o.cancel()}catch{}}return a+=n.decode(),a}function V(e,t=new Date){return!e||!A[e]?null:new Date(t.getTime()-A[e])}function U(e,t,o=new Date){let n=z(e),i=V(t,o),a=[],c=0;for(let u of n){let h=x(u,"pubDate")||x(u,"updated")||x(u,"published")||x(u,"dc:date"),g=J(h);g?a.push(g):c+=1}let s=a.length?new Date(Math.max(...a.map(u=>u.getTime()))):null,r=i?a.filter(u=>u.getTime()>=i.getTime()).length:a.length,l=t?a.length===0&&n.length>0?"undated":r>0?"recent":"stale":"not-requested";return{entryCount:n.length,datedEntryCount:a.length,undatedEntryCount:c,recentCount:r,newestEntryAt:s?s.toISOString():"",fresh:!t||r>0,freshnessMode:l}}function H(){console.log(`Usage:
  node skills/whats-new/scripts/check-rss.mjs [--sources <sources.md>] [--json-out <report.json>] [--timeout-ms <ms>] [--concurrency <n>] [--failures-only] [--window-label <24h|7d|14d|30d>]

NOTE: For standard discovery runs, prefer "fetch-rss --include-health" which combines
item fetching and health checking in a single network pass. This standalone script
remains useful for targeted feed audits and debugging.

What it does:
  1. Reads RSS/Atom URLs from sources.md tables, fenced RSS blocks, and repo release-feed patterns.
  2. Fetches them concurrently.
  3. Checks whether the response looks like a real feed.
  4. Optionally validates freshness by selected window label.
  5. Prints a summary and optionally writes a JSON report.
`)}function K(e){let t={sources:$,jsonOut:"",timeoutMs:15e3,concurrency:6,failuresOnly:!1,windowLabel:"",help:!1};for(let o=0;o<e.length;o+=1){let n=e[o];if(n==="--help"||n==="-h"){t.help=!0;continue}if(n==="--failures-only"){t.failuresOnly=!0;continue}if(n==="--sources"){t.sources=e[o+1]||"",o+=1;continue}if(n==="--json-out"){t.jsonOut=e[o+1]||"",o+=1;continue}if(n==="--timeout-ms"){t.timeoutMs=Number(e[o+1]||0),o+=1;continue}if(n==="--concurrency"){t.concurrency=Number(e[o+1]||0),o+=1;continue}if(n==="--window-label"){t.windowLabel=(e[o+1]||"").trim(),o+=1;continue}throw new Error(`Unknown argument: ${n}`)}if(!Number.isFinite(t.timeoutMs)||t.timeoutMs<=0)throw new Error("Expected --timeout-ms to be a positive number.");if(!Number.isInteger(t.concurrency)||t.concurrency<=0)throw new Error("Expected --concurrency to be a positive integer.");if(t.windowLabel&&!["24h","7d","14d","30d"].includes(t.windowLabel))throw new Error('Expected --window-label to be one of: "24h", "7d", "14d", "30d".');return t}async function X(e,t){let o=Date.now();try{let n=await fetch(e.url,{headers:{"user-agent":"whats-new-rss-check/1.0",accept:"application/atom+xml, application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.1"},redirect:"follow",signal:AbortSignal.timeout(t.timeoutMs)}),i=await N(n,1024*1024),a=n.headers.get("content-type")||"",c=n.ok&&O(a,i),s=c?I(i).slice(0,160):"",r=c?U(i,t.windowLabel):null,l=!!t.windowLabel,u=!!(c&&r&&r.entryCount===0),h=!!(l&&r&&r.entryCount>0&&r.datedEntryCount===0),g=!!(l&&r&&r.datedEntryCount>0&&!r.fresh),S=c&&!u&&!h&&!g;return{...e,ok:S,status:n.status,contentType:a,finalUrl:n.url,title:s,reason:S?"ok":u?"Feed returned no entries.":h?"Feed has entries but no parseable item dates.":g?`No entries found inside ${t.windowLabel}.`:n.ok?"Response did not look like RSS/Atom/XML.":`HTTP ${n.status}`,windowLabel:t.windowLabel||"",freshnessRequired:l,entryCount:r?r.entryCount:0,datedEntryCount:r?r.datedEntryCount:0,undatedEntryCount:r?r.undatedEntryCount:0,recentCount:r?r.recentCount:0,newestEntryAt:r?r.newestEntryAt:"",fresh:r?r.fresh:!1,freshnessMode:r?r.freshnessMode:"invalid",durationMs:Date.now()-o}}catch(n){let i=n&&typeof n=="object"&&n.cause&&typeof n.cause.message=="string"?` (${n.cause.message})`:"";return{...e,ok:!1,status:null,contentType:"",finalUrl:e.url,title:"",reason:`${n.name}: ${n.message}${i}`,windowLabel:t.windowLabel||"",freshnessRequired:!!t.windowLabel,entryCount:0,datedEntryCount:0,undatedEntryCount:0,recentCount:0,newestEntryAt:"",fresh:!1,freshnessMode:"error",durationMs:Date.now()-o}}}function G(e){let t=e.filter(s=>s.ok),o=e.filter(s=>!s.ok),n=e.filter(s=>s.reason==="Feed returned no entries."),i=e.filter(s=>s.freshnessRequired&&s.status!==null&&s.reason.startsWith("No entries found inside")),a=e.filter(s=>s.freshnessRequired&&s.reason==="Feed has entries but no parseable item dates."),c=new Map;for(let s of e){let r=c.get(s.section)||{total:0,passed:0,failed:0};r.total+=1,r.passed+=s.ok?1:0,r.failed+=s.ok?0:1,c.set(s.section,r)}return{total:e.length,passed:t.length,failed:o.length,empty:n.length,stale:i.length,undated:a.length,sections:Array.from(c.entries()).map(([s,r])=>({section:s,...r}))}}function Q(e){let t=e.ok?"PASS":"FAIL",o=e.status===null?"ERR":String(e.status),n=e.title?` | ${e.title}`:"",i=e.freshnessRequired?` | recent=${e.recentCount} newest=${e.newestEntryAt||"n/a"}`:"";return`${t} [${o}] ${e.section} :: ${e.url}${n}${i}${e.ok?"":` | ${e.reason}`}`}async function Y(e,t){if(!e)return;let o=C(e);await k.mkdir(Z.dirname(o),{recursive:!0}),await k.writeFile(o,JSON.stringify(t,null,2)+`
`,"utf8")}async function ee(){let e=K(process.argv.slice(2));if(e.help){H();return}let t=C(e.sources||$),o=await k.readFile(t,"utf8"),n=L(o);if(n.length===0)throw new Error(`No RSS feed URLs found in ${t}.`);let i=await T(n,e.concurrency,s=>X(s,e)),a=G(i),c=e.failuresOnly?i.filter(s=>!s.ok):i;console.log(`Checked ${a.total} feed URLs from ${t}`),console.log(`Passed: ${a.passed} | Failed: ${a.failed}`),e.windowLabel&&console.log(`Freshness window: ${e.windowLabel} | Empty feeds: ${a.empty} | Stale feeds: ${a.stale} | Undated feeds: ${a.undated}`);for(let s of a.sections)console.log(`- ${s.section}: ${s.passed}/${s.total} passed`);console.log("");for(let s of c)console.log(Q(s));await Y(e.jsonOut,{checkedAt:new Date().toISOString(),sourcesPath:t,summary:a,results:i}),a.failed>0&&(process.exitCode=1)}ee().catch(e=>{console.error(e.message||String(e)),process.exitCode=1});
