@@ -115,11 +115,18 @@ type DiscoverResult =
115115 | { kind : 'blocked' ; error : string }
116116 | { kind : 'error' ; error : string } ;
117117
118+ // Our honest fetcher identity, used for the first attempt of every upstream fetch.
119+ // We don't impersonate Googlebot ("like FeedFetcher-Google"): CDNs such as Akamai
120+ // (used by cbc.ca) verify Google crawlers by reverse-DNS and 403 any non-Google IP
121+ // that claims to be one. But the honest UA is itself a problem on those same CDNs —
122+ // their bot managers gate on the UA's *shape* (a parenthetical comment, an embedded
123+ // URL, or the token "bot" all read as non-browser), so this string is frequently
124+ // blocked. When it's refused we transparently retry with BROWSER_FALLBACK_UA; see
125+ // fetchWithBotFallback.
126+ const HONEST_UA = 'Skyreader/1.0 (+https://skyreader.app)' ;
127+
118128const FETCH_HEADERS = {
119- // Don't impersonate Googlebot (e.g. "like FeedFetcher-Google"): CDNs such as
120- // Akamai (used by cbc.ca) verify Google crawlers by reverse-DNS and 403 any
121- // non-Google IP that claims to be one. Identify honestly with a contact URL.
122- 'User-Agent' : 'Skyreader/1.0 (+https://skyreader.app)' ,
129+ 'User-Agent' : HONEST_UA ,
123130 Accept : 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*' ,
124131 'Accept-Language' : 'en-US,en;q=0.9' ,
125132 'Accept-Encoding' : 'gzip, deflate' ,
@@ -191,6 +198,59 @@ const MAX_RESPONSE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
191198// time so repeat (and cross-user) saves of the same article are free.
192199const EXTRACT_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 ; // 7 days
193200
201+ // A real desktop-Chrome User-Agent, used only as a fallback when HONEST_UA is
202+ // refused. Verified against cbc.ca (Akamai): a browser UA returns 200 straight
203+ // through Bun's fetch, so it's the UA string alone — not the TLS/JA3 fingerprint —
204+ // that these CDNs gate on, which is why swapping just this header is sufficient.
205+ const BROWSER_FALLBACK_UA =
206+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' ;
207+
208+ // First-attempt timeout for the honest-UA probe. Hostile CDNs often don't 403 —
209+ // they silently black-hole the connection so the fetch hangs to the full timeout.
210+ // Capping the honest probe well below FETCH_TIMEOUT_MS means a black-hole costs
211+ // ~this long, not 30s, before we retry with a browser UA. Kept comfortably above a
212+ // healthy feed's latency so normal-but-slow feeds aren't needlessly retried.
213+ const HONEST_UA_PROBE_TIMEOUT_MS = 10 * 1000 ; // 10 seconds
214+
215+ // Fetch an upstream URL identifying honestly (HONEST_UA), transparently retrying
216+ // once with a browser UA when the honest attempt is blocked — either an explicit
217+ // 403 or a silent black-hole (timeout / connection error on the short probe).
218+ // `headers` carries the caller's headers (incl. HONEST_UA and any conditional-
219+ // request headers); only the User-Agent and per-attempt timeout differ between the
220+ // two attempts. Returns the final Response unchanged for the caller to classify; if
221+ // the browser-UA attempt also fails, its error propagates (and is classified as a
222+ // network/timeout error exactly as before this fallback existed).
223+ export async function fetchWithBotFallback (
224+ url : string ,
225+ headers : Record < string , string > ,
226+ opts : { probeTimeoutMs ?: number ; fetchTimeoutMs ?: number } = { }
227+ ) : Promise < Response > {
228+ const probeTimeoutMs = opts . probeTimeoutMs ?? HONEST_UA_PROBE_TIMEOUT_MS ;
229+ const fetchTimeoutMs = opts . fetchTimeoutMs ?? FETCH_TIMEOUT_MS ;
230+
231+ // Attempt 1: honest UA, short timeout.
232+ try {
233+ const res = await safeFetch ( url , {
234+ headers,
235+ signal : AbortSignal . timeout ( probeTimeoutMs ) ,
236+ } ) ;
237+ // Only a 403 is treated as a block worth retrying; every other status
238+ // (200/304/404/5xx) is a real answer the caller should handle as-is.
239+ if ( ! isBlockedStatus ( res . status ) ) return res ;
240+ await res . body ?. cancel ( ) . catch ( ( ) => { } ) ;
241+ } catch {
242+ // Timeout / connection error on the honest attempt: possibly a silent block,
243+ // possibly a genuinely slow or down feed — indistinguishable here, so fall
244+ // through to the browser-UA attempt and let it settle the outcome.
245+ }
246+
247+ // Attempt 2: browser UA, full timeout.
248+ return safeFetch ( url , {
249+ headers : { ...headers , 'User-Agent' : BROWSER_FALLBACK_UA } ,
250+ signal : AbortSignal . timeout ( fetchTimeoutMs ) ,
251+ } ) ;
252+ }
253+
194254export interface ExtractedArticle {
195255 title : string | null ;
196256 author : string | null ;
@@ -714,10 +774,7 @@ export function initDatabase(db: Database): void {
714774// an HTTP response. Never throws — failures return an 'error'/'blocked' kind.
715775async function runDiscover ( siteUrl : string ) : Promise < DiscoverResult > {
716776 try {
717- const response = await safeFetch ( siteUrl , {
718- headers : FETCH_HEADERS ,
719- signal : AbortSignal . timeout ( FETCH_TIMEOUT_MS ) ,
720- } ) ;
777+ const response = await fetchWithBotFallback ( siteUrl , FETCH_HEADERS ) ;
721778
722779 if ( ! response . ok ) {
723780 const { error, blocked } = describeFetchFailure ( response . status , siteUrl ) ;
@@ -914,10 +971,7 @@ export function createApp(db: Database, config: AppConfig) {
914971 if ( cached ?. last_modified ) headers [ 'If-Modified-Since' ] = cached . last_modified ;
915972
916973 try {
917- const response = await safeFetch ( url , {
918- headers,
919- signal : AbortSignal . timeout ( FETCH_TIMEOUT_MS ) ,
920- } ) ;
974+ const response = await fetchWithBotFallback ( url , headers ) ;
921975
922976 if ( response . status === 304 && cached ) {
923977 // Success: reset error tracking
0 commit comments