| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Proactive Internal Link Scanner. |
| 9 |
* |
| 10 |
* Scans published post/page content for broken internal links — links whose |
| 11 |
* relative path matches a URL captured as a 404 in the plugin's redirect table. |
| 12 |
* |
| 13 |
* No HTTP requests are made: this is entirely database + post_content inspection. |
| 14 |
*/ |
| 15 |
class ABJ_404_Solution_InternalLinkScanner { |
| 16 |
|
| 17 |
/** Transient key used to cache scan results. */ |
| 18 |
const TRANSIENT_KEY = 'abj404_broken_links_scan'; |
| 19 |
|
| 20 |
/** Cache TTL in seconds (6 hours). */ |
| 21 |
const CACHE_TTL = 21600; |
| 22 |
|
| 23 |
/** Maximum posts to inspect in a single batch to guard large sites. */ |
| 24 |
const BATCH_LIMIT = 1000; |
| 25 |
|
| 26 |
/** |
| 27 |
* Scan all published posts/pages for broken internal links. |
| 28 |
* |
| 29 |
* Broken = URL appears in the captured-404 list (status = ABJ404_STATUS_CAPTURED, |
| 30 |
* disabled = 0). External links are ignored. |
| 31 |
* |
| 32 |
* @return array<int, array{post_id: int, post_title: string, broken_url: string, hit_count: int}> |
| 33 |
*/ |
| 34 |
public function scanForBrokenLinks(): array { |
| 35 |
// 1. Collect captured 404 URLs from the redirects table. |
| 36 |
$capturedUrls = $this->getCapturedUrlSet(); |
| 37 |
if (empty($capturedUrls)) { |
| 38 |
return array(); |
| 39 |
} |
| 40 |
|
| 41 |
// 2. Get published posts/pages in batches (guard against very large sites). |
| 42 |
$siteHomeUrl = function_exists('home_url') ? (string)home_url() : ''; |
| 43 |
$results = array(); |
| 44 |
$offset = 0; |
| 45 |
|
| 46 |
do { |
| 47 |
$posts = $this->fetchPublishedPosts($offset, self::BATCH_LIMIT); |
| 48 |
if (empty($posts)) { |
| 49 |
break; |
| 50 |
} |
| 51 |
|
| 52 |
foreach ($posts as $post) { |
| 53 |
// Boundary normalizer: WP_Post shape-probing lives in the VO. |
| 54 |
$ref = ABJ_404_Solution_PostRef::fromWpPost($post); |
| 55 |
if ($ref === null) { |
| 56 |
continue; |
| 57 |
} |
| 58 |
$content = $ref->getContent(); |
| 59 |
$postId = $ref->getId(); |
| 60 |
$postTitle = $ref->getTitle(); |
| 61 |
|
| 62 |
if ($content === '') { |
| 63 |
continue; |
| 64 |
} |
| 65 |
|
| 66 |
// Extract all href values. |
| 67 |
preg_match_all('/href=["\']([^"\']+)["\']/', $content, $matches); |
| 68 |
$hrefs = $matches[1]; |
| 69 |
|
| 70 |
foreach ($hrefs as $href) { |
| 71 |
$href = trim($href); |
| 72 |
if ($href === '') { |
| 73 |
continue; |
| 74 |
} |
| 75 |
|
| 76 |
// Normalize: strip the home_url prefix to get a relative path. |
| 77 |
$relative = $this->normalizeToRelative($href, $siteHomeUrl); |
| 78 |
|
| 79 |
// Skip purely external links (those we could not strip to a relative path). |
| 80 |
if ($relative === null) { |
| 81 |
continue; |
| 82 |
} |
| 83 |
|
| 84 |
// Check if this relative path is a captured 404. |
| 85 |
if (!isset($capturedUrls[$relative])) { |
| 86 |
continue; |
| 87 |
} |
| 88 |
|
| 89 |
$results[] = array( |
| 90 |
'post_id' => $postId, |
| 91 |
'post_title' => $postTitle, |
| 92 |
'broken_url' => $relative, |
| 93 |
'hit_count' => $capturedUrls[$relative], |
| 94 |
); |
| 95 |
} |
| 96 |
} |
| 97 |
|
| 98 |
$offset += count($posts); |
| 99 |
} while (count($posts) >= self::BATCH_LIMIT); |
| 100 |
|
| 101 |
return $results; |
| 102 |
} |
| 103 |
|
| 104 |
/** |
| 105 |
* Run a scan and cache results in a transient with a 6-hour TTL. |
| 106 |
* Called nightly by the maintenance cron. |
| 107 |
* @return void |
| 108 |
*/ |
| 109 |
public function runNightlyScan(): void { |
| 110 |
$results = $this->scanForBrokenLinks(); |
| 111 |
set_transient(self::TRANSIENT_KEY, $results, self::CACHE_TTL); |
| 112 |
} |
| 113 |
|
| 114 |
/** |
| 115 |
* Get cached scan results. |
| 116 |
* |
| 117 |
* @return array<int, array{post_id: int, post_title: string, broken_url: string, hit_count: int}>|false |
| 118 |
* Cached results array, or false if no cache exists. |
| 119 |
*/ |
| 120 |
public function getCachedResults() { |
| 121 |
$result = get_transient(self::TRANSIENT_KEY); |
| 122 |
return is_array($result) ? $result : false; |
| 123 |
} |
| 124 |
|
| 125 |
/** |
| 126 |
* Build a map of captured-404 URLs to their hit counts from the redirects table. |
| 127 |
* |
| 128 |
* @return array<string, int> Keyed by relative URL, value = hit count estimate. |
| 129 |
*/ |
| 130 |
private function getCapturedUrlSet(): array { |
| 131 |
global $wpdb; |
| 132 |
|
| 133 |
$capturedStatus = defined('ABJ404_STATUS_CAPTURED') ? intval(ABJ404_STATUS_CAPTURED) : 3; |
| 134 |
|
| 135 |
// Use the plugin's DatabaseCore if available, otherwise fall back to strtolower prefix. |
| 136 |
$dbCore = null; |
| 137 |
if (class_exists('ABJ_404_Solution_DatabaseCore')) { |
| 138 |
$dbCore = abj_service('db_core'); |
| 139 |
$redirectsTable = $dbCore->doTableNameReplacements('{wp_abj404_redirects}'); |
| 140 |
} else { |
| 141 |
$redirectsTable = strtolower($wpdb->prefix) . 'abj404_redirects'; |
| 142 |
} |
| 143 |
|
| 144 |
$sql = "SELECT `url` FROM `{$redirectsTable}` WHERE `status` = %d AND `disabled` = 0"; |
| 145 |
|
| 146 |
// Route through queryAndGetResults() so this nightly-cron scan inherits |
| 147 |
// the centralized timeout, retry, and corrupted-table recovery instead |
| 148 |
// of failing silently or hanging. Only fall back to the raw $wpdb path |
| 149 |
// when the DatabaseCore class is unavailable (e.g. integration test bootstraps). |
| 150 |
if ($dbCore !== null) { |
| 151 |
$result = $dbCore->queryAndGetResults($sql, array( |
| 152 |
'query_params' => array($capturedStatus), |
| 153 |
)); |
| 154 |
if (!empty($result['timed_out']) || |
| 155 |
(isset($result['last_error']) && $result['last_error'] != '')) { |
| 156 |
return array(); |
| 157 |
} |
| 158 |
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : array(); |
| 159 |
} else { |
| 160 |
$prepared = $wpdb->prepare($sql, $capturedStatus); |
| 161 |
// DAO-bypass-approved: Test-environment fallback — primary path goes through queryAndGetResults at :149 |
| 162 |
$rows = $wpdb->get_results($prepared, ARRAY_A); |
| 163 |
if (!is_array($rows)) { |
| 164 |
return array(); |
| 165 |
} |
| 166 |
} |
| 167 |
|
| 168 |
$urlSet = array(); |
| 169 |
foreach ($rows as $row) { |
| 170 |
if (!is_array($row) || !isset($row['url'])) { |
| 171 |
continue; |
| 172 |
} |
| 173 |
$url = (string)$row['url']; |
| 174 |
if ($url !== '') { |
| 175 |
// Use 0 as a placeholder hit count; real hit counts would need a join. |
| 176 |
$urlSet[$url] = 0; |
| 177 |
} |
| 178 |
} |
| 179 |
|
| 180 |
return $urlSet; |
| 181 |
} |
| 182 |
|
| 183 |
/** |
| 184 |
* Fetch a batch of published posts and pages. |
| 185 |
* |
| 186 |
* @param int $offset |
| 187 |
* @param int $limit |
| 188 |
* @return array<int, object> |
| 189 |
*/ |
| 190 |
private function fetchPublishedPosts(int $offset, int $limit): array { |
| 191 |
if (!function_exists('get_posts')) { |
| 192 |
return array(); |
| 193 |
} |
| 194 |
|
| 195 |
$posts = get_posts(array( |
| 196 |
'post_type' => array('post', 'page'), |
| 197 |
'post_status' => 'publish', |
| 198 |
'posts_per_page' => $limit, |
| 199 |
'offset' => $offset, |
| 200 |
'fields' => 'all', |
| 201 |
// Suppress filters so we get raw post_content. |
| 202 |
'suppress_filters' => true, |
| 203 |
)); |
| 204 |
|
| 205 |
return $posts; |
| 206 |
} |
| 207 |
|
| 208 |
/** |
| 209 |
* Normalize a URL to a relative path by stripping the home_url prefix. |
| 210 |
* |
| 211 |
* Returns null when the URL is purely external (not on this site). |
| 212 |
* |
| 213 |
* @param string $href The href value extracted from post content. |
| 214 |
* @param string $siteHome The result of home_url() for this site. |
| 215 |
* @return string|null Relative path (e.g. "/old-page/") or null for external links. |
| 216 |
*/ |
| 217 |
private function normalizeToRelative(string $href, string $siteHome): ?string { |
| 218 |
// Already a relative path. |
| 219 |
if (strpos($href, '/') === 0 && strpos($href, '//') !== 0) { |
| 220 |
return $href; |
| 221 |
} |
| 222 |
|
| 223 |
// Absolute URL: strip the home_url prefix if it matches this site. |
| 224 |
if ($siteHome !== '' && strpos($href, $siteHome) === 0) { |
| 225 |
$relative = substr($href, strlen($siteHome)); |
| 226 |
return ($relative === '') ? '/' : $relative; |
| 227 |
} |
| 228 |
|
| 229 |
// Fragment-only or javascript: — not a real internal link. |
| 230 |
if (strpos($href, '#') === 0 || strpos($href, 'javascript:') === 0 || strpos($href, 'mailto:') === 0) { |
| 231 |
return null; |
| 232 |
} |
| 233 |
|
| 234 |
// Anything else is an external URL. |
| 235 |
return null; |
| 236 |
} |
| 237 |
} |
| 238 |
|