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
thinkrank / includes / seo / class-instant-indexing-manager.php

class-instant-indexing-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.1, at includes/seo/class-instant-indexing-manager.php

901 lines 33.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Instant Indexing Manager Class
4 *
5 * Handles automated submission of URLs to IndexNow API.
6 *
7 * @package ThinkRank
8 * @subpackage SEO
9 * @since 1.1.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\SEO;
15
16 // Prevent direct access.
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 /**
22 * Instant Indexing Manager Class
23 *
24 * Auto-submits URLs to IndexNow when content is updated.
25 *
26 * @since 1.1.0
27 */
28 class Instant_Indexing_Manager {
29 /**
30 * Settings option name
31 *
32 * @var string
33 */
34 private $option_name = 'thinkrank_instant_indexing_settings';
35
36 /**
37 * IndexNow API Endpoint
38 *
39 * @var string
40 */
41 private $api_endpoint = 'https://api.indexnow.org/indexnow';
42
43 /**
44 * Cron hook used to submit URLs to IndexNow out-of-band.
45 *
46 * @var string
47 */
48 private const CRON_SUBMIT_HOOK = 'thinkrank_instant_indexing_submit';
49
50 /**
51 * Maximum URLs accepted per submission across every path (manual REST, bulk,
52 * MCP). Keeps the paths consistent; larger sets are truncated to this cap.
53 */
54 public const MAX_URLS_PER_SUBMISSION = 100;
55
56 /**
57 * Posts that transitioned to publish in this request and await the end of
58 * their save, keyed by ID, with what was true of them before it.
59 *
60 * @var array<int, array{was_published: bool, was_excluded: bool}>
61 */
62 private array $pending_transitions = [];
63
64 /**
65 * Initialize the component
66 *
67 * @since 1.1.0
68 * @return void
69 */
70 public function init(): void {
71 add_action('transition_post_status', [$this, 'handle_post_transition'], 10, 3);
72 add_action('wp_after_insert_post', [$this, 'handle_after_insert_post'], 10, 4);
73 add_action('delete_post', [$this, 'handle_post_deletion'], 10, 2);
74
75 // Serve the IndexNow key file from PHP when no physical file exists.
76 // The key is normally written to the WordPress root, but on managed and
77 // hardened hosting that root is read-only, the write was skipped in
78 // silence, and every submission then came back 403 Forbidden — the one
79 // status IndexNow returns when it cannot read the key at the advertised
80 // keyLocation. Answering the request directly removes the filesystem
81 // from the critical path entirely. A real file on disk still wins: the
82 // web server serves it and this never runs.
83 add_action('parse_request', [$this, 'maybe_serve_key_file']);
84
85 // Automatic submissions run out-of-band via WP-Cron so the editor's
86 // save/publish/delete request never blocks on the IndexNow HTTP call.
87 // Registered unconditionally (WP-Cron runs outside the admin context).
88 add_action(self::CRON_SUBMIT_HOOK, [$this, 'submit_urls_cron'], 10, 1);
89
90 if (is_admin()) {
91 $this->register_bulk_actions_hook();
92 $this->register_handler_hooks();
93 add_action('admin_notices', [$this, 'bulk_action_admin_notice']);
94
95 // Row actions
96 add_filter('post_row_actions', [$this, 'add_row_action_link'], 10, 2);
97 add_filter('page_row_actions', [$this, 'add_row_action_link'], 10, 2);
98 add_action('admin_action_thinkrank_instant_index_single', [$this, 'handle_single_action_submit']);
99 }
100 }
101
102 /**
103 * Serve `<key>.txt` at the site root when no physical file is present.
104 *
105 * IndexNow verifies ownership by fetching the key from `keyLocation` and
106 * comparing it to the key in the payload; anything else is a 403. Writing
107 * that file to ABSPATH fails on read-only roots, so this answers the
108 * request from PHP instead. Runs on `parse_request` (before the main query)
109 * because a missing `.txt` is routed to WordPress by the standard rewrite,
110 * and only matches the site's own current key — never an arbitrary path.
111 *
112 * @since 1.27.0
113 * @param \WP $wp Current WordPress environment instance.
114 * @return void
115 */
116 public function maybe_serve_key_file($wp): void {
117 if (is_admin()) {
118 return;
119 }
120
121 $settings = get_option($this->option_name, []);
122 if (empty($settings['enabled'])) {
123 return;
124 }
125
126 $api_key = (string) ($settings['api_key'] ?? '');
127 // The key is also a filename elsewhere, so it is always a plain hex
128 // token; refuse to match on anything else rather than compare loosely.
129 if (!preg_match('/^[a-f0-9]{8,64}$/', $api_key)) {
130 return;
131 }
132
133 $path = (string) wp_parse_url(
134 isset($_SERVER['REQUEST_URI']) ? esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])) : '',
135 PHP_URL_PATH
136 );
137
138 // Compare against the path of the advertised keyLocation, so a site in
139 // a subdirectory resolves exactly as it is announced to IndexNow.
140 $expected = (string) wp_parse_url(self::key_location($api_key), PHP_URL_PATH);
141 if ($expected === '' || untrailingslashit($path) !== untrailingslashit($expected)) {
142 return;
143 }
144
145 status_header(200);
146 header('Content-Type: text/plain; charset=utf-8');
147 header('X-Robots-Tag: noindex');
148 echo esc_html($api_key);
149 exit;
150 }
151
152 /**
153 * The public URL IndexNow is told to fetch the key from.
154 *
155 * Single source of truth: the submission payload, the settings screen and
156 * the request matcher above all derive from this, so they cannot drift.
157 *
158 * @since 1.27.0
159 * @param string $api_key Verification key.
160 * @return string Absolute key file URL.
161 */
162 public static function key_location(string $api_key): string {
163 // Matches the scheme the submitted URLs go out with, so IndexNow is
164 // never told to verify ownership at an address on the other scheme.
165 return Url_Scheme::apply(home_url('/' . $api_key . '.txt'));
166 }
167
168 /**
169 * Actively verify that the advertised keyLocation is reachable and returns
170 * the key — the general safety net for #247.
171 *
172 * IndexNow only ever answers 403 when it cannot read a matching key at
173 * keyLocation, and that failure is otherwise silent until the first
174 * submission. On a read-only root the physical `<key>.txt` is never written,
175 * and the `parse_request` fallback only fires when the request actually
176 * reaches WordPress — which it does not on an Apache-style host running
177 * Plain permalinks. This does a one-shot loopback fetch of the exact URL we
178 * announce to IndexNow so any unreachable-key configuration (that one
179 * included) is caught on the settings screen instead of at first submit.
180 *
181 * @since 1.28.0
182 * @return array{reachable:bool,code:int,url:string,reason:string}
183 */
184 public function verify_key_reachable(): array {
185 $settings = get_option($this->option_name, []);
186 $api_key = (string) ($settings['api_key'] ?? '');
187 $url = $api_key !== '' ? self::key_location($api_key) : '';
188
189 $result = [
190 'reachable' => false,
191 'code' => 0,
192 'url' => $url,
193 'reason' => '',
194 ];
195
196 if ($api_key === '' || !preg_match('/^[a-f0-9]{8,64}$/', $api_key)) {
197 $result['reason'] = __('No valid IndexNow key is set yet.', 'thinkrank');
198 return $result;
199 }
200
201 // Loopback fetch of our own key URL. sslverify is off because this is a
202 // self-check against this very site (a self-signed/local cert must not
203 // read as "unreachable"), mirroring how WP Site Health runs its loopback
204 // probes.
205 $response = wp_remote_get(
206 $url,
207 [
208 'timeout' => 7,
209 'sslverify' => false,
210 // translators: this is a diagnostic self-request user agent.
211 'user-agent' => 'ThinkRank-IndexNow-KeyCheck/1.0',
212 ]
213 );
214
215 if (is_wp_error($response)) {
216 $result['reason'] = sprintf(
217 /* translators: %s: HTTP error message. */
218 __('Could not reach the key file from this server (%s). Search engines may still reach it; open it in a browser to confirm.', 'thinkrank'),
219 $response->get_error_message()
220 );
221 return $result;
222 }
223
224 $result['code'] = (int) wp_remote_retrieve_response_code($response);
225 $body = trim((string) wp_remote_retrieve_body($response));
226
227 if ($result['code'] === 200 && hash_equals($api_key, $body)) {
228 $result['reachable'] = true;
229 return $result;
230 }
231
232 $result['reason'] = $this->key_unreachable_reason($result['code']);
233 return $result;
234 }
235
236 /**
237 * Build an actionable explanation when the key file is not reachable, naming
238 * the specific #247 combination (read-only root + Plain permalinks) so the
239 * user gets a fix instead of a silent, permanent 403.
240 *
241 * @since 1.28.0
242 * @param int $code HTTP status observed for the key URL (0 when none).
243 * @return string
244 */
245 private function key_unreachable_reason(int $code): string {
246 $root_writable = wp_is_writable(ABSPATH);
247 $pretty = (bool) get_option('permalink_structure');
248
249 // The exact #247 trap: the file can't be written (read-only root) AND
250 // the server only routes unknown paths to WordPress under pretty
251 // permalinks, so the PHP fallback never runs either.
252 if (!$root_writable && !$pretty) {
253 return __('Your site root is read-only (so the key file can’t be written) and permalinks are set to “Plain” (so ThinkRank can’t serve the key dynamically). Fix either one: set Settings → Permalinks to any option other than “Plain”, or make the site root writable.', 'thinkrank');
254 }
255
256 if ($code === 404) {
257 return __('The key file returned 404. If your site root is read-only, set Settings → Permalinks to any option other than “Plain” so ThinkRank can serve the key.', 'thinkrank');
258 }
259
260 return sprintf(
261 /* translators: %d: HTTP status code returned by the key URL. */
262 __('The key file could not be verified (HTTP %d). Open it in a browser: it should show the key and nothing else.', 'thinkrank'),
263 $code
264 );
265 }
266
267 /**
268 * Add Row Action Link
269 *
270 * @since 1.1.0
271 * @param array $actions Existing actions.
272 * @param \WP_Post $post Post object.
273 * @return array Modified actions.
274 */
275 public function add_row_action_link(array $actions, \WP_Post $post): array {
276 // Check if enabled and post type is supported
277 if (!$this->is_enabled() || !$this->is_post_type_supported($post->post_type)) {
278 return $actions;
279 }
280
281 // Check permissions
282 if (!current_user_can('edit_post', $post->ID)) {
283 return $actions;
284 }
285
286 $nonce = wp_create_nonce('thinkrank_instant_index_' . $post->ID);
287 $url = admin_url('admin.php?action=thinkrank_instant_index_single&post_id=' . $post->ID . '&nonce=' . $nonce);
288
289 $actions['thinkrank_instant_index'] = sprintf(
290 '<a href="%s">%s</a>',
291 esc_url($url),
292 esc_html__('ThinkRank: Instant Indexing Submit Page', 'thinkrank')
293 );
294
295 return $actions;
296 }
297
298 /**
299 * Handle Single Post Submission
300 *
301 * @since 1.1.0
302 * @return void
303 */
304 public function handle_single_action_submit(): void {
305 $post_id = isset($_GET['post_id']) ? (int) $_GET['post_id'] : 0;
306 $nonce = isset($_GET['nonce']) ? sanitize_text_field(wp_unslash($_GET['nonce'])) : '';
307
308 // Verify nonce
309 if (!wp_verify_nonce($nonce, 'thinkrank_instant_index_' . $post_id)) {
310 wp_die(esc_html__('Security check failed.', 'thinkrank'));
311 }
312
313 // Check permissions
314 if (!current_user_can('edit_post', $post_id)) {
315 wp_die(esc_html__('You do not have permission to edit this post.', 'thinkrank'));
316 }
317
318 $url = get_permalink($post_id);
319 $redirect_to = wp_get_referer() ?: admin_url('edit.php');
320
321 if ($url) {
322 $result = $this->submit_urls([$url]);
323
324 $redirect_to = add_query_arg([
325 'thinkrank_indexed_count' => 1,
326 'thinkrank_index_status' => $result['success'] ? 'success' : 'failed',
327 ], $redirect_to);
328 }
329
330 wp_safe_redirect($redirect_to);
331 exit;
332 }
333
334 /**
335 * Register Bulk Actions Hook
336 *
337 * @since 1.1.0
338 * @return void
339 */
340 public function register_bulk_actions_hook(): void {
341 add_filter('thinkrank_bulk_actions', [$this, 'add_bulk_action_item'], 10, 2);
342 }
343
344 /**
345 * Add Bulk Action Item to Dropdown
346 *
347 * @since 1.1.0
348 * @param array $actions Existing thinkrank actions.
349 * @param string $post_type Current post type.
350 * @return array Modified actions.
351 */
352 public function add_bulk_action_item(array $actions, string $post_type): array {
353 // Check if enabled and post type is supported
354 if (!$this->is_enabled() || !$this->is_post_type_supported($post_type)) {
355 return $actions;
356 }
357
358 $actions['thinkrank_instant_index'] = __('Instant Indexing: Submit Page', 'thinkrank');
359 return $actions;
360 }
361
362 /**
363 * Register handler hooks for bulk actions
364 *
365 * @since 1.1.0
366 * @return void
367 */
368 private function register_handler_hooks(): void {
369 $settings = get_option($this->option_name, []);
370 $supported_types = $settings['auto_submit_post_types'] ?? [];
371
372 foreach ($supported_types as $post_type) {
373 add_filter("handle_bulk_actions-edit-{$post_type}", [$this, 'handle_bulk_action_submit'], 10, 3);
374 }
375 }
376
377 /**
378 * Handle post status transitions (publish, update)
379 *
380 * @since 1.1.0
381 *
382 * @param string $new_status New post status.
383 * @param string $old_status Old post status.
384 * @param \WP_Post $post Post object.
385 * @return void
386 */
387 public function handle_post_transition(string $new_status, string $old_status, \WP_Post $post): void {
388
389 // Check if we should process this post
390 if (!$this->should_process_post($post)) {
391 return;
392 }
393
394 // Only content that is (still) published is submitted.
395 if ($new_status !== 'publish') {
396 return;
397 }
398
399 // Whether to submit is decided in handle_after_insert_post(), not
400 // here. This hook fires inside wp_insert_post() before the post's meta
401 // is written: the block editor saves the robots override through
402 // REST after the insert, the classic metabox on save_post. Deciding
403 // here read the previous robots value, so a post published with
404 // noindex was submitted (#911).
405 //
406 // What this hook can still see is the previous state, which the
407 // decision needs: a post that was an indexable destination and is no
408 // longer one is submitted once more, so engines recrawl it and drop it.
409 $this->pending_transitions[(int) $post->ID] = [
410 'was_published' => $old_status === 'publish',
411 'was_excluded' => Indexability::is_post_noindexed($post) || Indexability::is_post_redirected($post),
412 ];
413 }
414
415 /**
416 * Submit a published post once its save is complete.
417 *
418 * Runs on `wp_after_insert_post`, which fires after meta and terms are
419 * saved on every path (classic editor, REST, Quick Edit, WP-CLI, and the
420 * scheduled-post publish in wp_publish_post()).
421 *
422 * A post that is not an indexable destination (noindexed by its own
423 * override or its type, password-protected, or redirected) is skipped,
424 * unless it was one before this save: that transition is submitted so
425 * engines recrawl the page and drop it.
426 *
427 * @since 2.15.0
428 *
429 * @param int $post_id Post ID.
430 * @param \WP_Post $post Post object after the save.
431 * @param bool $update Whether this was an update.
432 * @param \WP_Post|null $post_before Post object before the save, null for a new post.
433 * @return void
434 */
435 public function handle_after_insert_post(int $post_id, \WP_Post $post, bool $update, ?\WP_Post $post_before): void {
436 if (!isset($this->pending_transitions[$post_id])) {
437 return;
438 }
439
440 $before = $this->pending_transitions[$post_id];
441 unset($this->pending_transitions[$post_id]);
442
443 if ($post->post_status !== 'publish') {
444 return;
445 }
446
447 $was_indexable = $before['was_published']
448 && !$before['was_excluded']
449 && !($post_before instanceof \WP_Post && Indexability::is_password_protected($post_before));
450
451 // Decided before the dedupe check below, which claims its 15-second
452 // slot as a side effect: a skipped post must not occupy it.
453 if (!Indexability::is_indexable_post($post) && !$was_indexable) {
454 return;
455 }
456
457 $url = get_permalink($post->ID);
458 if (!$url) {
459 return;
460 }
461
462 // Check for duplicate submission using short-lived cache (15 seconds)
463 if ($this->is_recently_submitted_cache($url)) {
464 return;
465 }
466
467 // Defer the outbound IndexNow call to WP-Cron so publishing doesn't
468 // block on a third-party HTTP request.
469 $this->schedule_url_submission([$url]);
470 }
471
472 /**
473 * Check if URL was recently submitted using transient cache
474 *
475 * @since 1.1.0
476 * @param string $url URL to check
477 * @return bool
478 */
479 private function is_recently_submitted_cache(string $url): bool {
480 $cache_key = 'thinkrank_indexing_' . md5($url);
481
482 if (get_transient($cache_key)) {
483 return true;
484 }
485
486 set_transient($cache_key, true, 15); // Cache for 15 seconds
487 return false;
488 }
489
490 /**
491 * Handle post deletion
492 *
493 * @since 1.1.0
494 *
495 * @param int $postid Post ID.
496 * @param \WP_Post $post Post object.
497 * @return void
498 */
499 public function handle_post_deletion(int $postid, \WP_Post $post): void {
500 // Check if we should process this post (even if it's being deleted, we might want to notify,
501 // though IndexNow 'submit' usually implies "please crawl this".
502 // IndexNow documentation says "notify... that a URL and its content has been added, updated, or deleted."
503 // So yes, we submit deleted URLs too if they were public.)
504
505 // For deletion, status might be 'trash' or 'delete', careful with checks.
506 // We only care if it was a supported post type.
507 if (!$this->is_post_type_supported($post->post_type)) {
508 return;
509 }
510
511 // If global setting disabled, abort
512 if (!$this->is_enabled()) {
513 return;
514 }
515
516 $url = get_permalink($postid);
517 if ($url) {
518 // Defer to WP-Cron so the delete request doesn't block on IndexNow.
519 $this->schedule_url_submission([$url]);
520 }
521 }
522
523 /**
524 * Check if a post should be processed
525 *
526 * @since 1.1.0
527 *
528 * @param \WP_Post $post Post object.
529 * @return bool True if should process, false otherwise.
530 */
531 private function should_process_post(\WP_Post $post): bool {
532 // 1. Check global enable switch
533 if (!$this->is_enabled()) {
534 return false;
535 }
536
537 // 2. Check if post type is supported
538 if (!$this->is_post_type_supported($post->post_type)) {
539 return false;
540 }
541
542 // 3. Check autosave/revision
543 if (wp_is_post_autosave((int) $post->ID) || wp_is_post_revision((int) $post->ID)) {
544 return false;
545 }
546
547 return true;
548 }
549
550 /**
551 * Check if feature is enabled globally
552 *
553 * @since 1.1.0
554 * @return bool
555 */
556 private function is_enabled(): bool {
557 $settings = get_option($this->option_name, []);
558 return isset($settings['enabled']) && $settings['enabled'];
559 }
560
561 /**
562 * Check if post type is in settings
563 *
564 * @since 1.1.0
565 * @param string $post_type The post type slug.
566 * @return bool
567 */
568 private function is_post_type_supported(string $post_type): bool {
569 $settings = get_option($this->option_name, []);
570 $supported_types = $settings['auto_submit_post_types'] ?? [];
571
572 return in_array($post_type, (array) $supported_types, true);
573 }
574
575 /**
576 * Submit URLs to IndexNow API
577 *
578 * @since 1.1.0
579 * @param array $urls List of URLs to submit.
580 * @return array Submission results.
581 */
582 public function submit_urls(array $urls): array {
583 if (empty($urls)) {
584 return ['success' => false, 'message' => 'No URLs provided', 'submitted_count' => 0];
585 }
586
587 $host = wp_parse_url(home_url(), PHP_URL_HOST);
588
589 // Only submit URLs on this site's host. IndexNow rejects a urlList whose
590 // entries don't match the declared host (HTTP 422), and the site's key
591 // must not be sent for foreign URLs — enforce it locally on every path.
592 $urls = array_values(array_filter($urls, static function ($u) use ($host) {
593 return strcasecmp((string) wp_parse_url((string) $u, PHP_URL_HOST), (string) $host) === 0;
594 }));
595 if (empty($urls)) {
596 return ['success' => false, 'message' => 'No URLs matched this site host', 'submitted_count' => 0];
597 }
598
599 // Every submission path lands here, so this is where the site's scheme
600 // preference is applied (#638). Submitting http URLs for a site served
601 // over https asks search engines to index an address that redirects,
602 // and it is the canonical mismatch all over again in the one place a
603 // site owner cannot see it happening.
604 $urls = array_values(array_unique(array_map(
605 static function ($u): string {
606 return Url_Scheme::apply((string) $u);
607 },
608 $urls
609 )));
610
611 // Enforce the shared per-submission cap so every path (manual, bulk, MCP)
612 // behaves consistently.
613 if (count($urls) > self::MAX_URLS_PER_SUBMISSION) {
614 $urls = array_slice($urls, 0, self::MAX_URLS_PER_SUBMISSION);
615 }
616
617 $settings = get_option($this->option_name, []);
618 $api_key = $settings['api_key'] ?? '';
619 if (empty($api_key)) {
620 return ['success' => false, 'message' => 'API Key missing', 'submitted_count' => 0];
621 }
622
623 $key_location = self::key_location($api_key);
624
625 $body = [
626 'host' => $host,
627 'key' => $api_key,
628 'keyLocation' => $key_location,
629 'urlList' => $urls
630 ];
631
632 $response = wp_remote_post($this->api_endpoint, [
633 'headers' => [
634 'Content-Type' => 'application/json; charset=utf-8'
635 ],
636 'body' => wp_json_encode($body),
637 'timeout' => 15,
638 'blocking' => true
639 ]);
640
641 // A network-layer failure (timeout, DNS, SSL) returns a WP_Error rather
642 // than an HTTP response — surface its message instead of logging an empty
643 // 0/'' row so the failure is diagnosable in the UI and history.
644 if (is_wp_error($response)) {
645 $status = 'failed';
646 $response_code = 0;
647 $response_message = $response->get_error_message();
648 } else {
649 $response_code = (int) wp_remote_retrieve_response_code($response);
650 $response_message = wp_remote_retrieve_response_message($response);
651 $status = ($response_code >= 200 && $response_code < 300) ? 'success' : 'failed';
652
653 // "403 Forbidden" is IndexNow's answer for exactly one problem —
654 // it could not read a matching key at keyLocation — but the raw
655 // status reads like a permissions error against the API and sent a
656 // customer hunting through the wrong settings for days. Say what it
657 // actually means, and name the URL to check.
658 if ($response_code === 403) {
659 $response_message = sprintf(
660 /* translators: %s: public URL of the IndexNow key file. */
661 __('Key file could not be verified. Search engines must be able to read your key at %s. Open it in a browser: it should show the key and nothing else.', 'thinkrank'),
662 $key_location
663 );
664 }
665 }
666
667 // Log each URL
668 foreach ($urls as $url) {
669 $this->log_submission($url, $status, $response_code, $response_message);
670 }
671
672 return [
673 'success' => $status === 'success',
674 'code' => $response_code,
675 'message' => $response_message,
676 // The count actually sent to IndexNow after same-host filtering and
677 // the per-submission cap, so callers report the real number instead
678 // of the raw input size.
679 'submitted_count' => count($urls),
680 'submitted_urls' => $urls,
681 ];
682 }
683
684 /**
685 * Queue a set of URLs for out-of-band submission to IndexNow.
686 *
687 * Used by the automatic (transition_post_status / delete_post) hooks so the
688 * blocking HTTP call runs on a WP-Cron request instead of the editor's save
689 * request. WP-Cron collapses identical (hook + args) events scheduled close
690 * together, which further de-dupes rapid repeat saves of the same URL.
691 *
692 * @since 1.16.0
693 * @param array $urls List of URLs to submit.
694 * @return void
695 */
696 private function schedule_url_submission(array $urls): void {
697 if (empty($urls)) {
698 return;
699 }
700
701 if (!wp_next_scheduled(self::CRON_SUBMIT_HOOK, [$urls])) {
702 wp_schedule_single_event(time(), self::CRON_SUBMIT_HOOK, [$urls]);
703 }
704 }
705
706 /**
707 * WP-Cron handler: perform the deferred IndexNow submission.
708 *
709 * @since 1.16.0
710 * @param array $urls List of URLs to submit.
711 * @return void
712 */
713 public function submit_urls_cron(array $urls): void {
714 // Re-check the feature is still enabled in case it was turned off between
715 // scheduling and execution.
716 if (!$this->is_enabled()) {
717 return;
718 }
719
720 $this->submit_urls($urls);
721 }
722
723 /**
724 * Log submission to database
725 *
726 * @param string $url URL submitted
727 * @param string $status success/failed
728 * @param int|string $code Response code
729 * @param string $message Response message
730 */
731 private function log_submission(string $url, string $status, $code, string $message): void {
732 global $wpdb;
733 $table_name = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
734
735 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Logging requires direct insert.
736 $wpdb->insert(
737 $table_name,
738 [
739 'url' => $url,
740 'status' => $status,
741 'response_code' => $code,
742 'response_message' => $message,
743 'created_at' => current_time('mysql')
744 ],
745 ['%s', '%s', '%d', '%s', '%s']
746 );
747 }
748
749 /**
750 * Get submission history
751 *
752 * @param int $limit Number of records to retrieve
753 * @return array
754 */
755 public function get_history(int $limit = -1): array {
756 global $wpdb;
757 $table_name = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
758
759 if ($limit === -1) {
760 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from controlled prefix
761 return $wpdb->get_results(
762 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is from $wpdb->prefix.
763 "SELECT * FROM `{$table_name}` ORDER BY created_at DESC",
764 ARRAY_A
765 );
766 }
767
768 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from controlled prefix
769 return $wpdb->get_results(
770 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is from $wpdb->prefix.
771 $wpdb->prepare("SELECT * FROM `{$table_name}` ORDER BY created_at DESC LIMIT %d", $limit),
772 ARRAY_A
773 );
774 }
775
776 /**
777 * Get a bounded page of submission history plus the total row count, so the
778 * History tab paginates server-side instead of fetching the whole table.
779 *
780 * @param int $page 1-based page number.
781 * @param int $per_page Rows per page (clamped 1..100).
782 * @return array{items: array, total: int, page: int, per_page: int}
783 */
784 public function get_history_page(int $page = 1, int $per_page = 10): array {
785 global $wpdb;
786 $table_name = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
787
788 $per_page = max(1, min(100, $per_page));
789 $page = max(1, $page);
790 $offset = ($page - 1) * $per_page;
791
792 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is from $wpdb->prefix.
793 $total = (int) $wpdb->get_var("SELECT COUNT(*) FROM `{$table_name}`");
794
795 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from controlled prefix
796 $items = $wpdb->get_results(
797 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is from $wpdb->prefix.
798 $wpdb->prepare("SELECT * FROM `{$table_name}` ORDER BY created_at DESC LIMIT %d OFFSET %d", $per_page, $offset),
799 ARRAY_A
800 );
801
802 return [
803 'items' => $items ?: [],
804 'total' => $total,
805 'page' => $page,
806 'per_page' => $per_page,
807 ];
808 }
809
810 /**
811 * Clear submission history
812 *
813 * @since 1.1.0
814 * @return bool
815 */
816 public function clear_history(): bool {
817 global $wpdb;
818 $table_name = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
819
820 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from controlled prefix, TRUNCATE requires direct query
821 $result = $wpdb->query("TRUNCATE TABLE `{$table_name}`");
822
823 return $result !== false;
824 }
825
826 /**
827 * Handle Bulk Action Submission
828 *
829 * @since 1.1.0
830 * @param string $redirect_to Redirect URL.
831 * @param string $action Action name.
832 * @param array $post_ids Selected post IDs.
833 * @return string Modified redirect URL.
834 */
835 public function handle_bulk_action_submit(string $redirect_to, string $action, array $post_ids): string {
836 if ($action !== 'thinkrank_instant_index') {
837 return $redirect_to;
838 }
839
840 $urls = [];
841 foreach ($post_ids as $post_id) {
842 $url = get_permalink($post_id);
843 if ($url) {
844 $urls[] = $url;
845 }
846 }
847
848 if (empty($urls)) {
849 return $redirect_to;
850 }
851
852 // Cap the set and defer the outbound call to WP-Cron so a large bulk
853 // selection doesn't block the admin request (parity with the auto path).
854 if (count($urls) > self::MAX_URLS_PER_SUBMISSION) {
855 $urls = array_slice($urls, 0, self::MAX_URLS_PER_SUBMISSION);
856 }
857 $count = count($urls);
858 $this->schedule_url_submission($urls);
859
860 // Add query args for admin notice
861 $redirect_to = add_query_arg([
862 'thinkrank_indexed_count' => $count,
863 'thinkrank_index_status' => 'scheduled',
864 ], $redirect_to);
865
866 return $redirect_to;
867 }
868
869 /**
870 * Display Admin Notice for Bulk Action
871 *
872 * @since 1.1.0
873 * @return void
874 */
875 public function bulk_action_admin_notice(): void {
876 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin notice display reads URL params, not form processing.
877 if (!isset($_GET['thinkrank_indexed_count']) || !isset($_GET['thinkrank_index_status'])) {
878 return;
879 }
880
881 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin notice display reads URL params.
882 $count = (int) $_GET['thinkrank_indexed_count'];
883 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin notice display reads URL params.
884 $status = sanitize_key(wp_unslash($_GET['thinkrank_index_status']));
885
886 if ($status === 'scheduled') {
887 $class = 'notice-success';
888 // translators: %s is the number of URLs queued for IndexNow submission.
889 $message = sprintf(_n('%s URL queued for submission to IndexNow.', '%s URLs queued for submission to IndexNow.', $count, 'thinkrank'), $count);
890 } else {
891 $class = ($status === 'success') ? 'notice-success' : 'notice-error';
892 $message = ($status === 'success')
893 // translators: %s is the number of URLs submitted to IndexNow.
894 ? sprintf(_n('%s URL submitted to IndexNow successfully.', '%s URLs submitted to IndexNow successfully.', $count, 'thinkrank'), $count)
895 : __('Failed to submit URLs to IndexNow.', 'thinkrank');
896 }
897
898 echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($message) . '</p></div>';
899 }
900 }
901