PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.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 / gsc / GscSearchAnalyticsClient.php

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

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