PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / core / InternalLinkScanner.php

InternalLinkScanner.php in 404 Solution trunk, at includes/core/InternalLinkScanner.php

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