import { cancelRateLimitedResponse, rateLimitedFetch } from '../utils/async'; import { DEFAULT_TIMEOUT, USER_AGENT } from '../utils/constants'; import { fetchArticleRequest } from './article-request'; export interface RepairOptions { timeout?: number; userAgent?: string; signal?: AbortSignal; allowPrivateNetwork?: boolean; } export type RepairMethod = 'none' | 'http_to_https' | 'www_redirect' | 'archive' | 'custom'; export interface BrokenLinkFixResult { original: string; fixed?: string; method: RepairMethod; success: boolean; } export interface CheckUrlResult { accessible: boolean; statusCode?: number; redirectUrl?: string; error?: string; } interface RepairCandidate { url: string; method: RepairMethod; } /** * Generate the ordered list of repair candidates to try for a broken URL. * Pure: no I/O. Returns candidates in the order callers should probe them. * * If `url` is unparseable by the URL constructor, only the http→https prefix * swap is offered (when applicable) — the www-toggle and archive fallbacks * require a structurally valid URL to be safe to construct. */ export function generateRepairCandidates(url: string): RepairCandidate[] { const candidates: RepairCandidate[] = []; if (url.startsWith('http://')) { candidates.push({ url: url.replace('http://', 'https://'), method: 'http_to_https' }); } let urlObj: URL; try { urlObj = new URL(url); } catch { return candidates; } if (urlObj.hostname.startsWith('www.')) { const noWww = new URL(url); noWww.hostname = noWww.hostname.replace(/^www\./, ''); candidates.push({ url: noWww.toString(), method: 'www_redirect' }); } else { const withWww = new URL(url); withWww.hostname = `www.${withWww.hostname}`; candidates.push({ url: withWww.toString(), method: 'www_redirect' }); } candidates.push({ url: `https://web.archive.org/web/${url}`, method: 'archive' }); return candidates; } function probeHeaders(options: RepairOptions): Record { return { 'User-Agent': options.userAgent || USER_AGENT }; } /** * Check if a URL is accessible. HEAD first, falling back to GET when the * server rejects HEAD with anything other than the definitive 404/410. */ export async function checkUrl(url: string, options: RepairOptions = {}): Promise { return checkUrlWith(url, options, async (requestUrl, init, timeout) => { const response = await rateLimitedFetch( requestUrl, { ...init, redirect: 'follow' }, { timeoutMs: timeout, signal: options.signal }, ); return { response, finalUrl: response.url, redirected: response.redirected }; }); } /** Protected reachability probe for publisher-controlled Article URLs. */ export async function checkArticleUrl(url: string, options: RepairOptions = {}): Promise { return checkUrlWith(url, options, async (requestUrl, init, timeout) => fetchArticleRequest(requestUrl, init, { timeoutMs: timeout, signal: options.signal, allowPrivateNetwork: options.allowPrivateNetwork, }), ); } type ProbeRequest = ( url: string, init: RequestInit, timeout: number, ) => Promise<{ response: Response; finalUrl: string; redirected: boolean }>; async function checkUrlWith(url: string, options: RepairOptions, request: ProbeRequest): Promise { if (options.timeout !== undefined && (!Number.isSafeInteger(options.timeout) || options.timeout < 0)) { throw new RangeError('timeout must be a non-negative safe integer'); } const timeout = options.timeout ?? DEFAULT_TIMEOUT; try { const initial = await request(url, { method: 'HEAD', redirect: 'manual', headers: probeHeaders(options) }, timeout); const response = initial.response; let finalResponse = response; let finalUrl = initial.finalUrl; let redirected = initial.redirected; if (!response.ok && ![404, 410].includes(response.status)) { await cancelRateLimitedResponse(response); const fallback = await request( url, { method: 'GET', redirect: 'manual', headers: probeHeaders(options), }, timeout, ); finalResponse = fallback.response; finalUrl = fallback.finalUrl; redirected = fallback.redirected; // We only read the status here; discard the body so the connection // isn't held open by an unconsumed stream (FETCH-B2). await cancelRateLimitedResponse(finalResponse); } if (finalResponse === response) { await cancelRateLimitedResponse(response); } return { accessible: finalResponse.ok, statusCode: finalResponse.status, redirectUrl: redirected ? finalUrl : undefined, error: finalResponse.ok ? undefined : `HTTP ${finalResponse.status} ${finalResponse.statusText}`.trim(), }; } catch (error) { options.signal?.throwIfAborted(); return { accessible: false, error: error instanceof Error ? error.message : String(error), }; } } /** * Try to repair a broken URL by probing each candidate generated by * `generateRepairCandidates` in order. Returns the first candidate that responds * accessibly; otherwise reports failure with method `none`. */ export async function tryFixBrokenUrl(url: string, options: RepairOptions = {}): Promise { return tryFixBrokenUrlWith(url, options, checkUrl); } /** Protected repair probes for publisher-controlled Article URLs. */ export async function tryFixBrokenArticleUrl(url: string, options: RepairOptions = {}): Promise { return tryFixBrokenUrlWith(url, options, checkArticleUrl); } async function tryFixBrokenUrlWith( url: string, options: RepairOptions, check: (url: string, options: RepairOptions) => Promise, ): Promise { for (const candidate of generateRepairCandidates(url)) { // Archive lookups can be slow; keep them under a short timeout per existing behavior. const probeOpts = candidate.method === 'archive' ? { ...options, timeout: 5000 } : options; try { const result = await check(candidate.url, probeOpts); if (result.accessible) { return { original: url, fixed: candidate.url, method: candidate.method, success: true }; } } catch { options.signal?.throwIfAborted(); // Probe failure for this candidate — try the next. } } return { original: url, method: 'none', success: false }; }