Skip to content

Instantly share code, notes, and snippets.

@84em
Last active January 12, 2026 14:42
Show Gist options
  • Select an option

  • Save 84em/742967dee62b484a1857829c04ca9e14 to your computer and use it in GitHub Desktop.

Select an option

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.
<?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