UNPKG

markdown_link_checker_sc

Version:
562 lines (497 loc) 23.1 kB
import path from "path"; import { ExternalLinkError, ExternalLinkWarning } from "./errors.js"; // TODO Add error for external links import { logFunction } from "./helpers.js"; import { exit } from "process"; import { URL } from "url"; // Import URL from the 'url' built-in module import http from "http"; // Import http from the 'http' built-in module import https from "https"; // Import https from the 'https' built-in module /** * Performs a HEAD request to a given URL and returns the HTTP status code. * * @param {string} urlString The URL to make the HEAD request to. * @param {number} [timeoutMs=5000] The timeout in milliseconds for the request. Defaults to 5000ms (5 seconds). * @returns {Promise<number>} A Promise that resolves with the HTTP status code, * or rejects with an error if the request fails or times out. */ async function getHeadRequestStatusCodeDeprec(urlString, timeoutMs = 5000) { // We use the 'URL' class to parse the URL string and extract its components. // This is crucial for correctly configuring the HTTP/HTTPS request options. const url = new URL(urlString); // Determine whether to use http or https module based on the protocol. // This makes the function flexible for both HTTP and HTTPS URLs. // We use the imported http and https modules directly. const client = url.protocol === "https:" ? https : http; // Define the options for the request. // 'method: 'HEAD'' is the core of this function, ensuring only headers are fetched. // 'hostname', 'port', and 'path' are extracted from the parsed URL. const options = { method: "HEAD", hostname: url.hostname, port: url.port || (url.protocol === "https:" ? 443 : 80), // Default ports if not specified path: url.pathname + url.search, // Include query parameters 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", }, }; return new Promise((resolve, reject) => { let timeoutId; // Variable to hold the timeout ID const req = client.request(options, (res) => { clearTimeout(timeoutId); // Clear the timeout if response is received const { statusCode, statusMessage, headers } = res; let redirectUrl; // Check for redirect status codes (3xx) if (statusCode >= 300 && statusCode < 400 && headers.location) { // Resolve the redirect URL relative to the original URL if it's a relative path redirectUrl = new URL(headers.location, urlString).toString(); } // Resolve with an object containing statusCode, statusMessage, and optionally redirectUrl resolve({ statusCode, statusMessage, redirectUrl }); //resolve({ statusCode: res.statusCode, statusMessage: res.statusMessage }); res.resume(); // Consume response data to free up memory/connection }); // Set a timeout for the request timeoutId = setTimeout(() => { req.destroy( new Error(`Request timed out after ${timeoutMs}ms for ${urlString}`) ); // Abort the request }, timeoutMs); // Handle any errors that occur during the request (e.g., network issues, or timeout destroying the request). req.on("error", (e) => { clearTimeout(timeoutId); // Clear the timeout on error too //console.error(`Problem with request to ${urlString}: ${e.message}`); reject(e); }); // End the request. For HEAD requests, there's no body to send. req.end(); }); } /** * Performs an HTTP request (HEAD or GET) to a given URL and returns the HTTP status code and other details. * This is a generalized function to handle both HEAD and GET requests. * * @param {string} urlString The URL to make the request to. * @param {'HEAD' | 'GET'} method The HTTP method to use ('HEAD' or 'GET'). * @param {number} [timeoutMs=5000] The timeout in milliseconds for the request. Defaults to 5000ms (5 seconds). * @returns {Promise<{statusCode: number, statusMessage: string, redirectUrl?: string}>} A Promise that resolves with an object * containing the HTTP status code, status message, and optionally a redirect URL, * or rejects with an error if the request fails or times out. */ async function makeHttpRequest(urlString, method, timeoutMs = 5000) { // Parse the URL string to extract its components (hostname, port, path, protocol). const url = new URL(urlString); // Determine whether to use the http or https module based on the URL's protocol. const client = url.protocol === "https:" ? https : http; // Define the options for the HTTP request. const options = { method: method, // Use the specified method ('HEAD' or 'GET') hostname: url.hostname, port: url.port || (url.protocol === "https:" ? 443 : 80), // Default ports for HTTP/HTTPS path: url.pathname + url.search, // Include query parameters from the URL headers: { // Standard User-Agent header to mimic a browser, which can help avoid some blocks. "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 header to indicate preferred content types. Accept: "text/html, application/xhtml+xml;q=0.9, application/vnd.wap.xhtml+xml;q=0.6, */*;q=0.5", // Accept-Language header for language preference. "Accept-Language": "en-US,en;q=0.9,es;q=0.8", }, }; return new Promise((resolve, reject) => { let timeoutId; // Variable to store the timeout ID for clearing it later. // Create the HTTP request. const req = client.request(options, (res) => { clearTimeout(timeoutId); // Clear the timeout as soon as a response is received. const { statusCode, statusMessage, headers } = res; let redirectUrl; // Check for redirect status codes (3xx range). if (statusCode >= 300 && statusCode < 400 && headers.location) { // Resolve the redirect URL relative to the original URL if it's a relative path. redirectUrl = new URL(headers.location, urlString).toString(); } // Consume response data to free up memory/connection resources. // For HEAD requests, there's no body, but for GET, we still need to consume it. res.resume(); // Resolve the promise with the request details. resolve({ statusCode, statusMessage, redirectUrl }); }); // Set a timeout for the request. If the request doesn't complete within this time, it will be aborted. timeoutId = setTimeout(() => { req.destroy( new Error( `Request timed out after ${timeoutMs}ms for ${urlString} (${method})` ) ); // Abort the request and reject the promise. }, timeoutMs); // Handle any errors that occur during the request (e.g., network issues, DNS resolution failures, or timeout). req.on("error", (e) => { clearTimeout(timeoutId); // Clear the timeout if an error occurs. reject(e); // Reject the promise with the error. }); // End the request. For HEAD requests, there's no body to send. For GET, the body is typically empty as well. req.end(); }); } /** * Performs a HEAD request to a given URL and, if it returns a 403 Forbidden, retries with a GET request. * * @param {string} urlString The URL to make the request to. * @param {number} [timeoutMs=5000] The timeout in milliseconds for each request. Defaults to 5000ms. * @returns {Promise<{statusCode: number, statusMessage: string, redirectUrl?: string}>} A Promise that resolves with an object * containing the HTTP status code, status message, and optionally a redirect URL, * or rejects with an error if both requests fail or time out. */ async function getHeadRequestStatusCode(urlString, timeoutMs = 5000) { try { // Attempt the HEAD request first. const headResult = await makeHttpRequest(urlString, "HEAD", timeoutMs); // If the HEAD request returns a 403 Forbidden, retry with a GET request. if (headResult.statusCode === 403) { /* console.log( `HEAD request to ${urlString} returned 403 Forbidden. Retrying with GET...` ); */ // Perform the GET request. const getResult = await makeHttpRequest(urlString, "GET", timeoutMs); return getResult; // Return the result from the GET request. } else { return headResult; // Otherwise, return the result from the HEAD request. } } catch (error) { // If the initial HEAD request fails (e.g., network error, timeout), // or the subsequent GET request fails, propagate the error. throw error; } } /** * Manages concurrent HEAD requests, queuing, and caching results with per-host concurrency limits. */ class LinkManager { /** * @private {Map<string, {statusCode?: number, statusMessage?: string, error?: Error, redirectUrl?: string}>} checkedUrls - Dictionary of URLs that have been resolved, with their results. * @private {string[]} pendingQueue - Queue of URLs waiting to be processed. * @private {Map<string, Promise<any>>} activeRequests - Map of URLs currently being processed and their active Promises. * @private {Set<string>} _activeHostnames - Set of hostnames that currently have an active request. * @private {number} maxActiveRequests - Maximum number of concurrent HEAD requests allowed. * @private {Function} headRequestFunction - The function used to perform HEAD requests (e.g., getHeadRequestStatusCode). * @private {boolean} _finishedAddingUrls - Flag to indicate if no more URLs will be added. * @private {Promise<void>} _completionPromise - A Promise that resolves when all URLs are processed. * @private {Function} _resolveCompletionPromise - Function to resolve _completionPromise. * @private {Map<string, number>} _retryAttempts - Map to track retry attempts for each URL (for 429 errors). * @private {number} _maxRetries - Maximum number of retries for a 429 error. * @private {number} _baseRetryDelayMs - Base delay for exponential backoff. */ constructor(headRequestFunction, maxConcurrent = 10) { this.checkedUrls = new Map(); // Stores resolved results (statusCode or error) this.pendingQueue = []; // URLs waiting to be picked up this.activeRequests = new Map(); // URLs currently being processed (URL -> Promise) this._activeHostnames = new Set(); // Tracks hostnames with active requests this.maxActiveRequests = maxConcurrent; // Global concurrency limit this.headRequestFunction = headRequestFunction; // Function to execute HEAD request this._finishedAddingUrls = false; // Initially, we can still add URLs this._retryAttempts = new Map(); // Tracks how many times a URL has been retried for 429 this._maxRetries = 3; // Max attempts for a 429 error this._baseRetryDelayMs = 1000; // 1 second base delay for exponential backoff // Create a Promise that will resolve when the manager is done processing everything this._completionPromise = new Promise((resolve) => { this._resolveCompletionPromise = resolve; }); } /** * Checks the status of a given URL, managing its lifecycle through pending, active, and resolved states. * * @param {string} urlString The URL to check. * @param {boolean} [isInternalTrigger=false] - Internal flag: true if called due to a redirect or retry. * @returns {{type: 'resolved', url: string, statusCode?: number, statusMessage?: string, error?: Error, redirectUrl?: string} | {type: 'active', url: string} | {type: 'pending', url: string}} * An object indicating the current status of the URL. */ checkURL(urlString, isInternalTrigger = false) { // If manager has been signaled to finish AND it's not an internal trigger (redirect or retry), // then new URLs should not be added by external calls. if (this._finishedAddingUrls && !isInternalTrigger) { console.warn( `Cannot add URL ${urlString}. LinkManager has been signaled to finish.` ); if (this.checkedUrls.has(urlString)) { const result = this.checkedUrls.get(urlString); return { type: "resolved", url: urlString, ...result }; } return { type: "error", url: urlString, message: "Manager is finishing.", }; } if (this.checkedUrls.has(urlString)) { const result = this.checkedUrls.get(urlString); return { type: "resolved", url: urlString, ...result }; } if (this.activeRequests.has(urlString)) { return { type: "active", url: urlString }; } // Check if it's already in pending queue (important for retries to avoid duplicates) if (this.pendingQueue.includes(urlString)) { return { type: "pending", url: urlString }; } this.pendingQueue.push(urlString); this._processQueue(); // Attempt to start processing immediately return { type: "pending", url: urlString }; } /** * Internal method to manage the active and pending queues. * It moves URLs from the pending queue to the active queue up to maxActiveRequests limit, * respecting per-host concurrency. * Also checks if all processing is complete to resolve the completion promise. * @private */ _processQueue() { // Iterate through the pending queue to find suitable URLs for (let i = 0; i < this.pendingQueue.length; i++) { // Stop if global concurrency limit is reached if (this.activeRequests.size >= this.maxActiveRequests) { break; } const urlToProcess = this.pendingQueue[i]; let hostname; try { hostname = new URL(urlToProcess).hostname; } catch (e) { // Handle malformed URLs that might be in the queue console.error( `Malformed URL in pending queue, skipping: ${urlToProcess} - ${e.message}` ); this.checkedUrls.set(urlToProcess, { error: new Error(`Malformed URL: ${e.message}`), }); this.pendingQueue.splice(i, 1); // Remove it i--; // Adjust index due to removal continue; } // Check if this hostname already has an active request if (this._activeHostnames.has(hostname)) { // This host is currently busy, skip this URL for now. // It remains in the pendingQueue and will be re-evaluated in future _processQueue calls. continue; } // If we reach here, we found a suitable URL: // 1. Remove it from the pending queue this.pendingQueue.splice(i, 1); i--; // Decrement index because we removed an element and the next element shifts to current position // 2. Add its hostname to the set of active hostnames this._activeHostnames.add(hostname); // 3. Add the URL to the active requests map // Start the HEAD request. The returned promise now resolves with { statusCode, statusMessage, redirectUrl } const requestPromise = this.headRequestFunction(urlToProcess, 8000) .then((result) => { // Handle 429 Too Many Requests errors with exponential backoff if (result.statusCode === 429) { const currentRetries = this._retryAttempts.get(urlToProcess) || 0; if (currentRetries < this._maxRetries) { const delay = this._baseRetryDelayMs * Math.pow(2, currentRetries); // Exponential backoff this._retryAttempts.set(urlToProcess, currentRetries + 1); /* console.warn( ` ${urlToProcess}: Received 429. Retrying in ${delay}ms (attempt ${ currentRetries + 1 }/${this._maxRetries}).` ); */ // Re-add to pending queue after delay setTimeout(() => this.checkURL(urlToProcess, true), delay); // true for isInternalTrigger } else { // Max retries reached, store as a final error const error = new Error(`Too many retries for 429 status code`); this.checkedUrls.set(urlToProcess, { statusCode: result.statusCode, statusMessage: result.statusMessage, error: error, }); } } else { // Not a 429, store the result normally this.checkedUrls.set(urlToProcess, { statusCode: result.statusCode, statusMessage: result.statusMessage, redirectUrl: result.redirectUrl, // Store the redirect URL if present }); this._retryAttempts.delete(urlToProcess); // Clean up retry attempts if successful // If it's a redirect, add the redirected URL back to be processed if (result.redirectUrl) { /* console.log( ` ${urlToProcess} redirected to: ${result.redirectUrl}` ); */ // IMPORTANT: Pass true for isInternalTrigger to allow adding even after finish() this.checkURL(result.redirectUrl, true); } } }) .catch((error) => { this.checkedUrls.set(urlToProcess, { error: error }); this._retryAttempts.delete(urlToProcess); // Clean up retry attempts on other errors }) .finally(() => { // Request completed (success or failure/timeout/429-max-retries) this.activeRequests.delete(urlToProcess); // Remove URL from active requests map this._activeHostnames.delete(hostname); // Free up the hostname for new requests // Important: Recurse to process more from the pending queue // This handles cases where _processQueue exited because all pending hosts were busy let totalSize = this.checkedUrls.size + this.pendingQueue.length + this.activeRequests.size; if (totalSize % 100 === 0) { // updateConsoleLine( `checked: ${this.checkedUrls.size}, pendingQueue: ${this.pendingQueue.length}, activeRequests: ${this.activeRequests.size}` ); } //console.log( `checked: ${this.checkedUrls.size}, pendingQueue: ${this.pendingQueue.length}, activeRequests: ${this.activeRequests.size}` ); this._processQueue(); }); this.activeRequests.set(urlToProcess, requestPromise); } // Check if everything is done if finish() has been called if ( this._finishedAddingUrls && this.pendingQueue.length === 0 && this.activeRequests.size === 0 ) { this._resolveCompletionPromise(); } } /** * Signals that no more URLs will be added to the LinkManager. * This allows the manager to know when it can consider all processing complete. */ finish() { this._finishedAddingUrls = true; this._processQueue(); } /** * Returns a Promise that resolves when all pending and active requests are complete, * and the `finish()` method has been called. * @returns {Promise<void>} */ async onComplete() { return this._completionPromise; } /** * Returns the current size of the pending queue. * @returns {number} */ getPendingCount() { return this.pendingQueue.length; } /** * Returns the current size of the active requests queue. * @returns {number} */ getActiveCount() { return this.activeRequests.size; } /** * Returns the current size of the checked URLs dictionary. * @returns {number} */ getCheckedCount() { return this.checkedUrls.size; } } function updateConsoleLine(message) { // Update the console line with the current status on same line process.stdout.write("\r" + message + " "); } const linkManager = new LinkManager(getHeadRequestStatusCode, 10); // Add all external links to the link manager. // That async gets the status of any URL passed. // But if doesn't refetch if already fetched or fetching async function checkExternalUrlLinks(results) { logFunction(`Function: checkExternalUrlLinks()`); results.forEach((page, index, array) => { page.urlLinks.forEach((link, index, array) => { // const status = linkManager.checkURL(link.url); linkManager.checkURL(link.url); }); //console.log(urlResultLookup); }); linkManager.finish(); // Signal that no more URLs will be added await linkManager.onComplete(); } async function processExternalUrlLinks(results) { logFunction(`Function: processExternalUrlLinks()`); await checkExternalUrlLinks(results); // Wait for all links to be checked logFunction(`Function FINISHED AWAITING: processExternalUrlLinks()`); // Now we can process the results and create errors for any links that failed. const errors = []; results.forEach((page, index, array) => { //console.log(`debug: PAGE: ${page}`); //console.log(page); //exit(); page.urlLinks.forEach((link, index, array) => { const urlResult = linkManager.checkedUrls.get(link.url); //console.log(urlResult); if (urlResult) { if (urlResult.statusCode === 200) { // Link is good. Do nothing. } else if ( urlResult.statusCode === 302 || urlResult.statusCode === 303 || urlResult.statusCode === 307 ) { const warning = new ExternalLinkWarning({ file: link.page, link: link, statusCode: urlResult.statusCode, statusMessage: urlResult.statusMessage, error: urlResult.error, }); errors.push(warning); } else { // Link is not valid, so we can create an error object. const error = new ExternalLinkError({ file: link.page, link: link, statusCode: urlResult.statusCode, statusMessage: urlResult.statusMessage, error: urlResult.error, }); errors.push(error); //error.output(); } } // Here we should have all our links checked, so we can start processing them. }); //console.log(urlResultLookup); }); return errors; } export { processExternalUrlLinks }; /* Format of a result of an external link. { "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": "" }, */