[ 'post', 'page' ], 'post_status' => 'publish', 'posts_per_page' => 50, 'orderby' => 'modified', 'order' => 'ASC', ] ); foreach ( $posts as $post ) { self::scan_post( $post->ID ); } } /** * Full site scan run synchronously on demand (the "Run Scan Now" button * in Broken Links, and the Dashboard's "Run SEO Analysis" button), as * opposed to run_scan()'s small daily-cron batches. Bounded by $limit so * a very large site can't time out the request. * * @param int $limit Max posts/pages to check in this run. * @return int Number of posts/pages actually checked. */ public static function run_full_scan_now( $limit = 300 ) { $posts = get_posts( [ 'post_type' => [ 'post', 'page' ], 'post_status' => 'publish', 'posts_per_page' => (int) $limit, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', ] ); foreach ( $posts as $post_id ) { self::scan_post( $post_id ); } return count( $posts ); } public static function ajax_scan_post() { check_ajax_referer( 'awbseo_admin', 'nonce' ); $post_id = (int) ( $_POST['post_id'] ?? 0 ); // post_id 0 from the "Run Scan Now" button means "scan the whole // site" - that's a site-wide operation, so it needs manage_options // rather than an edit_post check against a nonexistent post ID. if ( 0 === $post_id ) { if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' ); if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 120 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } $checked = self::run_full_scan_now(); wp_send_json_success( [ 'checked' => $checked ] ); } if ( ! current_user_can( 'edit_post', $post_id ) ) wp_send_json_error( 'Forbidden' ); $results = self::scan_post( $post_id ); wp_send_json_success( $results ); } public static function ajax_dismiss_link() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) wp_send_json_error( 'Forbidden' ); $id = (int) ( $_POST['id'] ?? 0 ); if ( ! $id ) wp_send_json_error( 'Missing id' ); self::delete_record( $id ); wp_send_json_success(); } public static function scan_post( $post_id ) { $post = get_post( $post_id ); $content = $post->post_content ?? ''; preg_match_all( '/href=["\']((https?:\/\/)[^"\']+)["\']/', $content, $matches ); $urls = array_unique( $matches[1] ?? [] ); $results = []; // Skip internal links and common tracker URLs. $home = parse_url( home_url(), PHP_URL_HOST ); $skip_patterns = [ '/localhost/', '/127\.0\.0\.1/', '/' . preg_quote( $home, '/' ) . '/' ]; $checked_urls = []; foreach ( $urls as $url ) { $skip = false; foreach ( $skip_patterns as $pattern ) { if ( preg_match( $pattern, $url ) ) { $skip = true; break; } } if ( $skip ) continue; $checked_urls[] = $url; $code = self::check_url( $url ); self::save_result( $post_id, $url, $code ); if ( self::is_broken_code( $code ) ) { $results[] = [ 'url' => $url, 'status' => $code ]; } } // A link removed or replaced in the content (the fix, in most // cases) would otherwise leave its old row behind forever - // still counted as broken even though it's no longer on the // page at all. Clear out anything this post used to link to // that it doesn't link to anymore. self::prune_stale_records( $post_id, $checked_urls ); return $results; } /** * Removes stored results for URLs no longer present in a post's * content, so a link fixed by removing or replacing it stops being * counted as broken as soon as this post is next scanned - whether * that scan was triggered by saving the post, a manual rescan, or * the daily cron. * * @param int $post_id * @param string[] $current_urls URLs actually checked in this scan. */ private static function prune_stale_records( $post_id, array $current_urls ) { global $wpdb; $table = $wpdb->prefix . 'awb_broken_links'; $existing = $wpdb->get_col( $wpdb->prepare( "SELECT url FROM {$table} WHERE post_id = %d", $post_id ) ); if ( ! $existing ) { return; } $stale = array_diff( $existing, $current_urls ); foreach ( $stale as $url ) { $wpdb->delete( $table, [ 'post_id' => $post_id, 'url' => $url ], [ '%d', '%s' ] ); } } /** * What actually counts as "broken" for reporting purposes. Every status * code a check returns is still stored (see save_result()), but only * these mean the page itself is the problem: * 404 - the page is genuinely not there. * 0 - the request couldn't connect at all (DNS failure, * timeout, refused connection) - a real dead end, not * a server choosing not to answer. * Deliberately NOT included: 401/403 (the server is blocking this * request specifically, most often bot/scanner protection - Instagram, * Cloudflare-protected sites, and similar routinely 403 or 429 * automated checks while working perfectly for every real visitor), * 429 (rate-limited, not broken), 405/406 (method/format the checker * used wasn't accepted, not the page), and 5xx (usually a transient * server hiccup rather than the link being wrong). * * @param int $code HTTP status code (or 0 for a failed connection). * @return bool */ public static function is_broken_code( $code ) { return 404 === (int) $code || 0 === (int) $code; } /** SQL fragment matching is_broken_code(), for use in WHERE clauses against awb_broken_links.status_code. */ public static function broken_sql( $col = 'status_code' ) { return "( {$col} = 404 OR {$col} = 0 )"; } /** * Codes that commonly mean "a firewall or CDN didn't like this request", * not "the page is actually gone". A HEAD request in particular is * rejected outright by a lot of WAFs/CDNs (Cloudflare, Sucuri, and many * hosts) even though the same URL opens fine in a real browser doing a * GET - so these get one retry as a real GET before being trusted. */ const RETRY_CODES = [ 0, 401, 403, 405, 406, 429, 501, 503 ]; private static function check_url( $url ) { $args = [ 'timeout' => 10, // A plain "compatible bot" identifier (the same format search // engine crawlers use) gets waved through by far more WAFs than // an unrecognized HTTP-library or "scanner"-labelled user agent, // and it's honest about what's making the request either way. 'user-agent' => 'Mozilla/5.0 (compatible; CitedSEOLinkChecker/1.0; +' . home_url( '/' ) . ')', 'headers' => [ 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', ], 'sslverify' => true, 'redirection' => 5, // URLs here come from post content (any author who can edit a // post), not an admin setting - reject_unsafe_urls blocks // requests to private/reserved/link-local IPs so this scanner // can't be used to probe internal network services (SSRF). 'reject_unsafe_urls' => true, ]; $code = self::request_status( $url, array_merge( $args, [ 'method' => 'HEAD' ] ) ); // Don't trust a HEAD-request rejection on its own - confirm with a // real GET (capped so we never pull down a large file) before // reporting the link as broken. if ( in_array( $code, self::RETRY_CODES, true ) ) { $code = self::request_status( $url, array_merge( $args, [ 'method' => 'GET', 'limit_response_size' => 65536, ] ) ); } return $code; } private static function request_status( $url, $args ) { $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) return 0; return (int) wp_remote_retrieve_response_code( $response ); } private static function save_result( $post_id, $url, $code ) { global $wpdb; $table = $wpdb->prefix . 'awb_broken_links'; $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE post_id = %d AND url = %s", $post_id, $url ) ); if ( $existing ) { $wpdb->update( $table, [ 'status_code' => $code, 'last_checked' => current_time( 'mysql' ) ], [ 'id' => $existing ], [ '%d', '%s' ], [ '%d' ] ); } else { $wpdb->insert( $table, [ 'post_id' => $post_id, 'url' => $url, 'status_code' => $code, 'last_checked' => current_time( 'mysql' ) ], [ '%d', '%s', '%d', '%s' ] ); } } public static function get_broken( $limit = 100 ) { global $wpdb; $broken_sql = self::broken_sql( 'bl.status_code' ); return $wpdb->get_results( $wpdb->prepare( "SELECT bl.*, p.post_title, p.ID as post_id FROM {$wpdb->prefix}awb_broken_links bl JOIN {$wpdb->posts} p ON p.ID = bl.post_id WHERE {$broken_sql} AND bl.fix_status != 'fixed' ORDER BY bl.last_checked DESC LIMIT %d", $limit ) ); } public static function get_all( $limit = 100 ) { global $wpdb; return $wpdb->get_results( $wpdb->prepare( "SELECT bl.*, p.post_title FROM {$wpdb->prefix}awb_broken_links bl JOIN {$wpdb->posts} p ON p.ID = bl.post_id ORDER BY bl.last_checked DESC LIMIT %d", $limit ) ); } public static function delete_record( $id ) { global $wpdb; $wpdb->delete( $wpdb->prefix . 'awb_broken_links', [ 'id' => (int) $id ], [ '%d' ] ); } /** * Update the fix_status field for a single broken-link record. * * @param int $id Record ID in awb_broken_links. * @param string $status 'fixed' or 'not_fixed'. */ public static function update_fix_status( $id, $status ) { global $wpdb; $allowed = [ 'fixed', 'not_fixed' ]; $status = in_array( $status, $allowed, true ) ? $status : 'not_fixed'; $wpdb->update( $wpdb->prefix . 'awb_broken_links', [ 'fix_status' => $status ], [ 'id' => (int) $id ], [ '%s' ], [ '%d' ] ); } public static function ajax_set_fix_status() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( 'Forbidden' ); } $id = (int) ( $_POST['id'] ?? 0 ); $status = sanitize_key( $_POST['fix_status'] ?? '' ); if ( ! $id ) { wp_send_json_error( 'Missing id' ); } self::update_fix_status( $id, $status ); wp_send_json_success(); } }