PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / InternalLinkScanner.php

InternalLinkScanner.php in 404 Solution 4.2.0, at includes/InternalLinkScanner.php

238 lines 8.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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