PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.28.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.28.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 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-auto-ai-optimizer.php

class-auto-ai-optimizer.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.28.0, at includes/seo/class-auto-ai-optimizer.php

214 lines 7.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Auto AI optimization — fill missing SEO metadata on publish, automatically.
4 *
5 * ThinkRank's metadata generation has always been on-demand: a human opens the
6 * editor and clicks. High-volume sites (a newswire publishing dozens of
7 * articles a day was the motivating request) never click — their posts go out
8 * with empty SEO titles and descriptions.
9 *
10 * This closes that gap with deliberately conservative rules:
11 *
12 * - opt-in (off by default), per post type
13 * - fires on the transition INTO publish, once, via a queued WP-Cron event —
14 * the publish request itself never waits on an AI call (same out-of-band
15 * pattern as Instant Indexing)
16 * - fills EMPTY fields only (SEO title, meta description, focus keyword); a
17 * human-written value is never overwritten
18 * - uses the site's own configured AI provider key, like every other AI
19 * feature — no key, no-op
20 *
21 * The last run's outcome is stored for the settings UI, so "is it working?"
22 * has an answer without digging through logs.
23 *
24 * @package ThinkRank\SEO
25 * @since 1.27.0
26 */
27
28 declare(strict_types=1);
29
30 namespace ThinkRank\SEO;
31
32 use ThinkRank\AI\Metadata_Generator;
33 use ThinkRank\Core\Settings;
34
35 if (!defined('ABSPATH')) {
36 exit;
37 }
38
39 /**
40 * Queues and runs on-publish metadata generation.
41 */
42 class Auto_Ai_Optimizer {
43
44 /**
45 * Cron hook carrying the post id.
46 */
47 public const CRON_HOOK = 'thinkrank_auto_ai_optimize';
48
49 /**
50 * Option recording the last run's outcome for the settings UI.
51 */
52 public const LAST_RUN_OPTION = 'thinkrank_auto_ai_last_run';
53
54 /**
55 * Wire hooks.
56 *
57 * @return void
58 */
59 public function init(): void {
60 add_action('transition_post_status', [$this, 'maybe_queue'], 10, 3);
61 add_action(self::CRON_HOOK, [$this, 'optimize'], 10, 1);
62 }
63
64 /**
65 * Decide whether a status transition should queue an optimization.
66 *
67 * Pure (settings passed in) so the rules are unit-testable: first
68 * transition into publish only, enabled, post type opted in.
69 *
70 * @param string $new_status New post status.
71 * @param string $old_status Old post status.
72 * @param string $post_type Post type.
73 * @param bool $enabled The auto_ai_meta_enabled setting.
74 * @param string[] $post_types The auto_ai_meta_post_types setting.
75 * @return bool
76 */
77 public static function should_queue(
78 string $new_status,
79 string $old_status,
80 string $post_type,
81 bool $enabled,
82 array $post_types
83 ): bool {
84 if (!$enabled) {
85 return false;
86 }
87 // Pro capability. Checked here rather than only at the settings layer
88 // so a stored `true` from a lapsed licence (or a direct option write)
89 // cannot keep spending the user's AI credits unattended.
90 if (!\ThinkRank\Core\Plan_Config::can('auto_ai_meta', 'ai_visibility')) {
91 return false;
92 }
93 // First publish only: an already-published post being updated has had
94 // its chance at human metadata — never race an editor.
95 if ('publish' !== $new_status || 'publish' === $old_status) {
96 return false;
97 }
98 return in_array($post_type, $post_types, true);
99 }
100
101 /**
102 * transition_post_status listener — queue the out-of-band run.
103 *
104 * @param string $new_status New status.
105 * @param string $old_status Old status.
106 * @param \WP_Post $post Post object.
107 * @return void
108 */
109 public function maybe_queue(string $new_status, string $old_status, $post): void {
110 if (!$post instanceof \WP_Post) {
111 return;
112 }
113
114 $settings = Settings::instance();
115 $enabled = (bool) $settings->get('auto_ai_meta_enabled', false);
116 $post_types = (array) $settings->get('auto_ai_meta_post_types', ['post']);
117
118 if (!self::should_queue($new_status, $old_status, $post->post_type, $enabled, $post_types)) {
119 return;
120 }
121
122 // All target fields already set → nothing to do; skip the cron
123 // round-trip. Only when title, description AND focus keyword are all
124 // present is there nothing left for Auto AI to fill.
125 if ('' !== (string) get_post_meta($post->ID, '_thinkrank_seo_title', true)
126 && '' !== (string) get_post_meta($post->ID, '_thinkrank_meta_description', true)
127 && '' !== Focus_Keywords::get_primary($post->ID)
128 ) {
129 return;
130 }
131
132 // WP-Cron collapses identical (hook, args) events scheduled close
133 // together, which de-dupes rapid re-saves.
134 wp_schedule_single_event(time() + 15, self::CRON_HOOK, [$post->ID]);
135 }
136
137 /**
138 * Cron handler: generate and fill the EMPTY metadata fields.
139 *
140 * @param int $post_id Post to optimize.
141 * @return void
142 */
143 public function optimize(int $post_id): void {
144 $post = get_post($post_id);
145 if (!$post || 'publish' !== $post->post_status) {
146 return;
147 }
148
149 // Re-check the toggle at run time — it may have been switched off
150 // between queueing and the cron tick.
151 if (!(bool) Settings::instance()->get('auto_ai_meta_enabled', false)) {
152 return;
153 }
154
155 $empty_title = '' === (string) get_post_meta($post_id, '_thinkrank_seo_title', true);
156 $empty_description = '' === (string) get_post_meta($post_id, '_thinkrank_meta_description', true);
157 $empty_keyword = '' === Focus_Keywords::get_primary($post_id);
158
159 if (!$empty_title && !$empty_description && !$empty_keyword) {
160 return;
161 }
162
163 try {
164 // A fresh AI Manager has no client until initialize_client() runs
165 // (the plugin's singleton instance does this on `init`; this cron
166 // context builds its own).
167 $ai = new \ThinkRank\AI\Manager();
168 $ai->initialize_client();
169
170 $generator = new Metadata_Generator($ai);
171 $metadata = $generator->generate_for_post($post_id);
172
173 // Fill ONLY what was empty at run time — never overwrite a human.
174 if ($empty_title && !empty($metadata['title'])) {
175 update_post_meta($post_id, '_thinkrank_seo_title', sanitize_text_field((string) $metadata['title']));
176 }
177 if ($empty_description && !empty($metadata['description'])) {
178 update_post_meta($post_id, '_thinkrank_meta_description', sanitize_text_field((string) $metadata['description']));
179 }
180 if ($empty_keyword && !empty($metadata['focus_keyword'])) {
181 Focus_Keywords::save($post_id, sanitize_text_field((string) $metadata['focus_keyword']));
182 }
183
184 $this->record_last_run($post_id, 'success', '');
185 } catch (\Exception $e) {
186 // One failed post must not break the feature silently — the
187 // settings UI shows this outcome.
188 $this->record_last_run($post_id, 'failed', $e->getMessage());
189 }
190 }
191
192 /**
193 * Persist the last run's outcome for the settings UI.
194 *
195 * @param int $post_id Post processed.
196 * @param string $status 'success' | 'failed'.
197 * @param string $error Error message when failed.
198 * @return void
199 */
200 private function record_last_run(int $post_id, string $status, string $error): void {
201 update_option(
202 self::LAST_RUN_OPTION,
203 [
204 'post_id' => $post_id,
205 'title' => get_the_title($post_id),
206 'status' => $status,
207 'error' => substr($error, 0, 300),
208 'time' => current_time('mysql'),
209 ],
210 false
211 );
212 }
213 }
214