import { gunzipSync, gzipSync } from 'zlib'; import { Mutex, Semaphore, SemaphoreInterface, withTimeout } from 'async-mutex'; import { Cacheable, CacheableMemory, Keyv } from 'cacheable'; import CachePolicy from 'http-cache-semantics'; import { Cookie, CookieJar } from 'tough-cookie'; import { fetch, Headers, RequestInit, Response } from 'undici'; import winston from 'winston'; import { BlockedError, HttpError, NotFoundError, QueueIsFullError, TimeoutError, TooManyRequestsError, TooManyTimeoutsError } from '../error'; import { BlockedReason, Context } from '../types'; import { noCache } from './config'; import { getProxyAgent, getProxyForUrl } from './dispatcher'; import { envGet } from './env'; export interface HttpCacheItem { body: string; headers: CachePolicy.Headers; status: number; statusText: string; ttl: number; url: string; } interface FlareSolverrResult { status: string; message: string; solution: { url: string; status: number; cookies: { domain: string; expires: number; httpOnly: boolean; name: string; path: string; sameSite: string; secure: boolean; value: string; }[]; userAgent: string; headers: Record; response: string; }; startTimeStamp: number; endTimeStamp: number; version: string; } export type CustomRequestInit = RequestInit & { minCacheTtl?: number; noCache?: boolean; noFlareSolverr?: boolean; noProxyHeaders?: boolean; queueLimit?: number; queueTimeout?: number; timeout?: number; timeoutsCountThrow?: number; }; export class Fetcher { private readonly MIN_CACHE_TTL = 900000; // 15m private readonly DEFAULT_TIMEOUT = 10000; private readonly DEFAULT_QUEUE_LIMIT = 10; private readonly DEFAULT_QUEUE_TIMEOUT = 10000; private readonly DEFAULT_TIMEOUTS_COUNT_THROW = 30; private readonly TIMEOUT_CACHE_TTL = 3600000; // 1h private readonly MAX_WAIT_RETRY_AFTER = 10000; private readonly logger: winston.Logger; private readonly httpCache = new Cacheable({ primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024 }) }), stats: true, }); private readonly rateLimitedCache = new Cacheable({ primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024 }) }) }); private readonly semaphores = new Map(); private readonly hostUserAgentMap = new Map(); private readonly cookieJar = new CookieJar(); private readonly timeoutsCountCache = new Cacheable({ primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024 }) }) }); private readonly timeoutsCountMutex = new Mutex(); public constructor(logger: winston.Logger) { this.logger = logger; } public stats() { return { httpCache: this.httpCache.stats, }; }; public async fetch(ctx: Context, url: URL, init?: CustomRequestInit): Promise { return await this.cachedFetch(ctx, url, init); }; public async text(ctx: Context, url: URL, init?: CustomRequestInit): Promise { return (await this.cachedFetch(ctx, url, init)).body; }; public async textPost(ctx: Context, url: URL, body: string, init?: CustomRequestInit): Promise { return (await this.cachedFetch(ctx, url, { ...init, method: 'POST', body })).body; }; public async head(ctx: Context, url: URL, init?: CustomRequestInit): Promise { return (await this.cachedFetch(ctx, url, { ...init, method: 'HEAD' })).headers; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any public async json(ctx: Context, url: URL, init?: CustomRequestInit): Promise { const jsonInit = { headers: { Accept: 'application/json,text/plain,*/*', }, ...init, }; return JSON.parse(await this.text(ctx, url, jsonInit)); } private getInit(ctx: Context, url: URL, init?: CustomRequestInit): CustomRequestInit { const cookieString = this.cookieJar.getCookieStringSync(url.href); const noProxyHeaders = init?.noProxyHeaders ?? false; const forwardedProto = url.protocol.slice(0, -1); return { ...init, headers: { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'en', ...(url.username && { Authorization: 'Basic ' + Buffer.from(`${url.username}:${url.password}`).toString('base64') }), 'Priority': 'u=0', 'User-Agent': this.hostUserAgentMap.get(url.host) ?? 'node', ...(cookieString && { Cookie: cookieString }), ...(ctx.ip && !noProxyHeaders && { 'Forwarded': `by=unknown;for=${ctx.ip};host=${url.host};proto=${forwardedProto}`, 'X-Forwarded-For': ctx.ip, 'X-Forwarded-Host': url.host, 'X-Forwarded-Proto': forwardedProto, 'X-Real-IP': ctx.ip, }), ...init?.headers, }, }; }; private async handleHttpCacheItem(ctx: Context, httpCacheItem: HttpCacheItem, url: URL, init?: CustomRequestInit): Promise { const triggeredCloudflareTurnstile = httpCacheItem.body.includes('cf-turnstile'); if (httpCacheItem.status && httpCacheItem.status >= 200 && httpCacheItem.status <= 399 && !triggeredCloudflareTurnstile) { return httpCacheItem; } if (httpCacheItem.status === 404) { throw new NotFoundError(); } if (httpCacheItem.headers['cf-mitigated'] === 'challenge' || triggeredCloudflareTurnstile) { const noFlareSolverr = init?.noFlareSolverr ?? false; const flareSolverrEndpoint = envGet('FLARESOLVERR_ENDPOINT'); if (noFlareSolverr || !flareSolverrEndpoint) { throw new BlockedError(url, BlockedReason.cloudflare_challenge, httpCacheItem.headers); } this.logger.info(`Query FlareSolverr for ${url.href}`, ctx); const body = { cmd: 'request.get', url: url.href, session: 'default' }; const challengeResult = await (await this.queuedFetch(ctx, new URL(flareSolverrEndpoint), { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' }, queueLimit: 1, timeout: 15000 })).json() as FlareSolverrResult; if (challengeResult.status !== 'ok') { this.logger.warn(`FlareSolverr issue: ${JSON.stringify(challengeResult)}`, ctx); throw new BlockedError(url, BlockedReason.flaresolverr_failed, {}); } challengeResult.solution.cookies.forEach((cookie) => { if (!['cf_clearance'].includes(cookie.name)) { return; } this.cookieJar.setCookie( new Cookie({ key: cookie.name, value: cookie.value, expires: new Date(cookie.expires * 1000), domain: cookie.domain.replace(/^.+/, ''), }), url.href, ); }); this.hostUserAgentMap.set(url.host, challengeResult.solution.userAgent); httpCacheItem.body = challengeResult.solution.response; return httpCacheItem; } if (httpCacheItem.status === 403) { if (ctx.config.mediaFlowProxyUrl && url.href.startsWith(ctx.config.mediaFlowProxyUrl)) { throw new BlockedError(url, BlockedReason.media_flow_proxy_auth, httpCacheItem.headers); } throw new BlockedError(url, BlockedReason.unknown, httpCacheItem.headers); } if (httpCacheItem.status === 451) { throw new BlockedError(url, BlockedReason.cloudflare_censor, httpCacheItem.headers); } if (httpCacheItem.status === 429) { const retryAfter = parseInt(`${httpCacheItem.headers['retry-after']}`); if (!isNaN(retryAfter)) { await this.rateLimitedCache.set(url.host, true, retryAfter * 1000); } throw new TooManyRequestsError(retryAfter); } throw new HttpError(httpCacheItem.status, httpCacheItem.statusText, httpCacheItem.headers); }; private determineCacheKey(url: URL, init?: CustomRequestInit): string { return `${url.href}_${init?.body?.toString()}`; } private determineCacheTtl(status: number, policy: CachePolicy, init?: CustomRequestInit): number { if ((status >= 200 && status <= 299) || status === 404) { return Math.max(policy.timeToLive(), init?.minCacheTtl ?? this.MIN_CACHE_TTL); } return 0; }; private async cacheGet(key: string): Promise { const buffer = await this.httpCache.get(key); return buffer ? JSON.parse(gunzipSync(buffer).toString()) : undefined; } private async cacheSet(key: string, httpCacheItem: HttpCacheItem) { if (httpCacheItem.ttl <= 0) { return; } await this.httpCache.set(key, gzipSync(JSON.stringify(httpCacheItem)), httpCacheItem.ttl); } private headersToObject(headers: Headers): Record { const obj: Record = {}; headers.forEach((value, name) => { obj[name] = value; }); return obj; }; private async cachedFetch(ctx: Context, url: URL, init?: CustomRequestInit): Promise { const newInit = this.getInit(ctx, url, init); const request: CachePolicy.Request = { url: url.href, method: newInit.method ?? 'GET', headers: {} }; const cacheKey = this.determineCacheKey(url, init); let httpCacheItem = await this.cacheGet(cacheKey); const disableCache = init?.noCache ?? noCache(ctx.config); if (httpCacheItem && !disableCache) { this.logger.info(`Cached fetch ${request.method} ${url}: ${httpCacheItem.status} (${httpCacheItem.statusText})`, ctx); return this.handleHttpCacheItem(ctx, httpCacheItem, url, init); } const response = await this.queuedFetch(ctx, url, newInit); const body = await response.text(); const policy = new CachePolicy(request, { status: response.status, headers: this.headersToObject(response.headers) }, { shared: false }); const ttl = this.determineCacheTtl(response.status, policy, init); httpCacheItem = { headers: policy.responseHeaders(), status: response.status, statusText: response.statusText, body, ttl, url: response.url }; await this.cacheSet(cacheKey, httpCacheItem); return this.handleHttpCacheItem(ctx, httpCacheItem, url, init); }; protected async fetchWithTimeout(ctx: Context, url: URL, init?: CustomRequestInit, tryCount = 0): Promise { const proxyUrl = getProxyForUrl(ctx, url); const headers = init?.headers as Record | undefined; let message = `Fetch ${init?.method ?? 'GET'} ${url}`; /* istanbul ignore if */ if (headers && headers['Referer']) { message += ' with referer ' + headers['Referer']; } /* istanbul ignore if */ if (proxyUrl) { message += ' via proxy ' + proxyUrl; } this.logger.info(message, ctx); const isRateLimitedRaw = await this.rateLimitedCache.getRaw(url.host); /* istanbul ignore if */ if (isRateLimitedRaw && isRateLimitedRaw.value && isRateLimitedRaw.expires) { const ttl = isRateLimitedRaw.expires - Date.now(); if (ttl <= this.MAX_WAIT_RETRY_AFTER && tryCount < 1) { this.logger.info(`Wait out rate limit for ${url}`, ctx); await this.sleep(ttl); return await this.fetchWithTimeout(ctx, url, { ...init, queueLimit: 1 }, ++tryCount); } throw new TooManyRequestsError((isRateLimitedRaw.expires as number - Date.now()) / 1000); } const timeouts = (await this.timeoutsCountCache.get(url.host)) ?? 0; if (timeouts >= (init?.timeoutsCountThrow ?? this.DEFAULT_TIMEOUTS_COUNT_THROW)) { throw new TooManyTimeoutsError(); } let response; try { const finalUrl = new URL(url.href); finalUrl.username = ''; finalUrl.password = ''; const finalInit = { ...init, keepalive: true, signal: AbortSignal.timeout(init?.timeout ?? this.DEFAULT_TIMEOUT), ...(/* istanbul ignore next */ proxyUrl && { dispatcher: getProxyAgent(proxyUrl) }), }; response = await fetch(finalUrl, finalInit); } catch (error) { this.logger.info(`Got error ${error} for ${url}`, ctx); if (error instanceof DOMException && ['AbortError', 'TimeoutError'].includes(error.name)) { await this.increaseTimeoutsCount(url); throw new TimeoutError(); } if (tryCount < 3) { this.logger.warn(`Retrying fetch ${init?.method ?? 'GET'} ${url} because of an unexpected error`, ctx); await this.sleep(333); return await this.fetchWithTimeout(ctx, url, init, ++tryCount); } throw error; } this.logger.info(`Got ${response.status} (${response.statusText}) for ${url}`, ctx); await this.decreaseTimeoutsCount(url); if (response.status === 429) { const retryAfter = parseInt(`${response.headers.get('retry-after')}`) * 1000; if (retryAfter <= this.MAX_WAIT_RETRY_AFTER && tryCount < 1) { this.logger.info(`Wait out rate limit for ${url.host}`, ctx); await this.sleep(retryAfter); return await this.fetchWithTimeout(ctx, url, { ...init, queueLimit: 1 }, ++tryCount); } } if ([503, 504].includes(response.status) && tryCount < 3) { this.logger.warn(`Retrying fetch ${init?.method ?? 'GET'} ${url} because of a temporary server error`, ctx); await this.sleep(333); return await this.fetchWithTimeout(ctx, url, init, ++tryCount); } return response; }; private async increaseTimeoutsCount(url: URL) { await this.timeoutsCountMutex.runExclusive(async () => { const count = (await this.timeoutsCountCache.get(url.host)) ?? 0; const newCount = count + 1; await this.timeoutsCountCache.set(url.host, newCount, this.TIMEOUT_CACHE_TTL); }); } private async decreaseTimeoutsCount(url: URL) { await this.timeoutsCountMutex.runExclusive(async () => { const count = (await this.timeoutsCountCache.get(url.host)) ?? 0; const newCount = Math.max(0, count - 1); await this.timeoutsCountCache.set(url.host, newCount, this.TIMEOUT_CACHE_TTL); }); } private getSemaphore(url: URL, queueLimit: number, queueTimeout: number): SemaphoreInterface { let sem = this.semaphores.get(url.host); if (!sem) { sem = withTimeout(new Semaphore(queueLimit), queueTimeout, new QueueIsFullError()); this.semaphores.set(url.host, sem); } return sem; } private async queuedFetch(ctx: Context, url: URL, init?: CustomRequestInit): Promise { const queueLimit = init?.queueLimit ?? this.DEFAULT_QUEUE_LIMIT; const queueTimeout = init?.queueTimeout ?? this.DEFAULT_QUEUE_TIMEOUT; const semaphore = this.getSemaphore(url, queueLimit, queueTimeout); const [,release] = await semaphore.acquire(); try { return await this.fetchWithTimeout(ctx, url, init); } finally { release(); } } private sleep(ms: number): Promise { return new Promise(sleep => setTimeout(sleep, ms)); } }