2026-07-19 12:38:43 +08:00
import { resolve4 , resolve6 } from "node:dns/promises" ;
import { isIP } from "node:net" ;
2026-07-19 05:32:55 +08:00
export type RssItem = { id : string ; content : string ; link : string ; publishedAt : string | null };
2026-07-19 12:38:43 +08:00
const MAX_FEED_BYTES = 2 * 1024 * 1024 ;
const decode = ( value : string ) => value . replace ( /<!\[CDATA\[([\s\S]*?)\]\]>/g , "$1" ). replace ( /</g , "<" ). replace ( />/g , ">" ). replace ( /"/g , '"' ). replace ( /&/g , "&" );
2026-07-19 05:32:55 +08:00
const field = ( xml : string , name : string ) => decode ( xml . match ( new RegExp ( `< ${ name } [^>]*>([\\s\\S]*?)</ ${ name } >` , "i" )) ? .[ 1 ] ? . trim () || "" );
2026-07-19 12:38:43 +08:00
const atomLink = ( xml : string ) => decode ( xml . match ( /<link\b[^>]*\bhref=["']([^"']+)["'][^>]*>/i ) ? .[ 1 ] || "" );
const date = ( value : string ) => { const parsed = new Date ( value ); return value && ! Number . isNaN ( parsed . getTime ()) ? parsed . toISOString () : null ; };
function privateIp ( address : string ) {
if ( isIP ( address ) === 4 ) { const [ a , b ] = address . split ( "." ). map ( Number ); return a === 10 || a === 127 || a === 0 || ( a === 169 && b === 254 ) || ( a === 172 && b >= 16 && b <= 31 ) || ( a === 192 && b === 168 ); }
const normalized = address . toLowerCase (); return normalized === "::1" || normalized . startsWith ( "fc" ) || normalized . startsWith ( "fd" ) || normalized . startsWith ( "fe80:" ) || normalized . startsWith ( "::ffff:127." ) || normalized . startsWith ( "::ffff:10." ) || normalized . startsWith ( "::ffff:192.168." );
}
async function assertPublicHttps ( raw : string ) {
const url = new URL ( raw ); if ( url . protocol !== "https:" || url . username || url . password ) throw new Error ( "RSS feed must use a public HTTPS URL" ); const host = url . hostname . toLowerCase (); if ( host === "localhost" || host . endsWith ( ".local" )) throw new Error ( "RSS feed host is not public" );
const direct = isIP ( host ); const addresses = direct ? [ host ] : [... await resolve4 ( host ). catch (() => [] as string []), ... await resolve6 ( host ). catch (() => [] as string [])];
if ( ! addresses . length || addresses . some ( privateIp )) throw new Error ( "RSS feed host is not public" ); return url ;
}
async function responseText ( response : Response ) {
const headerSize = Number ( response . headers . get ( "content-length" ) || 0 ); if ( headerSize > MAX_FEED_BYTES ) throw new Error ( "RSS feed is too large" ); const reader = response . body ? . getReader (); if ( ! reader ) return "" ; const chunks : Uint8Array [] = []; let total = 0 ;
while ( true ) { const { done , value } = await reader . read (); if ( done ) break ; total += value . byteLength ; if ( total > MAX_FEED_BYTES ) { await reader . cancel (); throw new Error ( "RSS feed is too large" ); } chunks . push ( value ); }
return Buffer . concat ( chunks . map (( item ) => Buffer . from ( item ))). toString ( "utf8" );
}
export function parseFeed ( xml : string ) : RssItem [] {
if ( /<!DOCTYPE|<!ENTITY/i . test ( xml )) throw new Error ( "RSS feed contains unsupported XML declarations" );
const rss = xml . match ( /<item\b[\s\S]*?<\/item>/gi ) || []; const atom = xml . match ( /<entry\b[\s\S]*?<\/entry>/gi ) || []; const entries = rss . length ? rss . map (( body ) => ({ body , atom : false })) : atom . map (( body ) => ({ body , atom : true }));
return entries . map (({ body , atom }) => { const link = atom ? atomLink ( body ) : field ( body , "link" ); const id = field ( body , atom ? "id" : "guid" ) || link ; const content = field ( body , atom ? "content" : "description" ) || field ( body , atom ? "summary" : "title" ) || field ( body , "title" ); return { id , link , content , publishedAt : date ( field ( body , atom ? "published" : "pubDate" ) || field ( body , atom ? "updated" : "" )) }; }). filter (( item ) => item . id && item . content ) as RssItem [];
}
export async function fetchRss ( raw : string ) {
let url = await assertPublicHttps ( raw );
for ( let redirects = 0 ; redirects <= 3 ; redirects ++ ) { const response = await fetch ( url , { redirect : "manual" , signal : AbortSignal.timeout ( 15 _000 ), headers : { Accept : "application/rss+xml, application/atom+xml, application/xml, text/xml" } }); if ([ 301 , 302 , 303 , 307 , 308 ]. includes ( response . status )) { const location = response . headers . get ( "location" ); if ( ! location || redirects === 3 ) throw new Error ( "RSS feed redirect is invalid" ); url = await assertPublicHttps ( new URL ( location , url ). toString ()); continue ; } if ( ! response . ok ) throw new Error ( `RSS ${ response . status } ` ); return parseFeed ( await responseText ( response )); }
throw new Error ( "RSS feed redirect is invalid" );
2026-07-19 05:32:55 +08:00
}