PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 All 56 releases
← All changes | includes/seo/class-email-report-generator.php +106 -0 2.9.0 → 2.14.1 View file →
@@ -117,8 +117,22 @@
117 117 'is_test' => $is_test,
118 118 'shared' => $shared,
119 119 ];
120 120
121 + // Search Console answered with an error rather than with data.
122 + // Every section is about to come back empty, and the no-data gate
123 + // further down would read that as a property with no traffic: it
124 + // would skip the period and tell the user Search Console "returned
125 + // no data". Record it as the failure it is and retry soon, the way
126 + // a failed send already is. Checked before the hook below so Pro
127 + // does not build an AI summary for a report that cannot go out.
128 + //
129 + // A test send still goes out: it exists to prove delivery.
130 + $fetch_error = self::fetch_error($shared);
131 + if (!$is_test && $fetch_error !== '') {
132 + return $this->handle_fetch_failure($config, $context, $fetch_error);
133 + }
134 +
121 135 /**
122 136 * Fires before the report is rendered/sent. Pro plugin uses
123 137 * this to fetch + attach the AI Highlights summary.
124 138 *
@@ -282,8 +296,100 @@
282 296 'result' => $result,
283 297 'not_connected' => true,
284 298 'reason' => $reason,
285 299 ];
300 + }
301 +
302 + /**
303 + * The error behind a failed data fetch, or '' when the fetch worked.
304 + *
305 + * A fetch fails in two shapes. Email_Report_Data_Provider::fetch()
306 + * catches a thrown API error and returns `available: false` with the
307 + * message in `error`. Analytics_Manager::get_dashboard_data() catches
308 + * its own and returns normally, with the message in `current.error`.
309 + * `available: false` without an `error` is not a failure, so it is not
310 + * reported as one.
311 + *
312 + * @since 2.15.0
313 + *
314 + * @param array $shared The data provider's fetch() result.
315 + * @return string The error message, or '' when nothing failed.
316 + */
317 + private static function fetch_error(array $shared): string {
318 + if (empty($shared['available']) && !empty($shared['error']) && is_scalar($shared['error'])) {
319 + return (string) $shared['error'];
320 + }
321 +
322 + $current = is_array($shared['current'] ?? null) ? $shared['current'] : [];
323 + if (!empty($current['error']) && is_scalar($current['error'])) {
324 + return (string) $current['error'];
325 + }
326 +
327 + return '';
328 + }
329 +
330 + /**
331 + * A scheduled run whose Search Console fetch failed.
332 + *
333 + * Treated like a failed send, not like a period with no data: the
334 + * period's log row is claimed and closed as `failed` with the API's
335 + * message, the next attempt is booked RETRY_DELAY_HOURS out, and once
336 + * MAX_SEND_ATTEMPTS are spent the schedule rejoins the normal cadence.
337 + * The reason and the message are recorded for the panel.
338 + *
339 + * @since 2.15.0
340 + *
341 + * @param array $config Per-site config.
342 + * @param array $context Render context (period bounds).
343 + * @param string $error The fetch error.
344 + * @return array{success:bool,skipped:string,error?:string}
345 + */
346 + private function handle_fetch_failure(array $config, array $context, string $error): array {
347 + $frequency = (int) ($config['frequency_days'] ?? 30);
348 + $message = self::short_error($error);
349 +
350 + $dedupe = $this->record_attempt($config, $context);
351 + if (!$dedupe['inserted']) {
352 + if (!empty($dedupe['exhausted'])) {
353 + $this->config->update_schedule(
354 + $this->config->get()['last_sent_at'] ?? null,
355 + $this->compute_next_run($frequency)
356 + );
357 + $this->config->record_skip('fetch_failed', $message);
358 + return ['success' => false, 'skipped' => 'retry_limit', 'error' => $message];
359 + }
360 + return [
361 + 'success' => false,
362 + 'skipped' => empty($dedupe['write_failed']) ? 'duplicate' : 'log_write_failed',
363 + ];
364 + }
365 +
366 + $this->finalize_log($config, $context, ['success' => false, 'error' => $message]);
367 +
368 + $attempts = (int) ($dedupe['attempts'] ?? 1);
369 + $this->config->update_schedule(
370 + $this->config->get()['last_sent_at'] ?? null,
371 + $attempts >= self::MAX_SEND_ATTEMPTS
372 + ? $this->compute_next_run($frequency)
373 + : $this->compute_retry_run()
374 + );
375 + $this->config->record_skip('fetch_failed', $message);
376 +
377 + return ['success' => false, 'skipped' => 'fetch_failed', 'error' => $message];
378 + }
379 +
380 + /**
381 + * An error message fit for the log row and the panel: plain text, one
382 + * line, at most 300 characters.
383 + *
384 + * @since 2.15.0
385 + *
386 + * @param string $error Raw error message.
387 + * @return string
388 + */
389 + private static function short_error(string $error): string {
390 + $error = trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags($error)));
391 + return mb_strlen($error) > 300 ? rtrim(mb_substr($error, 0, 299)) . '…' : $error;
286 392 }
287 393
288 394 /**
289 395 * Claim this period's send by inserting a `pending` row in