-
-
Save 84em/742967dee62b484a1857829c04ca9e14 to your computer and use it in GitHub Desktop.
WP-CLI command to scan the database for URLs and check their HTTP status.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Plugin Name: 84EM Link Checker | |
| * Plugin URI: https://84em.com/ | |
| * Description: WP-CLI command to scan the database for URLs and check their HTTP status. | |
| * Version: 1.3.2 | |
| * Author: Andrew Miller, 84EM | |
| * Author URI: https://84em.com | |
| * | |
| * Maintained by Andrew Miller, 84EM | |
| * Support: https://84em.com | |
| * | |
| * @package eightyfourem | |
| */ | |
| defined( 'ABSPATH' ) or die; | |
| if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { | |
| return; | |
| } | |
| /** | |
| * URL extraction from various content formats. | |
| */ | |
| class EightyFourEM_Linkcheck_Url_Extractor { | |
| private const URL_PATTERN = '#https?://[^\s"\'<>\[\](){}\\\\,;]+#i'; | |
| private const SKIP_PATTERNS = [ | |
| '#^https?://(localhost|127\.0\.0\.1|0\.0\.0\.0)#i', | |
| '#^https?://10\.\d+\.\d+\.\d+#', | |
| '#^https?://192\.168\.\d+\.\d+#', | |
| '#^data:#i', | |
| '#^javascript:#i', | |
| '#^mailto:#i', | |
| '#^tel:#i', | |
| ]; | |
| /** | |
| * Extract URLs from content. | |
| * | |
| * @param string $content Raw content (plain text, JSON, or serialized). | |
| * @return array Unique normalized URLs. | |
| */ | |
| public function extract( string $content ): array { | |
| $content = html_entity_decode( $content, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); | |
| $content = str_replace( '\\/', '/', $content ); | |
| preg_match_all( self::URL_PATTERN, $content, $matches ); | |
| if ( empty( $matches[0] ) ) { | |
| return []; | |
| } | |
| $urls = []; | |
| foreach ( $matches[0] as $url ) { | |
| $url = $this->clean_url( $url ); | |
| if ( empty( $url ) ) { | |
| continue; | |
| } | |
| if ( $this->should_skip( $url ) ) { | |
| continue; | |
| } | |
| $normalized = $this->normalize_url( $url ); | |
| if ( ! empty( $normalized ) ) { | |
| $urls[ $normalized ] = true; | |
| } | |
| } | |
| return array_keys( $urls ); | |
| } | |
| /** | |
| * Clean trailing punctuation from URL. | |
| */ | |
| private function clean_url( string $url ): string { | |
| $url = rtrim( $url, '.,;:!?\'")}]>' ); | |
| while ( preg_match( '#[\)\]\}>]$#', $url ) && ! preg_match( '#[\(\[\{<]#', $url ) ) { | |
| $url = substr( $url, 0, -1 ); | |
| } | |
| return $url; | |
| } | |
| /** | |
| * Check if URL should be skipped. | |
| */ | |
| private function should_skip( string $url ): bool { | |
| foreach ( self::SKIP_PATTERNS as $pattern ) { | |
| if ( preg_match( $pattern, $url ) ) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * Normalize URL for deduplication. | |
| */ | |
| private function normalize_url( string $url ): string { | |
| $parsed = wp_parse_url( $url ); | |
| if ( empty( $parsed['host'] ) ) { | |
| return ''; | |
| } | |
| $scheme = strtolower( $parsed['scheme'] ?? 'http' ); | |
| $host = strtolower( $parsed['host'] ); | |
| if ( function_exists( 'idn_to_ascii' ) && preg_match( '/[^\x20-\x7f]/', $host ) ) { | |
| $ascii_host = idn_to_ascii( $host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ); | |
| if ( false !== $ascii_host ) { | |
| $host = $ascii_host; | |
| } | |
| } | |
| $port = ''; | |
| if ( ! empty( $parsed['port'] ) ) { | |
| if ( ! ( 'http' === $scheme && 80 === (int) $parsed['port'] ) && | |
| ! ( 'https' === $scheme && 443 === (int) $parsed['port'] ) ) { | |
| $port = ':' . $parsed['port']; | |
| } | |
| } | |
| $path = $parsed['path'] ?? '/'; | |
| if ( empty( $path ) ) { | |
| $path = '/'; | |
| } | |
| $query = ''; | |
| if ( ! empty( $parsed['query'] ) ) { | |
| parse_str( $parsed['query'], $params ); | |
| ksort( $params ); | |
| $query = '?' . http_build_query( $params ); | |
| } | |
| $fragment = ''; | |
| if ( ! empty( $parsed['fragment'] ) ) { | |
| $fragment = '#' . $parsed['fragment']; | |
| } | |
| return $scheme . '://' . $host . $port . $path . $query . $fragment; | |
| } | |
| /** | |
| * Strip credentials from URL for safe logging. | |
| */ | |
| public function strip_credentials( string $url ): string { | |
| return preg_replace( '#(https?://)([^:]+:[^@]+@)#i', '$1', $url ); | |
| } | |
| /** | |
| * Check if URL is internal. | |
| */ | |
| public function is_internal( string $url ): bool { | |
| $site_host = wp_parse_url( home_url(), PHP_URL_HOST ); | |
| $url_host = wp_parse_url( $url, PHP_URL_HOST ); | |
| if ( empty( $url_host ) ) { | |
| return true; | |
| } | |
| return strtolower( $site_host ) === strtolower( $url_host ); | |
| } | |
| } | |
| /** | |
| * HTTP validation with redirect handling. | |
| */ | |
| class EightyFourEM_Linkcheck_Url_Validator { | |
| private const HTTP_STATUS_DESCRIPTIONS = [ | |
| 200 => 'OK', | |
| 201 => 'Created', | |
| 204 => 'No Content', | |
| 301 => 'Moved Permanently', | |
| 302 => 'Found (Redirect)', | |
| 303 => 'See Other', | |
| 304 => 'Not Modified', | |
| 307 => 'Temporary Redirect', | |
| 308 => 'Permanent Redirect', | |
| 400 => 'Bad Request', | |
| 401 => 'Unauthorized', | |
| 403 => 'Forbidden', | |
| 404 => 'Not Found', | |
| 405 => 'Method Not Allowed', | |
| 408 => 'Request Timeout', | |
| 410 => 'Gone', | |
| 429 => 'Too Many Requests', | |
| 451 => 'Unavailable For Legal Reasons', | |
| 500 => 'Internal Server Error', | |
| 501 => 'Not Implemented', | |
| 502 => 'Bad Gateway', | |
| 503 => 'Service Unavailable', | |
| 504 => 'Gateway Timeout', | |
| ]; | |
| private int $timeout; | |
| private int $delay_ms; | |
| private int $max_redirects; | |
| private array $last_request_time = []; | |
| public function __construct( int $timeout = 10, int $delay_ms = 100, int $max_redirects = 5 ) { | |
| $this->timeout = $timeout; | |
| $this->delay_ms = $delay_ms; | |
| $this->max_redirects = min( $max_redirects, 20 ); | |
| } | |
| /** | |
| * Validate a URL and return status details. | |
| * | |
| * @return array{original_url: string, final_url: string, status: string, final_code: int, redirect_chain: array, redirect_count: int, error: string} | |
| */ | |
| public function validate( string $url ): array { | |
| $result = [ | |
| 'original_url' => $url, | |
| 'final_url' => $url, | |
| 'status' => 'unknown', | |
| 'final_code' => 0, | |
| 'redirect_chain' => [], | |
| 'redirect_count' => 0, | |
| 'error' => '', | |
| ]; | |
| if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) { | |
| $result['status'] = 'invalid'; | |
| $result['error'] = 'Malformed URL'; | |
| return $result; | |
| } | |
| $this->rate_limit( $url ); | |
| $current_url = $url; | |
| $visited = []; | |
| $chain = []; | |
| for ( $i = 0; $i <= $this->max_redirects; $i++ ) { | |
| $normalized = strtolower( $current_url ); | |
| if ( isset( $visited[ $normalized ] ) ) { | |
| $result['status'] = 'redirect_loop'; | |
| $result['error'] = 'Redirect loop detected'; | |
| break; | |
| } | |
| $visited[ $normalized ] = true; | |
| $response = $this->make_request( $current_url ); | |
| if ( is_wp_error( $response ) ) { | |
| $result['status'] = 'error'; | |
| $result['error'] = $response->get_error_message(); | |
| break; | |
| } | |
| $code = wp_remote_retrieve_response_code( $response ); | |
| $result['final_code'] = $code; | |
| $result['final_url'] = $current_url; | |
| if ( $code >= 300 && $code < 400 ) { | |
| $location = wp_remote_retrieve_header( $response, 'location' ); | |
| if ( empty( $location ) ) { | |
| $result['status'] = 'redirect_broken'; | |
| $result['error'] = 'Redirect without Location header'; | |
| break; | |
| } | |
| $location = $this->resolve_relative_url( $location, $current_url ); | |
| $chain[] = [ | |
| 'url' => $current_url, | |
| 'code' => $code, | |
| ]; | |
| $current_url = $location; | |
| $result['redirect_count']++; | |
| continue; | |
| } | |
| if ( $code >= 200 && $code < 300 ) { | |
| $result['status'] = 'ok'; | |
| } elseif ( $code >= 400 && $code < 500 ) { | |
| $result['status'] = 'client_error'; | |
| $result['error'] = $this->get_status_description( $code ); | |
| } elseif ( $code >= 500 ) { | |
| $result['status'] = 'server_error'; | |
| $result['error'] = $this->get_status_description( $code ); | |
| } else { | |
| $result['status'] = 'unknown'; | |
| $result['error'] = $this->get_status_description( $code ); | |
| } | |
| break; | |
| } | |
| if ( $result['redirect_count'] > $this->max_redirects ) { | |
| $result['status'] = 'too_many_redirects'; | |
| $result['error'] = 'Exceeded maximum redirects'; | |
| } | |
| $result['redirect_chain'] = $chain; | |
| return $result; | |
| } | |
| /** | |
| * Make HTTP request (HEAD first, then GET with Range header if rejected). | |
| */ | |
| private function make_request( string $url ): array|\WP_Error { | |
| $args = [ | |
| 'timeout' => $this->timeout, | |
| 'redirection' => 0, | |
| 'sslverify' => true, | |
| 'user-agent' => 'Mozilla/5.0 (compatible; 84EM-LinkChecker/1.0; +https://84em.com)', | |
| ]; | |
| $response = wp_remote_head( $url, $args ); | |
| if ( is_wp_error( $response ) ) { | |
| return $response; | |
| } | |
| $code = wp_remote_retrieve_response_code( $response ); | |
| if ( 405 === $code || 501 === $code ) { | |
| $args['headers'] = [ 'Range' => 'bytes=0-1023' ]; | |
| $response = wp_remote_get( $url, $args ); | |
| } | |
| return $response; | |
| } | |
| /** | |
| * Resolve relative URL to absolute. | |
| */ | |
| private function resolve_relative_url( string $location, string $base_url ): string { | |
| if ( preg_match( '#^https?://#i', $location ) ) { | |
| return $location; | |
| } | |
| $parsed = wp_parse_url( $base_url ); | |
| $scheme = $parsed['scheme'] ?? 'http'; | |
| $host = $parsed['host'] ?? ''; | |
| $port = ! empty( $parsed['port'] ) ? ':' . $parsed['port'] : ''; | |
| if ( str_starts_with( $location, '//' ) ) { | |
| return $scheme . ':' . $location; | |
| } | |
| if ( str_starts_with( $location, '/' ) ) { | |
| return $scheme . '://' . $host . $port . $location; | |
| } | |
| $path = $parsed['path'] ?? '/'; | |
| $path = preg_replace( '#/[^/]*$#', '/', $path ); | |
| return $scheme . '://' . $host . $port . $path . $location; | |
| } | |
| /** | |
| * Get human-readable description for HTTP status code. | |
| */ | |
| private function get_status_description( int $code ): string { | |
| if ( isset( self::HTTP_STATUS_DESCRIPTIONS[ $code ] ) ) { | |
| return self::HTTP_STATUS_DESCRIPTIONS[ $code ]; | |
| } | |
| if ( $code >= 200 && $code < 300 ) { | |
| return 'Success'; | |
| } | |
| if ( $code >= 300 && $code < 400 ) { | |
| return 'Redirect'; | |
| } | |
| if ( $code >= 400 && $code < 500 ) { | |
| return 'Client Error'; | |
| } | |
| if ( $code >= 500 ) { | |
| return 'Server Error'; | |
| } | |
| return 'Unknown Status'; | |
| } | |
| /** | |
| * Apply per-domain rate limiting. | |
| */ | |
| private function rate_limit( string $url ): void { | |
| $host = wp_parse_url( $url, PHP_URL_HOST ); | |
| if ( empty( $host ) ) { | |
| return; | |
| } | |
| $now = microtime( true ); | |
| if ( isset( $this->last_request_time[ $host ] ) ) { | |
| $elapsed_ms = ( $now - $this->last_request_time[ $host ] ) * 1000; | |
| if ( $elapsed_ms < $this->delay_ms ) { | |
| usleep( (int) ( ( $this->delay_ms - $elapsed_ms ) * 1000 ) ); | |
| } | |
| } | |
| $this->last_request_time[ $host ] = microtime( true ); | |
| } | |
| } | |
| /** | |
| * Parallel HTTP validation using curl_multi. | |
| */ | |
| class EightyFourEM_Linkcheck_Parallel_Validator { | |
| private const HTTP_STATUS_DESCRIPTIONS = [ | |
| 200 => 'OK', | |
| 201 => 'Created', | |
| 204 => 'No Content', | |
| 301 => 'Moved Permanently', | |
| 302 => 'Found (Redirect)', | |
| 303 => 'See Other', | |
| 304 => 'Not Modified', | |
| 307 => 'Temporary Redirect', | |
| 308 => 'Permanent Redirect', | |
| 400 => 'Bad Request', | |
| 401 => 'Unauthorized', | |
| 403 => 'Forbidden', | |
| 404 => 'Not Found', | |
| 405 => 'Method Not Allowed', | |
| 408 => 'Request Timeout', | |
| 410 => 'Gone', | |
| 429 => 'Too Many Requests', | |
| 451 => 'Unavailable For Legal Reasons', | |
| 500 => 'Internal Server Error', | |
| 501 => 'Not Implemented', | |
| 502 => 'Bad Gateway', | |
| 503 => 'Service Unavailable', | |
| 504 => 'Gateway Timeout', | |
| ]; | |
| private int $timeout; | |
| private int $delay_ms; | |
| private int $max_redirects; | |
| private int $concurrency; | |
| private array $last_request_time = []; | |
| public function __construct( int $timeout = 10, int $delay_ms = 100, int $max_redirects = 5, int $concurrency = 10 ) { | |
| $this->timeout = $timeout; | |
| $this->delay_ms = $delay_ms; | |
| $this->max_redirects = min( $max_redirects, 20 ); | |
| $this->concurrency = min( max( $concurrency, 1 ), 20 ); | |
| } | |
| /** | |
| * Validate multiple URLs in parallel. | |
| * | |
| * @param array $urls Array of URLs to validate. | |
| * @param callable $on_complete Callback called after each URL completes: fn(array $result). | |
| * @return array Array of validation results. | |
| */ | |
| public function validate_batch( array $urls, ?callable $on_complete = null ): array { | |
| $results = []; | |
| $queue = array_values( $urls ); | |
| $queue_index = 0; | |
| $total = count( $queue ); | |
| $mh = curl_multi_init(); | |
| $active = []; | |
| $pending = []; | |
| while ( $queue_index < $total || count( $active ) > 0 ) { | |
| while ( count( $active ) < $this->concurrency && $queue_index < $total ) { | |
| $url = $queue[ $queue_index ]; | |
| $queue_index++; | |
| if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) { | |
| $result = [ | |
| 'original_url' => $url, | |
| 'final_url' => $url, | |
| 'status' => 'invalid', | |
| 'final_code' => 0, | |
| 'redirect_chain' => [], | |
| 'redirect_count' => 0, | |
| 'error' => 'Malformed URL', | |
| ]; | |
| $results[] = $result; | |
| if ( $on_complete ) { | |
| $on_complete( $result ); | |
| } | |
| continue; | |
| } | |
| $this->rate_limit( $url ); | |
| $ch = $this->create_curl_handle( $url, true ); | |
| curl_multi_add_handle( $mh, $ch ); | |
| $id = (int) $ch; | |
| $active[ $id ] = $ch; | |
| $pending[ $id ] = [ | |
| 'original_url' => $url, | |
| 'current_url' => $url, | |
| 'visited' => [ strtolower( $url ) => true ], | |
| 'redirect_chain' => [], | |
| 'redirect_count' => 0, | |
| 'is_head' => true, | |
| ]; | |
| } | |
| if ( empty( $active ) ) { | |
| break; | |
| } | |
| curl_multi_exec( $mh, $running ); | |
| if ( $running > 0 ) { | |
| curl_multi_select( $mh, 0.1 ); | |
| } | |
| while ( $info = curl_multi_info_read( $mh ) ) { | |
| if ( CURLMSG_DONE !== $info['msg'] ) { | |
| continue; | |
| } | |
| $ch = $info['handle']; | |
| $id = (int) $ch; | |
| if ( ! isset( $pending[ $id ] ) ) { | |
| curl_multi_remove_handle( $mh, $ch ); | |
| curl_close( $ch ); | |
| unset( $active[ $id ] ); | |
| continue; | |
| } | |
| $state = $pending[ $id ]; | |
| $code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); | |
| $error = curl_error( $ch ); | |
| curl_multi_remove_handle( $mh, $ch ); | |
| curl_close( $ch ); | |
| unset( $active[ $id ] ); | |
| unset( $pending[ $id ] ); | |
| if ( ! empty( $error ) ) { | |
| $result = [ | |
| 'original_url' => $state['original_url'], | |
| 'final_url' => $state['current_url'], | |
| 'status' => 'error', | |
| 'final_code' => 0, | |
| 'redirect_chain' => $state['redirect_chain'], | |
| 'redirect_count' => $state['redirect_count'], | |
| 'error' => $error, | |
| ]; | |
| $results[] = $result; | |
| if ( $on_complete ) { | |
| $on_complete( $result ); | |
| } | |
| continue; | |
| } | |
| if ( ( 405 === $code || 501 === $code ) && $state['is_head'] ) { | |
| $this->rate_limit( $state['current_url'] ); | |
| $new_ch = $this->create_curl_handle( $state['current_url'], false ); | |
| curl_multi_add_handle( $mh, $new_ch ); | |
| $new_id = (int) $new_ch; | |
| $active[ $new_id ] = $new_ch; | |
| $pending[ $new_id ] = $state; | |
| $pending[ $new_id ]['is_head'] = false; | |
| continue; | |
| } | |
| if ( $code >= 300 && $code < 400 ) { | |
| $location = $this->get_redirect_location( $state['current_url'] ); | |
| if ( empty( $location ) ) { | |
| $result = [ | |
| 'original_url' => $state['original_url'], | |
| 'final_url' => $state['current_url'], | |
| 'status' => 'redirect_broken', | |
| 'final_code' => $code, | |
| 'redirect_chain' => $state['redirect_chain'], | |
| 'redirect_count' => $state['redirect_count'], | |
| 'error' => 'Redirect without Location header', | |
| ]; | |
| $results[] = $result; | |
| if ( $on_complete ) { | |
| $on_complete( $result ); | |
| } | |
| continue; | |
| } | |
| $location = $this->resolve_relative_url( $location, $state['current_url'] ); | |
| $normalized = strtolower( $location ); | |
| if ( isset( $state['visited'][ $normalized ] ) ) { | |
| $result = [ | |
| 'original_url' => $state['original_url'], | |
| 'final_url' => $state['current_url'], | |
| 'status' => 'redirect_loop', | |
| 'final_code' => $code, | |
| 'redirect_chain' => $state['redirect_chain'], | |
| 'redirect_count' => $state['redirect_count'], | |
| 'error' => 'Redirect loop detected', | |
| ]; | |
| $results[] = $result; | |
| if ( $on_complete ) { | |
| $on_complete( $result ); | |
| } | |
| continue; | |
| } | |
| if ( $state['redirect_count'] >= $this->max_redirects ) { | |
| $result = [ | |
| 'original_url' => $state['original_url'], | |
| 'final_url' => $state['current_url'], | |
| 'status' => 'too_many_redirects', | |
| 'final_code' => $code, | |
| 'redirect_chain' => $state['redirect_chain'], | |
| 'redirect_count' => $state['redirect_count'], | |
| 'error' => 'Exceeded maximum redirects', | |
| ]; | |
| $results[] = $result; | |
| if ( $on_complete ) { | |
| $on_complete( $result ); | |
| } | |
| continue; | |
| } | |
| $state['redirect_chain'][] = [ | |
| 'url' => $state['current_url'], | |
| 'code' => $code, | |
| ]; | |
| $state['redirect_count']++; | |
| $state['visited'][ $normalized ] = true; | |
| $state['current_url'] = $location; | |
| $state['is_head'] = true; | |
| $this->rate_limit( $location ); | |
| $new_ch = $this->create_curl_handle( $location, true ); | |
| curl_multi_add_handle( $mh, $new_ch ); | |
| $new_id = (int) $new_ch; | |
| $active[ $new_id ] = $new_ch; | |
| $pending[ $new_id ] = $state; | |
| continue; | |
| } | |
| $status_str = 'unknown'; | |
| $error_str = ''; | |
| if ( $code >= 200 && $code < 300 ) { | |
| $status_str = 'ok'; | |
| } elseif ( $code >= 400 && $code < 500 ) { | |
| $status_str = 'client_error'; | |
| $error_str = $this->get_status_description( $code ); | |
| } elseif ( $code >= 500 ) { | |
| $status_str = 'server_error'; | |
| $error_str = $this->get_status_description( $code ); | |
| } else { | |
| $error_str = $this->get_status_description( $code ); | |
| } | |
| $result = [ | |
| 'original_url' => $state['original_url'], | |
| 'final_url' => $state['current_url'], | |
| 'status' => $status_str, | |
| 'final_code' => $code, | |
| 'redirect_chain' => $state['redirect_chain'], | |
| 'redirect_count' => $state['redirect_count'], | |
| 'error' => $error_str, | |
| ]; | |
| $results[] = $result; | |
| if ( $on_complete ) { | |
| $on_complete( $result ); | |
| } | |
| } | |
| } | |
| curl_multi_close( $mh ); | |
| return $results; | |
| } | |
| /** | |
| * Create a curl handle for a URL. | |
| */ | |
| private function create_curl_handle( string $url, bool $head_request ) { | |
| $ch = curl_init(); | |
| curl_setopt_array( $ch, [ | |
| CURLOPT_URL => $url, | |
| CURLOPT_RETURNTRANSFER => true, | |
| CURLOPT_HEADER => true, | |
| CURLOPT_NOBODY => $head_request, | |
| CURLOPT_TIMEOUT => $this->timeout, | |
| CURLOPT_CONNECTTIMEOUT => $this->timeout, | |
| CURLOPT_FOLLOWLOCATION => false, | |
| CURLOPT_SSL_VERIFYPEER => true, | |
| CURLOPT_SSL_VERIFYHOST => 2, | |
| CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; 84EM-LinkChecker/1.0; +https://84em.com)', | |
| CURLOPT_ENCODING => '', | |
| ] ); | |
| if ( ! $head_request ) { | |
| curl_setopt( $ch, CURLOPT_HTTPHEADER, [ 'Range: bytes=0-1023' ] ); | |
| } | |
| return $ch; | |
| } | |
| /** | |
| * Get redirect location from a URL using a quick HEAD request. | |
| */ | |
| private function get_redirect_location( string $url ): string { | |
| $ch = curl_init(); | |
| curl_setopt_array( $ch, [ | |
| CURLOPT_URL => $url, | |
| CURLOPT_RETURNTRANSFER => true, | |
| CURLOPT_HEADER => true, | |
| CURLOPT_NOBODY => true, | |
| CURLOPT_TIMEOUT => $this->timeout, | |
| CURLOPT_FOLLOWLOCATION => false, | |
| CURLOPT_SSL_VERIFYPEER => true, | |
| CURLOPT_SSL_VERIFYHOST => 2, | |
| CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; 84EM-LinkChecker/1.0; +https://84em.com)', | |
| ] ); | |
| $response = curl_exec( $ch ); | |
| curl_close( $ch ); | |
| if ( false === $response ) { | |
| return ''; | |
| } | |
| if ( preg_match( '/^Location:\s*(.+)$/mi', $response, $matches ) ) { | |
| return trim( $matches[1] ); | |
| } | |
| return ''; | |
| } | |
| /** | |
| * Resolve relative URL to absolute. | |
| */ | |
| private function resolve_relative_url( string $location, string $base_url ): string { | |
| if ( preg_match( '#^https?://#i', $location ) ) { | |
| return $location; | |
| } | |
| $parsed = wp_parse_url( $base_url ); | |
| $scheme = $parsed['scheme'] ?? 'http'; | |
| $host = $parsed['host'] ?? ''; | |
| $port = ! empty( $parsed['port'] ) ? ':' . $parsed['port'] : ''; | |
| if ( str_starts_with( $location, '//' ) ) { | |
| return $scheme . ':' . $location; | |
| } | |
| if ( str_starts_with( $location, '/' ) ) { | |
| return $scheme . '://' . $host . $port . $location; | |
| } | |
| $path = $parsed['path'] ?? '/'; | |
| $path = preg_replace( '#/[^/]*$#', '/', $path ); | |
| return $scheme . '://' . $host . $port . $path . $location; | |
| } | |
| /** | |
| * Get human-readable description for HTTP status code. | |
| */ | |
| private function get_status_description( int $code ): string { | |
| if ( isset( self::HTTP_STATUS_DESCRIPTIONS[ $code ] ) ) { | |
| return self::HTTP_STATUS_DESCRIPTIONS[ $code ]; | |
| } | |
| if ( $code >= 200 && $code < 300 ) { | |
| return 'Success'; | |
| } | |
| if ( $code >= 300 && $code < 400 ) { | |
| return 'Redirect'; | |
| } | |
| if ( $code >= 400 && $code < 500 ) { | |
| return 'Client Error'; | |
| } | |
| if ( $code >= 500 ) { | |
| return 'Server Error'; | |
| } | |
| return 'Unknown Status'; | |
| } | |
| /** | |
| * Apply per-domain rate limiting. | |
| */ | |
| private function rate_limit( string $url ): void { | |
| $host = wp_parse_url( $url, PHP_URL_HOST ); | |
| if ( empty( $host ) ) { | |
| return; | |
| } | |
| $now = microtime( true ); | |
| if ( isset( $this->last_request_time[ $host ] ) ) { | |
| $elapsed_ms = ( $now - $this->last_request_time[ $host ] ) * 1000; | |
| if ( $elapsed_ms < $this->delay_ms ) { | |
| usleep( (int) ( ( $this->delay_ms - $elapsed_ms ) * 1000 ) ); | |
| } | |
| } | |
| $this->last_request_time[ $host ] = microtime( true ); | |
| } | |
| } | |
| /** | |
| * Output formatting for results. | |
| */ | |
| class EightyFourEM_Linkcheck_Reporter { | |
| /** | |
| * Write content to file using WordPress filesystem API. | |
| */ | |
| private function write_file( string $file_path, string $content ): bool { | |
| global $wp_filesystem; | |
| if ( ! function_exists( 'WP_Filesystem' ) ) { | |
| require_once ABSPATH . 'wp-admin/includes/file.php'; | |
| } | |
| WP_Filesystem(); | |
| if ( ! $wp_filesystem ) { | |
| WP_CLI::error( 'Could not initialize WordPress filesystem.' ); | |
| return false; | |
| } | |
| $dir = dirname( $file_path ); | |
| if ( ! $wp_filesystem->is_dir( $dir ) ) { | |
| wp_mkdir_p( $dir ); | |
| } | |
| return $wp_filesystem->put_contents( $file_path, $content, FS_CHMOD_FILE ); | |
| } | |
| /** | |
| * Format results as table. | |
| */ | |
| public function format_table( array $results, array $locations ): void { | |
| $rows = []; | |
| foreach ( $results as $result ) { | |
| $url = $result['original_url']; | |
| $locs = $locations[ $url ] ?? []; | |
| $unique_posts = count( array_unique( array_column( $locs, 'post_id' ) ) ); | |
| $rows[] = [ | |
| 'status' => $result['final_code'] ?: $result['status'], | |
| 'url' => $url, | |
| 'redirects' => $result['redirect_count'], | |
| 'error' => $result['error'], | |
| 'posts' => $unique_posts, | |
| ]; | |
| } | |
| WP_CLI\Utils\format_items( 'table', $rows, [ 'status', 'url', 'redirects', 'error', 'posts' ] ); | |
| } | |
| /** | |
| * Format results as CSV. | |
| */ | |
| public function format_csv( array $results, array $locations, ?string $output_file = null ): void { | |
| $rows = []; | |
| $rows[] = [ 'status', 'url', 'redirects', 'error', 'post_id', 'post_url' ]; | |
| foreach ( $results as $result ) { | |
| $url = $result['original_url']; | |
| $locs = $locations[ $url ] ?? []; | |
| $seen = []; | |
| foreach ( $locs as $loc ) { | |
| $post_id = $loc['post_id']; | |
| if ( isset( $seen[ $post_id ] ) ) { | |
| continue; | |
| } | |
| $seen[ $post_id ] = true; | |
| $rows[] = [ | |
| $result['final_code'] ?: $result['status'], | |
| $url, | |
| $result['redirect_count'], | |
| $result['error'], | |
| $post_id, | |
| get_permalink( $post_id ), | |
| ]; | |
| } | |
| } | |
| $output = ''; | |
| foreach ( $rows as $row ) { | |
| $output .= $this->csv_row( $row ) . "\n"; | |
| } | |
| if ( $output_file ) { | |
| $this->write_file( $output_file, $output ); | |
| WP_CLI::success( sprintf( 'CSV saved to %s', $output_file ) ); | |
| } else { | |
| echo $output; | |
| } | |
| } | |
| /** | |
| * Format a row as CSV. | |
| */ | |
| private function csv_row( array $fields ): string { | |
| $escaped = array_map( function ( $field ) { | |
| $field = (string) $field; | |
| if ( str_contains( $field, '"' ) || str_contains( $field, ',' ) || str_contains( $field, "\n" ) ) { | |
| return '"' . str_replace( '"', '""', $field ) . '"'; | |
| } | |
| return $field; | |
| }, $fields ); | |
| return implode( ',', $escaped ); | |
| } | |
| /** | |
| * Format results as JSON. | |
| */ | |
| public function format_json( array $results, array $locations, array $scan_info, ?string $output_file = null ): void { | |
| $summary = [ | |
| 'ok' => 0, | |
| 'client_error' => 0, | |
| 'server_error' => 0, | |
| 'redirect' => 0, | |
| 'error' => 0, | |
| 'invalid' => 0, | |
| ]; | |
| $formatted_results = []; | |
| foreach ( $results as $result ) { | |
| $url = $result['original_url']; | |
| if ( 'ok' === $result['status'] ) { | |
| $summary['ok']++; | |
| } elseif ( 'client_error' === $result['status'] ) { | |
| $summary['client_error']++; | |
| } elseif ( 'server_error' === $result['status'] ) { | |
| $summary['server_error']++; | |
| } elseif ( $result['redirect_count'] > 0 ) { | |
| $summary['redirect']++; | |
| } elseif ( 'invalid' === $result['status'] ) { | |
| $summary['invalid']++; | |
| } else { | |
| $summary['error']++; | |
| } | |
| $locs = $locations[ $url ] ?? []; | |
| $unique_posts = []; | |
| foreach ( $locs as $loc ) { | |
| $post_id = $loc['post_id']; | |
| if ( ! isset( $unique_posts[ $post_id ] ) ) { | |
| $unique_posts[ $post_id ] = [ | |
| 'post_id' => $post_id, | |
| 'post_url' => get_permalink( $post_id ), | |
| ]; | |
| } | |
| } | |
| $formatted_results[] = [ | |
| 'url' => $url, | |
| 'status_code' => $result['final_code'], | |
| 'status' => $result['status'], | |
| 'redirect_count' => $result['redirect_count'], | |
| 'redirect_chain' => $result['redirect_chain'], | |
| 'error' => $result['error'], | |
| 'locations' => array_values( $unique_posts ), | |
| ]; | |
| } | |
| $output = [ | |
| 'scan_info' => $scan_info, | |
| 'totals' => [ | |
| 'urls_scanned' => count( $results ), | |
| 'problems' => $summary['client_error'] + $summary['server_error'] + $summary['error'] + $summary['invalid'], | |
| ], | |
| 'summary' => $summary, | |
| 'results' => $formatted_results, | |
| ]; | |
| $json = wp_json_encode( $output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); | |
| if ( $output_file ) { | |
| $this->write_file( $output_file, $json ); | |
| WP_CLI::success( sprintf( 'JSON saved to %s', $output_file ) ); | |
| } else { | |
| echo $json . "\n"; | |
| } | |
| } | |
| /** | |
| * Format results as HTML. | |
| */ | |
| public function format_html( array $results, array $locations, array $scan_info, string $output_file ): void { | |
| $problem_count = 0; | |
| $ok_count = 0; | |
| foreach ( $results as $result ) { | |
| if ( 'ok' === $result['status'] ) { | |
| $ok_count++; | |
| } else { | |
| $problem_count++; | |
| } | |
| } | |
| $rows_html = ''; | |
| foreach ( $results as $result ) { | |
| $url = htmlspecialchars( $result['original_url'], ENT_QUOTES, 'UTF-8' ); | |
| $status = $result['final_code'] ?: $result['status']; | |
| $error = htmlspecialchars( $result['error'], ENT_QUOTES, 'UTF-8' ); | |
| $locs = $locations[ $result['original_url'] ] ?? []; | |
| $status_class = 'status-ok'; | |
| if ( $result['final_code'] >= 400 || in_array( $result['status'], [ 'error', 'invalid', 'redirect_loop', 'too_many_redirects' ], true ) ) { | |
| $status_class = 'status-error'; | |
| } elseif ( $result['final_code'] >= 300 || $result['redirect_count'] > 0 ) { | |
| $status_class = 'status-redirect'; | |
| } | |
| $location_html = ''; | |
| $seen_posts = []; | |
| foreach ( $locs as $loc ) { | |
| $post_id = $loc['post_id']; | |
| if ( empty( $post_id ) || isset( $seen_posts[ $post_id ] ) ) { | |
| continue; | |
| } | |
| $seen_posts[ $post_id ] = true; | |
| if ( count( $seen_posts ) > 5 ) { | |
| $remaining = count( array_unique( array_column( $locs, 'post_id' ) ) ) - 5; | |
| $location_html .= "... and {$remaining} more"; | |
| break; | |
| } | |
| $post_url = get_permalink( $post_id ); | |
| if ( ! $post_url ) { | |
| continue; | |
| } | |
| $safe_url = htmlspecialchars( $post_url, ENT_QUOTES, 'UTF-8' ); | |
| $location_html .= "<a href=\"{$safe_url}\" target=\"_blank\">{$safe_url}</a> (ID: {$post_id})<br>"; | |
| } | |
| if ( empty( $location_html ) ) { | |
| $location_html = '<span style="color:#999">Unknown</span>'; | |
| } | |
| $rows_html .= "<tr> | |
| <td class=\"fixed-cell\"><input type=\"checkbox\" class=\"fixed-checkbox\" data-url=\"{$url}\"></td> | |
| <td><span class=\"badge {$status_class}\">{$status}</span></td> | |
| <td><a href=\"{$url}\" target=\"_blank\">{$url}</a></td> | |
| <td>{$error}</td> | |
| <td>{$location_html}</td> | |
| </tr>"; | |
| } | |
| $raw_timestamp = $scan_info['timestamp'] ?? gmdate( 'Y-m-d H:i:s' ); | |
| $scan_time = htmlspecialchars( gmdate( 'F j, Y \a\t g:i A', strtotime( $raw_timestamp ) ), ENT_QUOTES, 'UTF-8' ); | |
| $site_url = htmlspecialchars( home_url(), ENT_QUOTES, 'UTF-8' ); | |
| $total_urls = count( $results ); | |
| $html = $this->get_html_template( $rows_html, $scan_time, $site_url, $total_urls, $ok_count, $problem_count ); | |
| $this->write_file( $output_file, $html ); | |
| WP_CLI::success( sprintf( 'HTML report saved to %s', $output_file ) ); | |
| } | |
| /** | |
| * Get HTML template for report. | |
| */ | |
| private function get_html_template( string $rows_html, string $scan_time, string $site_url, int $total_urls, int $ok_count, int $problem_count ): string { | |
| return '<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Link Check Report - ' . $scan_time . '</title> | |
| <style> | |
| * { box-sizing: border-box; } | |
| body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 20px; background: #f5f5f5; font-size: 14px; } | |
| .container { margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } | |
| h1 { color: #333; margin-bottom: 10px; } | |
| .summary { display: flex; gap: 20px; margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 4px; } | |
| .summary-item { text-align: center; } | |
| .summary-item .number { font-size: 2em; font-weight: bold; } | |
| .summary-item .label { color: #666; font-size: 0.9em; } | |
| .ok { color: #28a745; } | |
| .error { color: #dc3545; } | |
| .table-wrapper { overflow-x: auto; margin-top: 20px; } | |
| table { border-collapse: collapse; } | |
| th, td { padding: 5px 10px; text-align: left; border-bottom: 1px solid #dee2e6; vertical-align: top; white-space: nowrap; } | |
| th { background: #f8f9fa; font-weight: 600; cursor: pointer; } | |
| th:hover { background: #e9ecef; } | |
| tr:hover { background: #f8f9fa; } | |
| td a { color: #007bff; text-decoration: none; font-family: monospace; font-size: 12px; } | |
| td a:hover { text-decoration: underline; } | |
| .badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 500; text-align: center; min-width: 45px; } | |
| .status-ok { background: #d4edda; color: #155724; } | |
| .status-error { background: #f8d7da; color: #721c24; } | |
| .status-redirect { background: #fff3cd; color: #856404; } | |
| .meta { color: #666; font-size: 0.9em; margin-bottom: 20px; } | |
| .actions { margin-bottom: 20px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } | |
| .btn { display: inline-block; padding: 8px 16px; background: #007bff; color: white; text-decoration: none; border-radius: 4px; cursor: pointer; border: none; font-size: 14px; } | |
| .btn:hover { background: #0056b3; } | |
| .btn-secondary { background: #6c757d; } | |
| .btn-secondary:hover { background: #545b62; } | |
| .search-box { padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; font-size: 14px; min-width: 250px; } | |
| .fixed-cell { text-align: center; } | |
| .fixed-checkbox { appearance: none; width: 20px; height: 20px; border: 2px solid #dee2e6; border-radius: 4px; cursor: pointer; position: relative; } | |
| .fixed-checkbox:checked { background: #28a745; border-color: #28a745; } | |
| .fixed-checkbox:checked::after { content: ""; position: absolute; left: 6px; top: 2px; width: 5px; height: 10px; border: solid white; border-width: 0 2px 2px 0; transform: rotate(45deg); } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>84EM Link Check Report</h1> | |
| <p class="meta">Site: <a href="' . $site_url . '">' . $site_url . '</a></p> | |
| <p class="meta">Scanned: ' . $scan_time . '</p> | |
| <div class="summary"> | |
| <div class="summary-item"><div class="number">' . $total_urls . '</div><div class="label">Total URLs</div></div> | |
| <div class="summary-item"><div class="number ok">' . $ok_count . '</div><div class="label">OK</div></div> | |
| <div class="summary-item"><div class="number error">' . $problem_count . '</div><div class="label">Problems</div></div> | |
| </div> | |
| <div class="actions"> | |
| <input type="text" id="searchFilter" class="search-box" placeholder="Filter results..." onkeyup="filterTable()"> | |
| <button class="btn" onclick="saveAllAsCSV()">Save All as CSV</button> | |
| <button class="btn btn-secondary" onclick="window.print()">Print</button> | |
| </div> | |
| <div class="table-wrapper"> | |
| <table id="results"> | |
| <thead><tr><th style="width:60px">Fixed</th><th onclick="sortTable(1)">Status</th><th onclick="sortTable(2)">URL</th><th onclick="sortTable(3)">Error</th><th onclick="sortTable(4)">Locations</th></tr></thead> | |
| <tbody>' . $rows_html . '</tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <script> | |
| const STORAGE_KEY = "84em-linkcheck-fixed"; | |
| function getFixedUrls() { try { return new Set(JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]")); } catch (e) { return new Set(); } } | |
| function saveFixedUrl(url, isFixed) { const fixed = getFixedUrls(); if (isFixed) { fixed.add(url); } else { fixed.delete(url); } localStorage.setItem(STORAGE_KEY, JSON.stringify([...fixed])); } | |
| function initCheckboxes() { const fixed = getFixedUrls(); document.querySelectorAll(".fixed-checkbox").forEach(cb => { cb.checked = fixed.has(cb.dataset.url); cb.addEventListener("change", function() { saveFixedUrl(this.dataset.url, this.checked); }); }); } | |
| document.addEventListener("DOMContentLoaded", initCheckboxes); | |
| function filterTable() { const filter = document.getElementById("searchFilter").value.toLowerCase(); const rows = document.getElementById("results").tBodies[0].rows; for (let i = 0; i < rows.length; i++) { let match = false; for (let j = 0; j < rows[i].cells.length; j++) { if (rows[i].cells[j].textContent.toLowerCase().indexOf(filter) > -1) { match = true; break; } } rows[i].style.display = match ? "" : "none"; } } | |
| function sortTable(n) { const table = document.getElementById("results"); const tbody = table.tBodies[0]; const rows = Array.from(tbody.rows); const dir = table.dataset.sortDir === "asc" ? "desc" : "asc"; table.dataset.sortDir = dir; rows.sort((a, b) => { const aVal = a.cells[n].textContent.trim(); const bVal = b.cells[n].textContent.trim(); return dir === "asc" ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); }); rows.forEach(row => tbody.appendChild(row)); } | |
| function saveAllAsCSV() { const rows = document.getElementById("results").querySelectorAll("tr"); let csv = []; rows.forEach((row, i) => { if (i === 0) { csv.push("Fixed,Status,URL,Error,Locations"); return; } const cols = row.querySelectorAll("td"); const cb = row.querySelector(".fixed-checkbox"); csv.push([cb && cb.checked ? "X" : "", ...Array.from(cols).slice(1).map(c => "\\"" + c.textContent.trim().replace(/"/g, "\\"\\"") + "\\"")].join(",")); }); const blob = new Blob([csv.join("\\n")], {type: "text/csv"}); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "linkcheck-" + new Date().toISOString().slice(0,10) + ".csv"; link.click(); } | |
| </script> | |
| </body> | |
| </html>'; | |
| } | |
| } | |
| /** | |
| * Slack webhook integration. | |
| */ | |
| class EightyFourEM_Linkcheck_Slack_Notifier { | |
| private string $webhook_url; | |
| public function __construct( string $webhook_url ) { | |
| $this->webhook_url = $webhook_url; | |
| } | |
| /** | |
| * Send notification to Slack. | |
| */ | |
| public function notify( array $results, array $scan_info, ?string $report_url = null, ?string $duration = null ): bool { | |
| $problems = array_filter( $results, fn( $r ) => 'ok' !== $r['status'] ); | |
| $problem_count = count( $problems ); | |
| $total_count = count( $results ); | |
| $ok_count = $total_count - $problem_count; | |
| $color = $problem_count > 0 ? '#dc3545' : '#28a745'; | |
| $icon = $problem_count > 0 ? ':warning:' : ':white_check_mark:'; | |
| $blocks = [ | |
| [ 'type' => 'header', 'text' => [ 'type' => 'plain_text', 'text' => "{$icon} 84EM Link Check Report" ] ], | |
| [ 'type' => 'section', 'fields' => [ | |
| [ 'type' => 'mrkdwn', 'text' => "*Site:*\n" . home_url() ], | |
| [ 'type' => 'mrkdwn', 'text' => "*Scanned:*\n" . ( $scan_info['timestamp'] ?? gmdate( 'Y-m-d H:i:s' ) ) ], | |
| ] ], | |
| [ 'type' => 'section', 'fields' => [ | |
| [ 'type' => 'mrkdwn', 'text' => "*Total URLs:*\n{$total_count}" ], | |
| [ 'type' => 'mrkdwn', 'text' => "*OK:*\n{$ok_count}" ], | |
| [ 'type' => 'mrkdwn', 'text' => "*Problems:*\n{$problem_count}" ], | |
| [ 'type' => 'mrkdwn', 'text' => "*Duration:*\n" . ( $duration ?? 'N/A' ) ], | |
| ] ], | |
| ]; | |
| if ( $report_url ) { | |
| $blocks[] = [ 'type' => 'section', 'text' => [ 'type' => 'mrkdwn', 'text' => "*Report:*\n<{$report_url}|View Full Report>" ] ]; | |
| } | |
| $payload = [ 'blocks' => $blocks, 'attachments' => [ [ 'color' => $color, 'blocks' => [] ] ] ]; | |
| $response = wp_remote_post( $this->webhook_url, [ | |
| 'headers' => [ 'Content-Type' => 'application/json' ], | |
| 'body' => wp_json_encode( $payload ), | |
| 'timeout' => 15, | |
| ] ); | |
| if ( is_wp_error( $response ) ) { | |
| WP_CLI::warning( sprintf( 'Slack notification failed: %s', $response->get_error_message() ) ); | |
| return false; | |
| } | |
| if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { | |
| WP_CLI::warning( 'Slack notification failed.' ); | |
| return false; | |
| } | |
| return true; | |
| } | |
| } | |
| /** | |
| * Main WP-CLI command for link checking. | |
| */ | |
| class EightyFourEM_Linkcheck_Command { | |
| private const DEFAULT_TABLES = [ 'posts', 'postmeta' ]; | |
| /** | |
| * Scan the database for URLs and check their HTTP status. | |
| * | |
| * ## OPTIONS | |
| * | |
| * [--codes=<codes>] | |
| * : Filter by HTTP status codes (2xx, 3xx, 4xx, 5xx, 0xx or specific codes). | |
| * Default: non-2xx. | |
| * | |
| * [--dry-run] | |
| * : List discovered URLs without making HTTP requests. | |
| * | |
| * [--format=<format>] | |
| * : Output format: table, csv, json, html. | |
| * Default: html. | |
| * | |
| * [--output=<file>] | |
| * : Save results to file. Defaults to uploads/84em-link-check/84em-linkcheck-{random}.html. | |
| * | |
| * [--slack] | |
| * : Send Slack notification. Defaults to true if EIGHTYFOUREM_SLACK_LINKCHECK_WEBHOOK is defined. | |
| * | |
| * [--no-slack] | |
| * : Disable Slack notification for this run. | |
| * | |
| * [--tables=<tables>] | |
| * : Tables to scan (comma-separated): posts, postmeta. | |
| * Default: posts, postmeta. | |
| * | |
| * [--timeout=<seconds>] | |
| * : HTTP request timeout in seconds. | |
| * Default: 10. | |
| * | |
| * [--delay=<ms>] | |
| * : Delay between requests per domain in milliseconds. | |
| * Default: 500. | |
| * | |
| * [--max-redirects=<count>] | |
| * : Maximum redirects to follow. | |
| * Default: 5. | |
| * | |
| * [--skip-external] | |
| * : Only check internal URLs. | |
| * | |
| * [--skip-internal] | |
| * : Only check external URLs. | |
| * | |
| * [--batch-size=<size>] | |
| * : Database rows per batch. | |
| * Default: 1000. | |
| * | |
| * [--limit=<count>] | |
| * : Maximum number of URLs to check. Useful for testing. | |
| * | |
| * [--concurrency=<count>] | |
| * : Number of concurrent HTTP connections for parallel validation. | |
| * Use 1 for sequential fallback. Max: 20. | |
| * Default: 10. | |
| * | |
| * ## EXAMPLES | |
| * | |
| * wp 84em linkcheck --codes=4xx,5xx | |
| * wp 84em linkcheck --dry-run | |
| * wp 84em linkcheck --format=html --output=/tmp/report.html | |
| * wp 84em linkcheck --skip-external --format=json | |
| * wp 84em linkcheck --limit=50 | |
| * wp 84em linkcheck --concurrency=15 | |
| * wp 84em linkcheck --concurrency=1 --delay=200 | |
| * | |
| * @when after_wp_load | |
| */ | |
| public function linkcheck( array $args, array $assoc_args ): void { | |
| $this->run_linkcheck( $assoc_args ); | |
| } | |
| /** | |
| * Run the link check command. | |
| */ | |
| private function run_linkcheck( array $assoc_args ): void { | |
| $this->flush_output(); | |
| $start_time = microtime( true ); | |
| $format = $assoc_args['format'] ?? 'html'; | |
| $output_file = $assoc_args['output'] ?? null; | |
| if ( null === $output_file && in_array( $format, [ 'html', 'csv', 'json' ], true ) ) { | |
| $upload_dir = wp_upload_dir(); | |
| $report_dir = $upload_dir['basedir'] . '/84em-link-check'; | |
| $random_str = wp_generate_password( 12, false ); | |
| $output_file = "{$report_dir}/84em-linkcheck-{$random_str}.{$format}"; | |
| if ( ! is_dir( $report_dir ) ) { | |
| wp_mkdir_p( $report_dir ); | |
| } | |
| } | |
| $dry_run = isset( $assoc_args['dry-run'] ); | |
| $codes_filter = $assoc_args['codes'] ?? null; | |
| $timeout = (int) ( $assoc_args['timeout'] ?? 10 ); | |
| $delay = (int) ( $assoc_args['delay'] ?? 500 ); | |
| $max_redirects = (int) ( $assoc_args['max-redirects'] ?? 5 ); | |
| $batch_size = (int) ( $assoc_args['batch-size'] ?? 1000 ); | |
| $limit = isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 0; | |
| $concurrency = min( max( (int) ( $assoc_args['concurrency'] ?? 10 ), 1 ), 20 ); | |
| $skip_external = isset( $assoc_args['skip-external'] ); | |
| $skip_internal = isset( $assoc_args['skip-internal'] ); | |
| $send_slack = defined( 'EIGHTYFOUREM_SLACK_LINKCHECK_WEBHOOK' ) && EIGHTYFOUREM_SLACK_LINKCHECK_WEBHOOK; | |
| if ( isset( $assoc_args['slack'] ) ) { | |
| $send_slack = true; | |
| } | |
| if ( isset( $assoc_args['no-slack'] ) ) { | |
| $send_slack = false; | |
| } | |
| $tables = self::DEFAULT_TABLES; | |
| if ( ! empty( $assoc_args['tables'] ) ) { | |
| $tables = array_map( 'trim', explode( ',', $assoc_args['tables'] ) ); | |
| $tables = array_intersect( $tables, self::DEFAULT_TABLES ); | |
| } | |
| if ( empty( $tables ) ) { | |
| WP_CLI::error( 'No valid tables specified.' ); | |
| return; | |
| } | |
| $this->display_config( $tables, $dry_run, $skip_external, $skip_internal, $limit, $timeout, $delay, $max_redirects, $concurrency, $codes_filter, $send_slack, $format, $output_file ); | |
| $confirm = \cli\prompt( WP_CLI::colorize( '%YProceed with scan?%n [Y/n]' ), 'y' ); | |
| if ( strtolower( trim( $confirm ) ) === 'n' ) { | |
| WP_CLI::line( WP_CLI::colorize( '%YScan cancelled.%n' ) ); | |
| return; | |
| } | |
| WP_CLI::line( '' ); | |
| $extractor = new EightyFourEM_Linkcheck_Url_Extractor(); | |
| $urls = []; | |
| $locations = []; | |
| WP_CLI::line( WP_CLI::colorize( '%CScanning database for URLs...%n' ) ); | |
| $limit_reached = false; | |
| foreach ( $tables as $table ) { | |
| if ( $limit_reached ) { | |
| break; | |
| } | |
| $table_urls = 0; | |
| foreach ( $this->scan_table( $table, $batch_size ) as $row ) { | |
| $found = $extractor->extract( $row['content'] ); | |
| foreach ( $found as $url ) { | |
| $is_internal = $extractor->is_internal( $url ); | |
| if ( $skip_external && ! $is_internal ) { | |
| continue; | |
| } | |
| if ( $skip_internal && $is_internal ) { | |
| continue; | |
| } | |
| $safe_url = $extractor->strip_credentials( $url ); | |
| if ( ! isset( $urls[ $safe_url ] ) ) { | |
| $urls[ $safe_url ] = $url; | |
| if ( $limit > 0 && count( $urls ) >= $limit ) { | |
| $limit_reached = true; | |
| break 2; | |
| } | |
| } | |
| $locations[ $safe_url ][] = [ 'post_id' => $row['post_id'] ]; | |
| $table_urls++; | |
| } | |
| } | |
| WP_CLI::line( WP_CLI::colorize( sprintf( ' %%C%s:%%n %d URL occurrences found', $table, $table_urls ) ) ); | |
| } | |
| $unique_count = count( $urls ); | |
| WP_CLI::line( '' ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%GTotal unique URLs:%%n %d%s', $unique_count, $limit_reached ? ' %Y(limit reached)%n' : '' ) ) ); | |
| if ( 0 === $unique_count ) { | |
| WP_CLI::warning( 'No URLs found to check.' ); | |
| return; | |
| } | |
| $scan_info = [ | |
| 'timestamp' => gmdate( 'Y-m-d H:i:s' ), | |
| 'site' => home_url(), | |
| 'tables' => $tables, | |
| 'dry_run' => $dry_run, | |
| ]; | |
| if ( $dry_run ) { | |
| $this->handle_dry_run( $urls, $locations, $scan_info, $format, $output_file ); | |
| return; | |
| } | |
| $results = $this->validate_urls( $urls, $timeout, $delay, $max_redirects, $concurrency ); | |
| $duration_text = $this->format_duration( microtime( true ) - $start_time ); | |
| $results = $this->filter_by_codes( $results, $codes_filter, $extractor ); | |
| WP_CLI::line( '' ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%GResults:%%n %d URLs after filtering', count( $results ) ) ) ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%GScan completed in:%%n %s', $duration_text ) ) ); | |
| WP_CLI::line( '' ); | |
| $this->output_results( $results, $locations, $scan_info, $format, $output_file ); | |
| if ( $send_slack && defined( 'EIGHTYFOUREM_SLACK_LINKCHECK_WEBHOOK' ) ) { | |
| $this->send_slack_notification( $results, $scan_info, $output_file, $duration_text ); | |
| } | |
| $this->prompt_cleanup_old_reports( $output_file ); | |
| } | |
| /** | |
| * Display configuration. | |
| */ | |
| private function display_config( array $tables, bool $dry_run, bool $skip_external, bool $skip_internal, int $limit, int $timeout, int $delay, int $max_redirects, int $concurrency, ?string $codes_filter, bool $send_slack, string $format, ?string $output_file ): void { | |
| WP_CLI::line( '' ); | |
| WP_CLI::line( WP_CLI::colorize( '%B===== 84EM Link Checker =====%n' ) ); | |
| WP_CLI::line( '' ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%C Tables:%%n %s', implode( ', ', $tables ) ) ) ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%C Mode:%%n %s', $dry_run ? '%YDry run (no HTTP requests)%n' : 'Full scan' ) ) ); | |
| $scope = 'All URLs'; | |
| if ( $skip_external ) { | |
| $scope = 'Internal only'; | |
| } elseif ( $skip_internal ) { | |
| $scope = 'External only'; | |
| } | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%C Scope:%%n %s', $scope ) ) ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%C Limit:%%n %s', $limit > 0 ? $limit . ' URLs' : 'None' ) ) ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%C Timeout:%%n %ds', $timeout ) ) ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%C Delay:%%n %dms', $delay ) ) ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%C Max Redirects:%%n %d', $max_redirects ) ) ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%C Concurrency:%%n %d%s', $concurrency, $concurrency > 1 ? ' (parallel)' : ' (sequential)' ) ) ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%C Codes Filter:%%n %s', $codes_filter ?? 'Non-2xx (default)' ) ) ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%C Slack:%%n %s', $send_slack ? 'Enabled' : 'Disabled' ) ) ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%C Format:%%n %s', $format ) ) ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%C Output:%%n %s', $output_file ?? 'stdout' ) ) ); | |
| WP_CLI::line( '' ); | |
| } | |
| /** | |
| * Handle dry run mode. | |
| */ | |
| private function handle_dry_run( array $urls, array $locations, array $scan_info, string $format, ?string $output_file ): void { | |
| WP_CLI::line( '' ); | |
| WP_CLI::line( WP_CLI::colorize( '%YDry run mode - URLs discovered:%n' ) ); | |
| WP_CLI::line( '' ); | |
| $dry_results = []; | |
| foreach ( $urls as $safe_url => $original_url ) { | |
| $dry_results[] = [ | |
| 'original_url' => $safe_url, | |
| 'final_url' => '', | |
| 'status' => 'not_checked', | |
| 'final_code' => 0, | |
| 'redirect_chain' => [], | |
| 'redirect_count' => 0, | |
| 'error' => '', | |
| ]; | |
| } | |
| $this->output_results( $dry_results, $locations, $scan_info, $format, $output_file ); | |
| } | |
| /** | |
| * Validate URLs. | |
| */ | |
| private function validate_urls( array $urls, int $timeout, int $delay, int $max_redirects, int $concurrency ): array { | |
| WP_CLI::line( '' ); | |
| WP_CLI::line( WP_CLI::colorize( '%CValidating URLs...%n' ) ); | |
| $results = []; | |
| $progress = \WP_CLI\Utils\make_progress_bar( 'Checking URLs', count( $urls ) ); | |
| $url_list = []; | |
| $safe_url_map = []; | |
| foreach ( $urls as $safe_url => $original_url ) { | |
| $url_list[] = $original_url; | |
| $safe_url_map[ $original_url ] = $safe_url; | |
| } | |
| if ( $concurrency > 1 ) { | |
| $parallel_validator = new EightyFourEM_Linkcheck_Parallel_Validator( $timeout, $delay, $max_redirects, $concurrency ); | |
| $parallel_validator->validate_batch( | |
| $url_list, | |
| function ( $result ) use ( &$results, &$safe_url_map, $progress ) { | |
| $original = $result['original_url']; | |
| $result['original_url'] = $safe_url_map[ $original ] ?? $original; | |
| $results[] = $result; | |
| $progress->tick(); | |
| } | |
| ); | |
| } else { | |
| $validator = new EightyFourEM_Linkcheck_Url_Validator( $timeout, $delay, $max_redirects ); | |
| foreach ( $urls as $safe_url => $original_url ) { | |
| $result = $validator->validate( $original_url ); | |
| $result['original_url'] = $safe_url; | |
| $results[] = $result; | |
| $progress->tick(); | |
| } | |
| } | |
| $progress->finish(); | |
| return $results; | |
| } | |
| /** | |
| * Send Slack notification. | |
| */ | |
| private function send_slack_notification( array $results, array $scan_info, ?string $output_file, string $duration_text ): void { | |
| WP_CLI::line( '' ); | |
| WP_CLI::line( WP_CLI::colorize( '%CSending Slack notification...%n' ) ); | |
| $notifier = new EightyFourEM_Linkcheck_Slack_Notifier( EIGHTYFOUREM_SLACK_LINKCHECK_WEBHOOK ); | |
| $report_url = null; | |
| if ( $output_file ) { | |
| $upload_dir = wp_upload_dir(); | |
| if ( str_starts_with( $output_file, $upload_dir['basedir'] ) ) { | |
| $report_url = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $output_file ); | |
| } | |
| } | |
| if ( $notifier->notify( $results, $scan_info, $report_url, $duration_text ) ) { | |
| WP_CLI::success( 'Slack notification sent.' ); | |
| } | |
| } | |
| /** | |
| * Format duration in seconds to human-readable string. | |
| */ | |
| private function format_duration( float $seconds ): string { | |
| $minutes = (int) round( $seconds / 60 ); | |
| if ( $minutes < 1 ) { | |
| return 'less than 1 minute'; | |
| } | |
| if ( $minutes === 1 ) { | |
| return '1 minute'; | |
| } | |
| if ( $minutes < 60 ) { | |
| return $minutes . ' minutes'; | |
| } | |
| $hours = (int) floor( $minutes / 60 ); | |
| $remaining_minutes = $minutes % 60; | |
| if ( $remaining_minutes === 0 ) { | |
| return $hours === 1 ? '1 hour' : $hours . ' hours'; | |
| } | |
| $hour_text = $hours === 1 ? '1 hour' : $hours . ' hours'; | |
| $minute_text = $remaining_minutes === 1 ? '1 minute' : $remaining_minutes . ' minutes'; | |
| return $hour_text . ' ' . $minute_text; | |
| } | |
| /** | |
| * Prompt user to clean up old report files. | |
| */ | |
| private function prompt_cleanup_old_reports( ?string $current_file ): void { | |
| $upload_dir = wp_upload_dir(); | |
| $report_dir = $upload_dir['basedir'] . '/84em-link-check'; | |
| if ( ! is_dir( $report_dir ) ) { | |
| return; | |
| } | |
| $files = glob( $report_dir . '/84em-linkcheck-*.*' ); | |
| $old_files = array_filter( $files ?: [], fn( $f ) => $f !== $current_file ); | |
| if ( empty( $old_files ) ) { | |
| return; | |
| } | |
| usort( $old_files, fn( $a, $b ) => filemtime( $b ) - filemtime( $a ) ); | |
| WP_CLI::line( '' ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( '%%YFound %d older report(s)%%n in %s', count( $old_files ), $report_dir ) ) ); | |
| foreach ( $old_files as $file ) { | |
| $filename = basename( $file ); | |
| $filesize = size_format( filesize( $file ) ); | |
| $file_date = gmdate( 'Y-m-d H:i:s', filemtime( $file ) ); | |
| WP_CLI::line( '' ); | |
| WP_CLI::line( WP_CLI::colorize( sprintf( ' %%C%s%%n (%s, %s)', $filename, $filesize, $file_date ) ) ); | |
| $answer = \cli\prompt( WP_CLI::colorize( '%YDelete this file?%n [y/N]' ), 'n' ); | |
| if ( strtolower( trim( $answer ) ) === 'y' ) { | |
| if ( wp_delete_file( $file ) || ! file_exists( $file ) ) { | |
| WP_CLI::success( 'Deleted.' ); | |
| } else { | |
| WP_CLI::warning( 'Could not delete file.' ); | |
| } | |
| } else { | |
| WP_CLI::line( WP_CLI::colorize( '%CSkipped.%n' ) ); | |
| } | |
| } | |
| } | |
| /** | |
| * Scan a database table for content. | |
| */ | |
| private function scan_table( string $table, int $batch_size ): \Generator { | |
| global $wpdb; | |
| $table_name = $wpdb->prefix . $table; | |
| $columns = []; | |
| $id_col = 'ID'; | |
| switch ( $table ) { | |
| case 'posts': | |
| $columns = [ 'post_content', 'post_excerpt' ]; | |
| $id_col = 'ID'; | |
| break; | |
| case 'postmeta': | |
| $columns = [ 'meta_value' ]; | |
| $id_col = 'meta_id'; | |
| break; | |
| default: | |
| return; | |
| } | |
| $select_cols = array_merge( [ $id_col ], $columns ); | |
| $offset = 0; | |
| $where_clause = ''; | |
| $join_clause = ''; | |
| if ( 'posts' === $table ) { | |
| $where_clause = "WHERE post_status = 'publish' AND post_type IN ('post', 'page')"; | |
| } elseif ( 'postmeta' === $table ) { | |
| $select_cols[] = "{$table_name}.post_id"; | |
| $join_clause = "INNER JOIN {$wpdb->posts} p ON {$table_name}.post_id = p.ID"; | |
| $where_clause = "WHERE p.post_status = 'publish' AND p.post_type IN ('post', 'page')"; | |
| } | |
| while ( true ) { | |
| $query = $wpdb->prepare( | |
| "SELECT " . implode( ', ', $select_cols ) . " FROM {$table_name} {$join_clause} {$where_clause} LIMIT %d OFFSET %d", | |
| $batch_size, | |
| $offset | |
| ); | |
| $rows = $wpdb->get_results( $query, ARRAY_A ); | |
| if ( empty( $rows ) ) { | |
| break; | |
| } | |
| foreach ( $rows as $row ) { | |
| $row_id = $row[ $id_col ]; | |
| foreach ( $columns as $column ) { | |
| $content = $row[ $column ] ?? ''; | |
| if ( empty( $content ) ) { | |
| continue; | |
| } | |
| $unserialized = maybe_unserialize( $content ); | |
| if ( is_array( $unserialized ) || is_object( $unserialized ) ) { | |
| $content = wp_json_encode( $unserialized ); | |
| } | |
| yield [ | |
| 'table' => $table, | |
| 'column' => $column, | |
| 'row_id' => $row_id, | |
| 'post_id' => $row['post_id'] ?? $row_id, | |
| 'content' => $content, | |
| ]; | |
| } | |
| } | |
| $offset += $batch_size; | |
| } | |
| } | |
| /** | |
| * Filter results by status codes. | |
| */ | |
| private function filter_by_codes( array $results, ?string $codes_filter, EightyFourEM_Linkcheck_Url_Extractor $extractor ): array { | |
| if ( null === $codes_filter ) { | |
| return array_filter( $results, function ( $r ) use ( $extractor ) { | |
| if ( 'ok' === $r['status'] ) { | |
| return false; | |
| } | |
| $is_internal = $extractor->is_internal( $r['original_url'] ); | |
| if ( ! $is_internal ) { | |
| return 404 === $r['final_code']; | |
| } | |
| return true; | |
| } ); | |
| } | |
| $codes = array_map( 'trim', explode( ',', strtolower( $codes_filter ) ) ); | |
| return array_filter( $results, function ( $result ) use ( $codes, $extractor ) { | |
| $status_code = $result['final_code']; | |
| $is_internal = $extractor->is_internal( $result['original_url'] ); | |
| if ( ! $is_internal && 404 !== $status_code ) { | |
| return false; | |
| } | |
| foreach ( $codes as $code ) { | |
| if ( '2xx' === $code && $status_code >= 200 && $status_code < 300 ) { | |
| return true; | |
| } | |
| if ( '3xx' === $code && $status_code >= 300 && $status_code < 400 ) { | |
| return true; | |
| } | |
| if ( '4xx' === $code && $status_code >= 400 && $status_code < 500 ) { | |
| return true; | |
| } | |
| if ( '5xx' === $code && $status_code >= 500 && $status_code < 600 ) { | |
| return true; | |
| } | |
| if ( '0xx' === $code && 0 === $status_code ) { | |
| return true; | |
| } | |
| if ( is_numeric( $code ) && (int) $code === $status_code ) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } ); | |
| } | |
| /** | |
| * Output results in the specified format. | |
| */ | |
| private function output_results( array $results, array $locations, array $scan_info, string $format, ?string $output_file ): void { | |
| $reporter = new EightyFourEM_Linkcheck_Reporter(); | |
| switch ( $format ) { | |
| case 'csv': | |
| $reporter->format_csv( $results, $locations, $output_file ); | |
| break; | |
| case 'json': | |
| $reporter->format_json( $results, $locations, $scan_info, $output_file ); | |
| break; | |
| case 'html': | |
| $reporter->format_html( $results, $locations, $scan_info, $output_file ); | |
| break; | |
| case 'table': | |
| default: | |
| $reporter->format_table( $results, $locations ); | |
| break; | |
| } | |
| } | |
| /** | |
| * Flush output buffers for WP Engine compatibility. | |
| */ | |
| private function flush_output(): void { | |
| while ( ob_get_level() > 0 ) { | |
| ob_end_flush(); | |
| } | |
| flush(); | |
| } | |
| } | |
| WP_CLI::add_command( '84em linkcheck', [ 'EightyFourEM_Linkcheck_Command', 'linkcheck' ] ); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment