src / services / retryFetch.ts
src / services / retryFetch.ts
import { REQUEST_TIMEOUT_MS, MAX_RETRIES, MAX_HTML_SIZE } from "../utils/constants";
import { isBlockedExtension } from "../utils/blockedExtensions";
import { RANDOM_UA_POOL } from "../utils/userAgents";
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
const WIKI_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
function isLikelyWikiUrl(url: string): boolean {
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
const wikiHosts = ["wikipedia.org", "wikimedia.org", "fandom.com", "fextralife.com", "ylvapedia.wiki"];
return wikiHosts.some(w => host.endsWith(w));
} catch {
return false;
}
}
function extractWikiTitle(url: string): string | null {
try {
const parsed = new URL(url);
const wikiPath = parsed.pathname.match(/^\/wiki\/(.+)$/i);
if (wikiPath?.[1]) return decodeURIComponent(wikiPath[1]).replace(/_/g, " ");
const title = parsed.searchParams.get("title");
if (title) return decodeURIComponent(title).replace(/_/g, " ");
} catch {}
return null;
}
function buildWikiApiUrls(url: string): string[] {
const title = extractWikiTitle(url);
if (!title) return [];
const urls: string[] = [];
for (const apiPath of ["/w/api.php", "/api.php"]) {
const apiUrl = new URL(url);
apiUrl.pathname = apiPath;
apiUrl.search = "";
apiUrl.hash = "";
apiUrl.searchParams.set("action", "parse");
apiUrl.searchParams.set("page", title);
apiUrl.searchParams.set("prop", "text");
apiUrl.searchParams.set("format", "json");
apiUrl.searchParams.set("origin", "*");
urls.push(apiUrl.toString());
}
return [...new Set(urls)];
}
async function fetchDirect(url: string, extraHeaders?: Record<string, string>, timeoutMs = REQUEST_TIMEOUT_MS): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: "follow",
headers: {
"User-Agent": RANDOM_UA_POOL[Math.floor(Math.random() * RANDOM_UA_POOL.length)],
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
...extraHeaders,
}
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const html = await response.text();
return html.length > MAX_HTML_SIZE ? html.slice(0, MAX_HTML_SIZE) : html;
} finally {
clearTimeout(timeout);
}
}
async function fetchViaWikiApi(url: string): Promise<string> {
const apiUrls = buildWikiApiUrls(url);
if (apiUrls.length === 0) throw new Error("No wiki API fallback available");
let lastError: unknown = null;
for (const apiUrl of apiUrls) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(apiUrl, {
signal: controller.signal,
redirect: "follow",
headers: {
"User-Agent": WIKI_UA,
"Accept": "application/json,text/plain,*/*",
}
});
if (!response.ok) throw new Error(`Wiki API HTTP ${response.status}`);
const data = await response.json() as { parse?: { text?: { ["*"]?: string } }; error?: { info?: string } };
const html = data?.parse?.text?.["*"];
if (!html) throw new Error(data?.error?.info || "Wiki API returned no HTML");
return html.length > MAX_HTML_SIZE ? html.slice(0, MAX_HTML_SIZE) : html;
} catch (error) {
lastError = error;
} finally {
clearTimeout(timeout);
}
}
const message = lastError instanceof Error ? lastError.message : String(lastError ?? "unknown wiki API error");
throw new Error(message);
}
export async function retryFetch(url: string, options?: { headers?: Record<string, string>; timeoutMs?: number }): Promise<string> {
if (isBlockedExtension(url)) throw new Error(`Blocked extension: ${url}`);
let lastError: unknown = null;
const useWikiFallback = isLikelyWikiUrl(url);
const extraHeaders = options?.headers;
const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
for (let i = 0; i < MAX_RETRIES; i++) {
try {
// If it's a wiki URL, try the API first on the initial attempt
if (useWikiFallback && i === 0) {
try { return await fetchViaWikiApi(url); }
catch (wikiError) { lastError = wikiError; }
}
// Always attempt direct fetch
return await fetchDirect(url, extraHeaders, timeoutMs);
} catch (error) {
lastError = error;
if (i < MAX_RETRIES - 1) {
const baseDelay = 150 * (i + 1);
const jitter = Math.random() * baseDelay;
await sleep(baseDelay + jitter);
}
}
}
// Final fallback: if direct fetch failed and it's a wiki, try API one last time
if (useWikiFallback) {
try { return await fetchViaWikiApi(url); } catch {}
}
const message = lastError instanceof Error ? lastError.message : String(lastError ?? "unknown error");
throw new Error(`Failed fetch: ${message}`);
}
import { REQUEST_TIMEOUT_MS, MAX_RETRIES, MAX_HTML_SIZE } from "../utils/constants";
import { isBlockedExtension } from "../utils/blockedExtensions";
import { RANDOM_UA_POOL } from "../utils/userAgents";
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
const WIKI_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
function isLikelyWikiUrl(url: string): boolean {
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
const wikiHosts = ["wikipedia.org", "wikimedia.org", "fandom.com", "fextralife.com", "ylvapedia.wiki"];
return wikiHosts.some(w => host.endsWith(w));
} catch {
return false;
}
}
function extractWikiTitle(url: string): string | null {
try {
const parsed = new URL(url);
const wikiPath = parsed.pathname.match(/^\/wiki\/(.+)$/i);
if (wikiPath?.[1]) return decodeURIComponent(wikiPath[1]).replace(/_/g, " ");
const title = parsed.searchParams.get("title");
if (title) return decodeURIComponent(title).replace(/_/g, " ");
} catch {}
return null;
}
function buildWikiApiUrls(url: string): string[] {
const title = extractWikiTitle(url);
if (!title) return [];
const urls: string[] = [];
for (const apiPath of ["/w/api.php", "/api.php"]) {
const apiUrl = new URL(url);
apiUrl.pathname = apiPath;
apiUrl.search = "";
apiUrl.hash = "";
apiUrl.searchParams.set("action", "parse");
apiUrl.searchParams.set("page", title);
apiUrl.searchParams.set("prop", "text");
apiUrl.searchParams.set("format", "json");
apiUrl.searchParams.set("origin", "*");
urls.push(apiUrl.toString());
}
return [...new Set(urls)];
}
async function fetchDirect(url: string, extraHeaders?: Record<string, string>, timeoutMs = REQUEST_TIMEOUT_MS): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: "follow",
headers: {
"User-Agent": RANDOM_UA_POOL[Math.floor(Math.random() * RANDOM_UA_POOL.length)],
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
...extraHeaders,
}
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const html = await response.text();
return html.length > MAX_HTML_SIZE ? html.slice(0, MAX_HTML_SIZE) : html;
} finally {
clearTimeout(timeout);
}
}
async function fetchViaWikiApi(url: string): Promise<string> {
const apiUrls = buildWikiApiUrls(url);
if (apiUrls.length === 0) throw new Error("No wiki API fallback available");
let lastError: unknown = null;
for (const apiUrl of apiUrls) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(apiUrl, {
signal: controller.signal,
redirect: "follow",
headers: {
"User-Agent": WIKI_UA,
"Accept": "application/json,text/plain,*/*",
}
});
if (!response.ok) throw new Error(`Wiki API HTTP ${response.status}`);
const data = await response.json() as { parse?: { text?: { ["*"]?: string } }; error?: { info?: string } };
const html = data?.parse?.text?.["*"];
if (!html) throw new Error(data?.error?.info || "Wiki API returned no HTML");
return html.length > MAX_HTML_SIZE ? html.slice(0, MAX_HTML_SIZE) : html;
} catch (error) {
lastError = error;
} finally {
clearTimeout(timeout);
}
}
const message = lastError instanceof Error ? lastError.message : String(lastError ?? "unknown wiki API error");
throw new Error(message);
}
export async function retryFetch(url: string, options?: { headers?: Record<string, string>; timeoutMs?: number }): Promise<string> {
if (isBlockedExtension(url)) throw new Error(`Blocked extension: ${url}`);
let lastError: unknown = null;
const useWikiFallback = isLikelyWikiUrl(url);
const extraHeaders = options?.headers;
const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
for (let i = 0; i < MAX_RETRIES; i++) {
try {
// If it's a wiki URL, try the API first on the initial attempt
if (useWikiFallback && i === 0) {
try { return await fetchViaWikiApi(url); }
catch (wikiError) { lastError = wikiError; }
}
// Always attempt direct fetch
return await fetchDirect(url, extraHeaders, timeoutMs);
} catch (error) {
lastError = error;
if (i < MAX_RETRIES - 1) {
const baseDelay = 150 * (i + 1);
const jitter = Math.random() * baseDelay;
await sleep(baseDelay + jitter);
}
}
}
// Final fallback: if direct fetch failed and it's a wiki, try API one last time
if (useWikiFallback) {
try { return await fetchViaWikiApi(url); } catch {}
}
const message = lastError instanceof Error ? lastError.message : String(lastError ?? "unknown error");
throw new Error(`Failed fetch: ${message}`);
}