UNPKG

markdown_link_checker_sc

Version:
564 lines (508 loc) 20.7 kB
import readline from "readline"; import { ExternalLinkError, ExternalLinkWarning } from "./errors.js"; import { logFunction } from "./helpers.js"; import { URL } from "url"; import http from "http"; import https from "https"; /** * Strips the fragment (#...) from a URL string so that * https://example.com/page#section-1 and https://example.com/page#section-2 * are treated as the same resource when making HTTP requests. * * Malformed URLs are returned unchanged; they will be caught later in _processQueue. * * @param {string} urlString * @returns {string} */ function stripFragment(urlString) { try { const u = new URL(urlString); u.hash = ""; return u.toString(); } catch { return urlString; } } /** * Performs an HTTP request (HEAD or GET) to a given URL and returns the HTTP * status code, status message, redirect URL (if any), and parsed Retry-After * delay (if the server supplied one). * * @param {string} urlString * @param {'HEAD' | 'GET'} method * @param {number} [timeoutMs=5000] * @returns {Promise<{statusCode: number, statusMessage: string, redirectUrl?: string, retryAfterMs?: number}>} */ async function makeHttpRequest(urlString, method, timeoutMs = 5000) { const url = new URL(urlString); const client = url.protocol === "https:" ? https : http; const options = { method, hostname: url.hostname, port: url.port || (url.protocol === "https:" ? 443 : 80), path: url.pathname + url.search, maxHeaderSize: 65536, // 64 KB (default is 16 KB; some sites send very large headers) headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", Accept: "text/html, application/xhtml+xml;q=0.9, application/vnd.wap.xhtml+xml;q=0.6, */*;q=0.5", "Accept-Language": "en-US,en;q=0.9,es;q=0.8", "Accept-Encoding": "gzip, deflate, br", "Cache-Control": "no-cache", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", }, }; return new Promise((resolve, reject) => { let timeoutId; const req = client.request(options, (res) => { clearTimeout(timeoutId); const { statusCode, statusMessage, headers } = res; let redirectUrl; if (statusCode >= 300 && statusCode < 400 && headers.location) { redirectUrl = new URL(headers.location, urlString).toString(); } // Parse Retry-After header (integer seconds only; HTTP-date format ignored). let retryAfterMs; if (headers["retry-after"]) { const parsed = parseInt(headers["retry-after"], 10); if (!isNaN(parsed)) retryAfterMs = parsed * 1000; } res.resume(); resolve({ statusCode, statusMessage, redirectUrl, retryAfterMs }); }); timeoutId = setTimeout(() => { const err = new Error(`Request timed out after ${timeoutMs}ms for ${urlString} (${method})`); err.code = "ETIMEDOUT"; req.destroy(err); }, timeoutMs); req.on("error", (e) => { clearTimeout(timeoutId); reject(e); }); req.end(); }); } /** * Performs a HEAD request and retries with GET if the server returns 403 * (some servers block HEAD requests). * * @param {string} urlString * @param {number} [timeoutMs=5000] * @returns {Promise<{statusCode: number, statusMessage: string, redirectUrl?: string, retryAfterMs?: number}>} */ async function getHeadRequestStatusCode(urlString, timeoutMs = 5000, _makeRequest = makeHttpRequest) { let headResult; try { headResult = await _makeRequest(urlString, "HEAD", timeoutMs); } catch (err) { // Some servers silently drop HEAD requests, causing a timeout rather than // returning 405. Retry with GET before giving up. if (err.code === "ETIMEDOUT") { return _makeRequest(urlString, "GET", timeoutMs); } throw err; } if (headResult.statusCode === 403 || headResult.statusCode === 404 || headResult.statusCode === 405) { return _makeRequest(urlString, "GET", timeoutMs); } return headResult; } /** * Manages concurrent HTTP requests with: * - A primary pendingQueue for first-attempt URLs. * - A separate retryQueue for 429-rate-limited URLs, drained only after * pendingQueue is empty (so retries never starve first-time requests). * - Per-hostname concurrency limiting (maxPerHostRequests, default 2). * - Global concurrency limiting (maxActiveRequests). * - Exponential backoff for 429s, honouring Retry-After headers. * - Fragment stripping: https://host/page#a and https://host/page#b share * one HTTP request. * - abort() to stop immediately and continue with partial results. */ // Transient server-side codes that warrant one automatic retry before being // classified. Includes standard gateway/overload codes (502–504) that are // often triggered by bot-protection CDNs or momentary load spikes. const TRANSIENT_CODES = new Set([502, 503, 504]); class LinkManager { constructor(headRequestFunction, maxConcurrent = 10) { this.checkedUrls = new Map(); // First-attempt URLs. this.pendingQueue = []; this._pendingSet = new Set(); // O(1) membership test for pendingQueue // 429-rate-limited URLs waiting for their backoff to expire. // Each entry: { url: string, notBefore: number (epoch ms) } // Drained only when pendingQueue is empty. this.retryQueue = []; this._retrySet = new Set(); // O(1) membership test for retryQueue this.activeRequests = new Map(); // url -> Promise this._activeHostCounts = new Map(); // hostname -> active request count this.maxActiveRequests = maxConcurrent; this.maxPerHostRequests = 5; // concurrent requests allowed per hostname this.headRequestFunction = headRequestFunction; this._finishedAddingUrls = false; this._aborted = false; this._retryAttempts = new Map(); this._maxRetries = 3; this._baseRetryDelayMs = 1000; // Transient-code retry: one retry after a short fixed delay. this._transientRetryAttempts = new Map(); this._maxTransientRetries = 1; this._transientRetryDelayMs = 2000; this._completionPromise = new Promise((resolve) => { this._resolveCompletionPromise = resolve; }); } /** * Registers a URL to be checked. Strips the fragment before queuing so * that multiple links to the same page (different anchors) share one request. * * @param {string} urlString * @param {boolean} [isInternalTrigger=false] Set true for redirects/retries. * @returns {{ type: 'resolved'|'active'|'pending'|'error', url: string, ...}} */ checkURL(urlString, isInternalTrigger = false) { urlString = stripFragment(urlString); if (this._finishedAddingUrls && !isInternalTrigger) { console.warn( `Cannot add URL ${urlString}. LinkManager has been signaled to finish.` ); if (this.checkedUrls.has(urlString)) { return { type: "resolved", url: urlString, ...this.checkedUrls.get(urlString) }; } return { type: "error", url: urlString, message: "Manager is finishing." }; } if (this.checkedUrls.has(urlString)) { return { type: "resolved", url: urlString, ...this.checkedUrls.get(urlString) }; } if (this.activeRequests.has(urlString)) { return { type: "active", url: urlString }; } if (this._pendingSet.has(urlString) || this._retrySet.has(urlString)) { return { type: "pending", url: urlString }; } this.pendingQueue.push(urlString); this._pendingSet.add(urlString); this._processQueue(); return { type: "pending", url: urlString }; } /** * Returns the cached result for a URL, stripping its fragment first. * Use this instead of checkedUrls.get(url) so that fragment URLs resolve correctly. * * @param {string} urlString * @returns {{ statusCode?: number, statusMessage?: string, error?: Error, redirectUrl?: string } | undefined} */ getResult(urlString) { return this.checkedUrls.get(stripFragment(urlString)); } /** * Pulls work from pendingQueue (priority) then retryQueue and starts HTTP * requests, respecting global and per-host concurrency limits. * @private */ _processQueue() { if (this._aborted) return; // Two passes: pendingQueue first, retryQueue only when pendingQueue is empty. outer: for (const [queue, set, isRetry] of [ [this.pendingQueue, this._pendingSet, false], [this.retryQueue, this._retrySet, true], ]) { if (isRetry && this.pendingQueue.length > 0) break; for (let i = 0; i < queue.length; i++) { if (this.activeRequests.size >= this.maxActiveRequests) break outer; const entry = queue[i]; const urlToProcess = isRetry ? entry.url : entry; // Retry entries whose backoff hasn't elapsed yet are skipped. // The entry's `ready` flag is set to true by the scheduled setTimeout, // guaranteeing the delay has actually elapsed before we dispatch. if (isRetry && !entry.ready) continue; let hostname; try { hostname = new URL(urlToProcess).hostname; } catch (e) { console.error( `Malformed URL in queue, skipping: ${urlToProcess} - ${e.message}` ); this.checkedUrls.set(urlToProcess, { error: new Error(`Malformed URL: ${e.message}`), }); queue.splice(i, 1); set.delete(urlToProcess); i--; continue; } const hostCount = this._activeHostCounts.get(hostname) ?? 0; if (hostCount >= this.maxPerHostRequests) continue; // Claim this URL. queue.splice(i, 1); set.delete(urlToProcess); i--; this._activeHostCounts.set(hostname, hostCount + 1); const requestPromise = this.headRequestFunction(urlToProcess, 8000) .then((result) => { if (result.statusCode === 429) { const currentRetries = this._retryAttempts.get(urlToProcess) ?? 0; if (currentRetries < this._maxRetries) { const backoffMs = this._baseRetryDelayMs * Math.pow(2, currentRetries); const delay = Math.max(backoffMs, result.retryAfterMs ?? 0); this._retryAttempts.set(urlToProcess, currentRetries + 1); // Place in retryQueue immediately so the completion check sees it. // The entry starts with ready=false; the setTimeout below sets // ready=true, guaranteeing the delay has elapsed before dispatch. const retryEntry = { url: urlToProcess, ready: false }; this.retryQueue.push(retryEntry); this._retrySet.add(urlToProcess); // Wake up _processQueue once the backoff expires. setTimeout(() => { retryEntry.ready = true; this._processQueue(); }, delay); } else { this.checkedUrls.set(urlToProcess, { statusCode: result.statusCode, statusMessage: result.statusMessage, error: new Error("Too many retries for 429 status code"), }); } } else if (TRANSIENT_CODES.has(result.statusCode)) { // Transient server error (502/503/504): retry once after a short delay. const currentRetries = this._transientRetryAttempts.get(urlToProcess) ?? 0; if (currentRetries < this._maxTransientRetries) { this._transientRetryAttempts.set(urlToProcess, currentRetries + 1); const retryEntry = { url: urlToProcess, ready: false }; this.retryQueue.push(retryEntry); this._retrySet.add(urlToProcess); setTimeout(() => { retryEntry.ready = true; this._processQueue(); }, this._transientRetryDelayMs); } else { this.checkedUrls.set(urlToProcess, { statusCode: result.statusCode, statusMessage: result.statusMessage, }); this._transientRetryAttempts.delete(urlToProcess); } } else { this.checkedUrls.set(urlToProcess, { statusCode: result.statusCode, statusMessage: result.statusMessage, redirectUrl: result.redirectUrl, }); this._retryAttempts.delete(urlToProcess); this._transientRetryAttempts.delete(urlToProcess); if (result.redirectUrl) { this.checkURL(result.redirectUrl, true); } } }) .catch((error) => { this.checkedUrls.set(urlToProcess, { error }); this._retryAttempts.delete(urlToProcess); }) .finally(() => { this.activeRequests.delete(urlToProcess); const newCount = (this._activeHostCounts.get(hostname) ?? 1) - 1; if (newCount <= 0) { this._activeHostCounts.delete(hostname); } else { this._activeHostCounts.set(hostname, newCount); } if (!this._aborted) { this._processQueue(); updateConsoleLine( `checked: ${this.checkedUrls.size} active: ${this.activeRequests.size} queued: ${this.pendingQueue.length + this.retryQueue.length}` ); } }); this.activeRequests.set(urlToProcess, requestPromise); } } // Resolve when all queues and in-flight requests are drained. if ( this._finishedAddingUrls && this.pendingQueue.length === 0 && this.retryQueue.length === 0 && this.activeRequests.size === 0 ) { this._resolveCompletionPromise(); } } /** Signals that no more external URLs will be added. */ finish() { this._finishedAddingUrls = true; this._processQueue(); } /** * Stops processing immediately and resolves onComplete() with whatever * results have been collected so far. In-flight requests complete silently * but their results are not used. No new requests are started after abort(). */ abort() { this._aborted = true; this._resolveCompletionPromise(); } /** @returns {Promise<void>} Resolves when all URLs are checked (or after abort()). */ async onComplete() { return this._completionPromise; } getPendingCount() { return this.pendingQueue.length; } getRetryCount() { return this.retryQueue.length; } getActiveCount() { return this.activeRequests.size; } getCheckedCount() { return this.checkedUrls.size; } } function updateConsoleLine(message) { if (process.stdout.isTTY) { process.stdout.write("\r" + message + " "); } } /** * Queues all external links from results into manager, then waits for * completion. If stdin is a TTY, pressing X stops checking early and * continues with partial results; Ctrl+C quits entirely. * * @param {Array} results * @param {LinkManager} manager */ async function checkExternalUrlLinks(results, manager) { logFunction("Function: checkExternalUrlLinks()"); results.forEach((page) => { page.urlLinks.forEach((link) => { manager.checkURL(link.url); }); }); manager.finish(); if (process.stdin.isTTY) { readline.emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); process.stdin.resume(); process.stdout.write( "\n [X] or [Ctrl+C] Stop external link checking and continue with partial results\n" ); const onKeypress = (_str, key) => { if (!key) return; if (key.name === "x" || key.name === "X" || (key.ctrl && key.name === "c")) { process.stderr.write( "\n External link checking stopped. Continuing with partial results...\n" ); manager.abort(); } }; process.stdin.on("keypress", onKeypress); await manager.onComplete(); process.stdin.removeListener("keypress", onKeypress); process.stdin.setRawMode(false); process.stdin.pause(); } else { await manager.onComplete(); } } /** * Main entry point for external link checking. * Accepts an optional LinkManager for testing; creates a default one otherwise. * * @param {Array} results * @param {LinkManager|null} [manager=null] * @returns {Promise<Array>} Array of ExternalLinkError / ExternalLinkWarning instances. */ async function processExternalUrlLinks(results, manager = null) { if (!manager) { manager = new LinkManager(getHeadRequestStatusCode, 10); } logFunction("Function: processExternalUrlLinks()"); await checkExternalUrlLinks(results, manager); logFunction("Function FINISHED AWAITING: processExternalUrlLinks()"); const errors = []; results.forEach((page) => { page.urlLinks.forEach((link) => { // getResult() strips the fragment before looking up, so // https://host/page#section resolves to the cached https://host/page result. const urlResult = manager.getResult(link.url); if (urlResult) { if (urlResult.statusCode === 200) { // Link is good — do nothing. } else if ( urlResult.statusCode === 302 || urlResult.statusCode === 303 || urlResult.statusCode === 307 || urlResult.statusCode === 403 || // Transient server-side codes: retried once automatically; if still failing // after retry they are more likely bot-blocking or load spikes than broken links. urlResult.statusCode === 502 || urlResult.statusCode === 503 || urlResult.statusCode === 504 || // Cloudflare/CDN-specific codes (520–527, 530): indicate CDN or bot-blocking // issues rather than a definitively broken link, so report as warning not error. (urlResult.statusCode >= 520 && urlResult.statusCode <= 527) || urlResult.statusCode === 530 ) { errors.push( new ExternalLinkWarning({ file: link.page, link, statusCode: urlResult.statusCode, statusMessage: urlResult.statusMessage, error: urlResult.error, redirectUrl: urlResult.redirectUrl, }) ); } else { // Connection-level failures (AggregateError from Happy Eyeballs, ECONNREFUSED, // ECONNRESET) mean the link couldn't be verified, not necessarily that it's broken. // Treat these as warnings rather than hard errors. const err = urlResult.error; const isConnectionFailure = err instanceof AggregateError || err?.code === "ECONNREFUSED" || err?.code === "ECONNRESET" || err?.code === "ETIMEDOUT"; if (!urlResult.statusCode && isConnectionFailure) { errors.push( new ExternalLinkWarning({ file: link.page, link, statusCode: urlResult.statusCode, statusMessage: urlResult.statusMessage, error: err, redirectUrl: urlResult.redirectUrl, }) ); } else { errors.push( new ExternalLinkError({ file: link.page, link, statusCode: urlResult.statusCode, statusMessage: urlResult.statusMessage, error: urlResult.error, redirectUrl: urlResult.redirectUrl, }) ); } } } }); }); return errors; } export { processExternalUrlLinks, LinkManager, stripFragment, getHeadRequestStatusCode }; /* Format of a result object on page.urlLinks: { "address": "https://github.com/PX4/PX4-Autopilot/blob/main/src/drivers/gps/gps.cpp", "anchor": "L1023", "params": "", "type": "urlLink", "isImage": false, "isMarkdown": false, "isHTML": false, "isRelative": false, "isReferenceLink": false, "page": "D:\\github\\px4\\PX4-Autopilot\\docs\\en\\uart\\user_configurable_serial_driver.md", "fileRelativeToRoot": "en\\uart\\user_configurable_serial_driver.md", "url": "https://github.com/PX4/PX4-Autopilot/blob/main/src/drivers/gps/gps.cpp#L1023", "text": "gps driver", "title": "", "refName": "", "refMatch": "" } */