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 / gsc / GscSearchAnalyticsClient.php

GscSearchAnalyticsClient.php in 404 Solution trunk, at includes/gsc/GscSearchAnalyticsClient.php

321 lines 10.9 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 require_once __DIR__ . '/GscConfig.php';
8 require_once __DIR__ . '/GscFetchLock.php';
9
10 /**
11 * Owns Google Search Console Search Analytics requests, cache writes, fetch
12 * locks, and background refresh scheduling.
13 */
14 class ABJ_404_Solution_GscSearchAnalyticsClient {
15
16 /**
17 * Recency window scanned by the GSC URL probe (rows from logsv2).
18 * Made explicit at the call site so the cap is visible here, not buried in SQL.
19 */
20 const GSC_URL_PROBE_RECENT_LOG_WINDOW = 5000;
21
22 /** Max distinct URLs the GSC URL probe pulls per fetch. */
23 const GSC_URL_PROBE_DISTINCT_URL_CAP = 500;
24
25 /** @var ABJ_404_Solution_Logging */
26 private $logger;
27
28 /** @var ABJ_404_Solution_GscOAuthTokenStore */
29 private $oauthStore;
30
31 /** @var ABJ_404_Solution_GscFetchLock */
32 private $fetchLock;
33
34 /** @param ABJ_404_Solution_Logging $logger */
35 public function __construct($logger, ABJ_404_Solution_GscOAuthTokenStore $oauthStore) {
36 $this->logger = $logger;
37 $this->oauthStore = $oauthStore;
38 $this->fetchLock = new ABJ_404_Solution_GscFetchLock($logger);
39 }
40
41 /**
42 * Fetch search analytics data for a list of URLs.
43 *
44 * @param string[] $urls Relative or absolute URLs to query.
45 * @param int $days Number of days to look back.
46 * @return array<int, array<string, mixed>>
47 */
48 public function getSearchAnalyticsForUrls(array $urls, int $days = 90): array {
49 if (!$this->oauthStore->isAuthorized() || empty($urls)) {
50 return array();
51 }
52
53 $cached = get_transient(ABJ_404_Solution_GscConfig::TRANSIENT_KEY);
54 $cachedRows = $this->normalizeRows($cached);
55 if ($cachedRows !== false) {
56 return $cachedRows;
57 }
58
59 $fetchResult = $this->doFetchFromApi($urls, $days);
60 $allRows = $fetchResult['rows'];
61 // allow-cache-empty: empty GSC result sets are valid recent fetches and drive the explicit no-data UI state.
62 set_transient(ABJ_404_Solution_GscConfig::TRANSIENT_KEY, $allRows, ABJ_404_Solution_GscConfig::TRANSIENT_TTL);
63 update_option(ABJ_404_Solution_GscConfig::LAST_FETCH_OPTION_KEY, abj_clock()->now(), false);
64 return $allRows;
65 }
66
67 /**
68 * Fetch GSC data and cache it. Called by cron and background refresh.
69 *
70 * @return void
71 */
72 public function fetchAndCacheGscData(): void {
73 if (!$this->oauthStore->isAuthorized()) {
74 return;
75 }
76
77 if (!$this->fetchLock->claim()) {
78 return;
79 }
80
81 try {
82 $urls = $this->getUrlsToQuery();
83 $fetchResult = $this->doFetchFromApi($urls);
84 if (!$fetchResult['completed']) {
85 return;
86 }
87 $allRows = $fetchResult['rows'];
88 // allow-cache-empty: empty GSC result sets are valid recent fetches and drive the explicit no-data UI state.
89 set_transient(ABJ_404_Solution_GscConfig::TRANSIENT_KEY, $allRows, ABJ_404_Solution_GscConfig::TRANSIENT_TTL);
90 update_option(ABJ_404_Solution_GscConfig::LAST_FETCH_OPTION_KEY, abj_clock()->now(), false);
91 } finally {
92 $this->fetchLock->release();
93 }
94 }
95
96 /**
97 * Get the list of 404 URLs to query from the logs table.
98 *
99 * @return string[]
100 */
101 protected function getUrlsToQuery(): array {
102 $logsRepo = abj_service('logs_repository');
103 return $logsRepo->getDistinctLoggedUrls(
104 self::GSC_URL_PROBE_RECENT_LOG_WINDOW,
105 self::GSC_URL_PROBE_DISTINCT_URL_CAP
106 );
107 }
108
109 /**
110 * Return cached GSC data, or false if the cache is empty.
111 *
112 * @return array<int, array<string, mixed>>|false
113 */
114 public function getCachedData() {
115 $cached = get_transient(ABJ_404_Solution_GscConfig::TRANSIENT_KEY);
116 return $this->normalizeRows($cached);
117 }
118
119 /**
120 * Whether a background refresh should be triggered.
121 *
122 * @return bool
123 */
124 public function isRefreshNeeded(): bool {
125 $lastFetch = get_option(ABJ_404_Solution_GscConfig::LAST_FETCH_OPTION_KEY, 0);
126 $lastFetchTime = is_numeric($lastFetch) ? (int)$lastFetch : 0;
127 return (abj_clock()->now() - $lastFetchTime) > ABJ_404_Solution_GscConfig::STALE_THRESHOLD;
128 }
129
130 /**
131 * Schedule an immediate single-event background refresh via WP-Cron.
132 *
133 * @return void
134 */
135 public function scheduleBackgroundRefresh(): void {
136 $this->fetchLock->initializeAtomicLockMigrationState();
137 if ($this->fetchLock->isHeld()) {
138 return;
139 }
140 abj_cron_scheduler()->scheduleSingleIfMissing(
141 ABJ_404_Solution_GscConfig::BACKGROUND_REFRESH_HOOK
142 );
143 }
144
145 /**
146 * Fetch top 404 URLs that also have GSC search traffic.
147 *
148 * @param string[] $capturedUrls Array of captured 404 URL strings.
149 * @param int $days Number of days for GSC data.
150 * @return array<int, array<string, mixed>>
151 */
152 public function getTrafficDataForCaptured404s(array $capturedUrls, int $days = 90): array {
153 if (empty($capturedUrls)) {
154 return array();
155 }
156 $data = $this->getSearchAnalyticsForUrls($capturedUrls, $days);
157 return array_values(array_filter($data, function ($row) {
158 return isset($row['clicks']) && is_numeric($row['clicks']) && (int)$row['clicks'] > 0;
159 }));
160 }
161
162 /**
163 * Query the GSC Search Analytics API for each URL individually.
164 *
165 * @param string[] $urls Relative or absolute URLs to query.
166 * @param int $days Number of days to look back.
167 * @return array{rows: array<int, array<string, mixed>>, completed: bool}
168 */
169 private function doFetchFromApi(array $urls, int $days = 90): array {
170 $s = $this->oauthStore->getSettings();
171 $token = get_option(ABJ_404_Solution_GscConfig::TOKEN_OPTION_KEY, false);
172 $accessToken = $this->tokenAccessToken($token);
173 if ($accessToken === '') {
174 return array('rows' => array(), 'completed' => true);
175 }
176
177 $siteUrl = $s['site_url'];
178 $now = abj_clock()->now();
179 $endDayIndex = intdiv($now, 86400);
180 $startDayIndex = $endDayIndex - $days;
181 $endDate = gmdate('Y-m-d', $endDayIndex * 86400);
182 $startDate = gmdate('Y-m-d', $startDayIndex * 86400);
183
184 $urls = array_slice($urls, 0, 500);
185 $allRows = array();
186
187 foreach ($urls as $url) {
188 if (!$this->fetchLock->renewIfDue()) {
189 return array('rows' => $allRows, 'completed' => false);
190 }
191 $absoluteUrl = (strpos($url, 'http') === 0) ? $url : rtrim(home_url('/'), '/') . '/' . ltrim($url, '/');
192 $body = array(
193 'startDate' => $startDate,
194 'endDate' => $endDate,
195 'dimensions' => array('page'),
196 'dimensionFilterGroups' => array(
197 array(
198 'filters' => array(
199 array(
200 'dimension' => 'page',
201 'operator' => 'equals',
202 'expression' => $absoluteUrl,
203 ),
204 ),
205 ),
206 ),
207 'rowLimit' => 1000,
208 );
209
210 $encodedSiteUrl = urlencode($siteUrl);
211 $response = wp_remote_post(
212 ABJ_404_Solution_GscConfig::API_BASE_URL . "/sites/{$encodedSiteUrl}/searchAnalytics/query",
213 array(
214 'headers' => array(
215 'Authorization' => 'Bearer ' . $accessToken,
216 'Content-Type' => 'application/json',
217 ),
218 'body' => (string)wp_json_encode($body),
219 'timeout' => 20,
220 )
221 );
222
223 if (is_wp_error($response)) {
224 $this->logger->warn('GSC API transport error: ' . $response->get_error_message());
225 break;
226 }
227
228 $httpCode = (int) wp_remote_retrieve_response_code($response);
229 if ($httpCode !== 200) {
230 $this->logger->warn('GSC API returned HTTP ' . $httpCode . ': ' . wp_remote_retrieve_body($response));
231 break;
232 }
233
234 $data = json_decode(wp_remote_retrieve_body($response), true);
235 if (!is_array($data) || empty($data['rows']) || !is_array($data['rows'])) {
236 continue;
237 }
238
239 foreach ($data['rows'] as $row) {
240 if (!is_array($row)) {
241 continue;
242 }
243 $allRows[] = $this->normalizeApiRow($row);
244 }
245 }
246
247 usort($allRows, function ($a, $b) {
248 return $b['clicks'] - $a['clicks'];
249 });
250
251 return array('rows' => $allRows, 'completed' => true);
252 }
253
254 /**
255 * @param mixed $cached
256 * @return array<int, array<string, mixed>>|false
257 */
258 private function normalizeRows($cached) {
259 if (!is_array($cached)) {
260 return false;
261 }
262 $rows = array();
263 foreach ($cached as $row) {
264 if (is_array($row)) {
265 $normalized = array();
266 foreach ($row as $key => $value) {
267 if (is_string($key)) {
268 $normalized[$key] = $value;
269 }
270 }
271 $rows[] = $normalized;
272 }
273 }
274 return $rows;
275 }
276
277 /** @param mixed $token */
278 private function tokenAccessToken($token): string {
279 if (!is_array($token)) {
280 return '';
281 }
282 $accessToken = $token['access_token'] ?? '';
283 return is_scalar($accessToken) ? (string)$accessToken : '';
284 }
285
286 /**
287 * @param array<mixed, mixed> $row
288 * @return array{url: string, clicks: int, impressions: int, position: float}
289 */
290 private function normalizeApiRow(array $row): array {
291 return array(
292 'url' => $this->rowUrl($row),
293 'clicks' => $this->rowInt($row, 'clicks'),
294 'impressions' => $this->rowInt($row, 'impressions'),
295 'position' => round($this->rowFloat($row, 'position'), 1),
296 );
297 }
298
299 /** @param array<mixed, mixed> $row */
300 private function rowUrl(array $row): string {
301 $keys = $row['keys'] ?? array();
302 if (!is_array($keys)) {
303 return '';
304 }
305 $url = $keys[0] ?? '';
306 return is_scalar($url) ? (string)$url : '';
307 }
308
309 /** @param array<mixed, mixed> $row */
310 private function rowInt(array $row, string $key): int {
311 $value = $row[$key] ?? 0;
312 return is_numeric($value) ? (int)$value : 0;
313 }
314
315 /** @param array<mixed, mixed> $row */
316 private function rowFloat(array $row, string $key): float {
317 $value = $row[$key] ?? 0.0;
318 return is_numeric($value) ? (float)$value : 0.0;
319 }
320 }
321