PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.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 2.0.0, at includes/seo/class-auto-ai-optimizer.php

237 lines 8.5 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 // Tell the editor panel a write is coming, *before* queueing it, so a
133 // panel that mounts between publish and the cron tick sees the flag
134 // and knows to wait for the value instead of polling blind (#329).
135 Metadata_Pending::mark($post->ID);
136
137 // WP-Cron collapses identical (hook, args) events scheduled close
138 // together, which de-dupes rapid re-saves.
139 wp_schedule_single_event(time() + 15, self::CRON_HOOK, [$post->ID]);
140 }
141
142 /**
143 * Cron handler: generate and fill the EMPTY metadata fields.
144 *
145 * @param int $post_id Post to optimize.
146 * @return void
147 */
148 public function optimize(int $post_id): void {
149 try {
150 $this->run_optimization($post_id);
151 } finally {
152 // Whatever happened below — skipped, filled, or thrown — no
153 // further write is coming, so the editor panel must stop waiting
154 // for one. Cleared on every path, including exceptions (#329).
155 Metadata_Pending::clear($post_id);
156 }
157 }
158
159 /**
160 * The actual optimization run, wrapped by `optimize()` so the pending
161 * marker is always cleared.
162 *
163 * @param int $post_id Post to optimize.
164 * @return void
165 */
166 private function run_optimization(int $post_id): void {
167 $post = get_post($post_id);
168 if (!$post || 'publish' !== $post->post_status) {
169 return;
170 }
171
172 // Re-check the toggle at run time — it may have been switched off
173 // between queueing and the cron tick.
174 if (!(bool) Settings::instance()->get('auto_ai_meta_enabled', false)) {
175 return;
176 }
177
178 $empty_title = '' === (string) get_post_meta($post_id, '_thinkrank_seo_title', true);
179 $empty_description = '' === (string) get_post_meta($post_id, '_thinkrank_meta_description', true);
180 $empty_keyword = '' === Focus_Keywords::get_primary($post_id);
181
182 if (!$empty_title && !$empty_description && !$empty_keyword) {
183 return;
184 }
185
186 try {
187 // A fresh AI Manager has no client until initialize_client() runs
188 // (the plugin's singleton instance does this on `init`; this cron
189 // context builds its own).
190 $ai = new \ThinkRank\AI\Manager();
191 $ai->initialize_client();
192
193 $generator = new Metadata_Generator($ai);
194 $metadata = $generator->generate_for_post($post_id);
195
196 // Fill ONLY what was empty at run time — never overwrite a human.
197 if ($empty_title && !empty($metadata['title'])) {
198 update_post_meta($post_id, '_thinkrank_seo_title', sanitize_text_field((string) $metadata['title']));
199 }
200 if ($empty_description && !empty($metadata['description'])) {
201 update_post_meta($post_id, '_thinkrank_meta_description', sanitize_text_field((string) $metadata['description']));
202 }
203 if ($empty_keyword && !empty($metadata['focus_keyword'])) {
204 Focus_Keywords::save($post_id, sanitize_text_field((string) $metadata['focus_keyword']));
205 }
206
207 $this->record_last_run($post_id, 'success', '');
208 } catch (\Exception $e) {
209 // One failed post must not break the feature silently — the
210 // settings UI shows this outcome.
211 $this->record_last_run($post_id, 'failed', $e->getMessage());
212 }
213 }
214
215 /**
216 * Persist the last run's outcome for the settings UI.
217 *
218 * @param int $post_id Post processed.
219 * @param string $status 'success' | 'failed'.
220 * @param string $error Error message when failed.
221 * @return void
222 */
223 private function record_last_run(int $post_id, string $status, string $error): void {
224 update_option(
225 self::LAST_RUN_OPTION,
226 [
227 'post_id' => $post_id,
228 'title' => get_the_title($post_id),
229 'status' => $status,
230 'error' => substr($error, 0, 300),
231 'time' => current_time('mysql'),
232 ],
233 false
234 );
235 }
236 }
237