PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.83
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.83
51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 51.1.46 51.1.47 51.1.49 All 37 releases
king-addons / includes / extensions / AI_SEO_Tools / Post_Generator_Module.php

Post_Generator_Module.php in King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder 51.1.83, at includes/extensions/AI_SEO_Tools/Post_Generator_Module.php

560 lines 24.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Post Generator module for AI SEO Tools.
4 *
5 * Generates full blog posts (title, HTML content, excerpt, tags)
6 * via the configured AI provider and optionally generates a matching featured image.
7 *
8 * @package King_Addons
9 */
10
11 namespace King_Addons\AI_SEO_Tools;
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 class Post_Generator_Module
18 {
19 private const BULK_OPTION_PENDING = 'king_addons_ai_seo_post_gen_pending';
20 private const BULK_OPTION_PROGRESS = 'king_addons_ai_seo_post_gen_progress';
21 private const BULK_OPTION_LOCK = 'king_addons_ai_seo_post_gen_lock';
22 private const CRON_HOOK = 'king_addons_ai_seo_post_gen_cron';
23 private const BATCH_SIZE = 1;
24
25 public function __construct()
26 {
27 add_action('wp_ajax_king_addons_ai_seo_start_post_gen', [$this, 'handle_ajax_start']);
28 add_action('wp_ajax_king_addons_ai_seo_get_post_gen_status', [$this, 'handle_ajax_status']);
29 add_action('wp_ajax_king_addons_ai_seo_stop_post_gen', [$this, 'handle_ajax_stop']);
30 add_action(self::CRON_HOOK, [$this, 'process_batch']);
31 }
32
33 public function handle_ajax_start(): void
34 {
35 check_ajax_referer('king_addons_ai_seo_post_gen_start_nonce', 'nonce');
36
37 if (!current_user_can('manage_options')) {
38 wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
39 }
40
41 $ka_ai_opts = get_option('king_addons_ai_options', []);
42 if ('' === \King_Addons\AI_Provider::getApiKey()) {
43 wp_send_json_error(['message' => esc_html__('OpenAI API key is not set. Please add it in AI Settings.', 'king-addons'), 'code' => 'no_api_key'], 400);
44 }
45
46 $description = sanitize_textarea_field(wp_unslash($_POST['description'] ?? ''));
47 $count = max(1, min(50, absint($_POST['count'] ?? 1)));
48 $post_status = in_array($_POST['post_status'] ?? 'draft', ['draft', 'publish'], true)
49 ? sanitize_key(wp_unslash($_POST['post_status']))
50 : 'draft';
51 $length = in_array($_POST['length'] ?? 'medium', ['short', 'medium', 'long'], true)
52 ? sanitize_key(wp_unslash($_POST['length']))
53 : 'medium';
54 $category_raw = sanitize_text_field(wp_unslash($_POST['category_id'] ?? 'auto'));
55 $category_id = ($category_raw === 'auto' || $category_raw === '0') ? $category_raw : (string) absint($category_raw);
56 $gen_image = !empty($_POST['generate_image']) && king_addons_freemius()->can_use_premium_code();
57 $posted_model = sanitize_text_field(wp_unslash($_POST['image_model'] ?? ''));
58 $allowed_models = wp_list_pluck(\King_Addons\AI_Provider::getModelsFor('image'), 'id');
59 $image_model = in_array($posted_model, $allowed_models, true)
60 ? $posted_model
61 : \King_Addons\AI_Provider::getImageModel();
62 $image_quality = sanitize_key(wp_unslash($_POST['image_quality'] ?? 'standard'));
63 $image_size = sanitize_key(wp_unslash($_POST['image_size'] ?? '1024x1024'));
64
65 if ($description === '') {
66 wp_send_json_error(['message' => esc_html__('Please provide a description.', 'king-addons')], 400);
67 }
68
69 wp_clear_scheduled_hook(self::CRON_HOOK);
70 delete_transient(self::BULK_OPTION_LOCK);
71
72 $pending = range(1, $count);
73
74 update_option(self::BULK_OPTION_PENDING, $pending, false);
75 update_option(self::BULK_OPTION_PROGRESS, [
76 'status' => 'running',
77 'total' => $count,
78 'processed' => 0,
79 'last_run' => 0,
80 'started_at' => time(),
81 'errors' => [],
82 'current_item' => null,
83 'last_success' => null,
84 'settings' => [
85 'description' => $description,
86 'post_status' => $post_status,
87 'length' => $length,
88 'category_id' => $category_id,
89 'generate_image' => $gen_image,
90 'image_model' => $image_model,
91 'image_quality' => $image_quality,
92 'image_size' => $image_size,
93 ],
94 ], false);
95
96 wp_schedule_single_event(time(), self::CRON_HOOK);
97
98 wp_send_json_success([
99 'status' => 'running',
100 'total' => $count,
101 'processed' => 0,
102 ]);
103 }
104
105 public function handle_ajax_status(): void
106 {
107 check_ajax_referer('king_addons_ai_seo_post_gen_status_nonce', 'nonce');
108
109 if (!current_user_can('manage_options')) {
110 wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
111 }
112
113 $progress = get_option(self::BULK_OPTION_PROGRESS, ['status' => 'idle']);
114 $progress = $this->maybe_kick($progress);
115
116 wp_send_json_success($progress);
117 }
118
119 public function handle_ajax_stop(): void
120 {
121 check_ajax_referer('king_addons_ai_seo_post_gen_stop_nonce', 'nonce');
122
123 if (!current_user_can('manage_options')) {
124 wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
125 }
126
127 wp_clear_scheduled_hook(self::CRON_HOOK);
128 delete_option(self::BULK_OPTION_PENDING);
129 delete_transient(self::BULK_OPTION_LOCK);
130
131 $progress = get_option(self::BULK_OPTION_PROGRESS, []);
132 $progress['status'] = 'stopped';
133 $progress['current_item'] = null;
134 update_option(self::BULK_OPTION_PROGRESS, $progress, false);
135
136 wp_send_json_success($progress);
137 }
138
139 public function process_batch(): void
140 {
141 if ((bool) get_transient(self::BULK_OPTION_LOCK)) {
142 return;
143 }
144
145 // Extend execution time — image generation can take 60-120s.
146 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
147 @set_time_limit(300);
148 set_transient(self::BULK_OPTION_LOCK, 1, 180);
149
150 $pending = get_option(self::BULK_OPTION_PENDING, []);
151 $progress = get_option(self::BULK_OPTION_PROGRESS, []);
152
153 if (empty($pending) || !is_array($pending) || ($progress['status'] ?? '') !== 'running') {
154 wp_clear_scheduled_hook(self::CRON_HOOK);
155 delete_transient(self::BULK_OPTION_LOCK);
156 return;
157 }
158
159 $settings = (array) ($progress['settings'] ?? []);
160 $batch = array_slice($pending, 0, self::BATCH_SIZE);
161 $errors = [];
162
163 foreach ($batch as $idx) {
164 $idx = (int) $idx;
165
166 // Signal current item so the status poll can show it.
167 $progress['current_item'] = [
168 'id' => $idx,
169 'title' => sprintf(esc_html__('Generating post %d of %d…', 'king-addons'), $idx, $progress['total'] ?? '?'),
170 'prompt' => $this->build_prompt_preview(
171 (string) ($settings['description'] ?? ''),
172 $idx,
173 (string) ($settings['length'] ?? 'medium'),
174 (string) ($settings['category_id'] ?? 'auto')
175 ),
176 ];
177 update_option(self::BULK_OPTION_PROGRESS, $progress, false);
178
179 // 1. Generate content via the configured AI provider.
180 $post_data = $this->generate_post_content(
181 (string) ($settings['description'] ?? ''),
182 $idx,
183 (string) ($settings['length'] ?? 'medium'),
184 (string) ($settings['category_id'] ?? 'auto')
185 );
186 if (is_wp_error($post_data)) {
187 $errors[$idx] = $post_data->get_error_message();
188 continue;
189 }
190
191 // 2. Insert post into WordPress.
192 $post_id = wp_insert_post([
193 'post_title' => $post_data['title'],
194 'post_content' => $post_data['content'],
195 'post_excerpt' => $post_data['excerpt'],
196 'post_status' => $settings['post_status'] ?? 'draft',
197 'post_type' => 'post',
198 ]);
199
200 if (is_wp_error($post_id) || !$post_id) {
201 $errors[$idx] = is_wp_error($post_id)
202 ? $post_id->get_error_message()
203 : esc_html__('Failed to insert post.', 'king-addons');
204 continue;
205 }
206
207 // 3. Assign tags.
208 if (!empty($post_data['tags']) && is_array($post_data['tags'])) {
209 wp_set_post_tags($post_id, $post_data['tags'], false);
210 }
211
212 // 4. Assign category.
213 $cat_id_setting = $settings['category_id'] ?? 'auto';
214 if ($cat_id_setting === 'auto' && !empty($post_data['category'])) {
215 // Find or create the AI-suggested category using core functions (safe in cron context).
216 $cat_name = sanitize_text_field((string) $post_data['category']);
217 $term = term_exists($cat_name, 'category');
218 if ($term) {
219 $resolved = (int) (is_array($term) ? $term['term_id'] : $term);
220 } else {
221 $inserted = wp_insert_term($cat_name, 'category');
222 $resolved = (!is_wp_error($inserted) && isset($inserted['term_id'])) ? (int) $inserted['term_id'] : 0;
223 }
224 if ($resolved > 0) {
225 wp_set_post_categories($post_id, [$resolved], false);
226 }
227 } elseif (is_numeric($cat_id_setting) && (int) $cat_id_setting > 0) {
228 wp_set_post_categories($post_id, [(int) $cat_id_setting], false);
229 }
230
231 // 4. Optionally generate & set featured image.
232 $thumb_url = '';
233 if (!empty($settings['generate_image'])) {
234 $attach_id = $this->generate_and_attach_image(
235 $post_data['title'],
236 (string) ($settings['description'] ?? ''),
237 (string) ($settings['image_model'] ?: \King_Addons\AI_Provider::getImageModel()),
238 (string) ($settings['image_quality'] ?? 'standard'),
239 (string) ($settings['image_size'] ?? '1024x1024'),
240 $post_id
241 );
242 if (!is_wp_error($attach_id)) {
243 set_post_thumbnail($post_id, $attach_id);
244 $thumb_url = (string) wp_get_attachment_image_url($attach_id, 'thumbnail');
245 }
246 }
247
248 $progress['last_success'] = [
249 'id' => $post_id,
250 'title' => $post_data['title'],
251 'result_text' => implode(', ', $post_data['tags'] ?? []),
252 'thumb_url' => $thumb_url,
253 'edit_url' => (string) get_edit_post_link($post_id),
254 ];
255 }
256
257 $remaining = array_slice($pending, count($batch));
258 update_option(self::BULK_OPTION_PENDING, $remaining, false);
259
260 $progress['processed'] = (int) ($progress['processed'] ?? 0) + count($batch);
261 $progress['last_run'] = time();
262 $progress['errors'] = array_slice(array_merge($progress['errors'] ?? [], $errors), -20);
263 $progress['current_item'] = null;
264
265 if (empty($remaining)) {
266 $progress['status'] = 'complete';
267 delete_option(self::BULK_OPTION_PENDING);
268 wp_clear_scheduled_hook(self::CRON_HOOK);
269 } else {
270 $delay = $this->get_bulk_delay();
271 wp_schedule_single_event(time() + $delay, self::CRON_HOOK);
272 }
273
274 update_option(self::BULK_OPTION_PROGRESS, $progress, false);
275 delete_transient(self::BULK_OPTION_LOCK);
276 }
277
278 /**
279 * Call the provider's Chat Completions endpoint to generate post fields as JSON.
280 *
281 * @param string $description User-supplied topic/description.
282 * @param int $post_num Index within the batch (for uniqueness).
283 * @return array|\WP_Error Associative array with keys: title, content, excerpt, tags.
284 */
285 /**
286 * Build the prompt string (same logic as generate_post_content) without making an API call.
287 * Used to expose the current prompt in the status response.
288 */
289 private function build_prompt_preview(string $description, int $post_num, string $length = 'medium', string $category_mode = 'auto'): string
290 {
291 $word_targets = ['short' => 300, 'medium' => 600, 'long' => 1200];
292 $word_count = $word_targets[$length] ?? 600;
293
294 $category_instruction = ($category_mode === 'auto' || $category_mode === '0')
295 ? ' "category" (string — one concise, relevant category name for this post, 1–3 words),'
296 : '';
297
298 $opts_prev = get_option('king_addons_ai_options', []);
299 $lang_enabled_prev = !empty($opts_prev['content_language_custom_enable']);
300 $lang_prev = trim($opts_prev['content_language_custom'] ?? '');
301 $lang_instr = ($lang_enabled_prev && $lang_prev !== '') ? ' Write everything in ' . $lang_prev . '.' : '';
302
303 return 'Generate a high-quality, original WordPress blog post about the following topic: "' . $description . '".' .
304 ' This is post number ' . $post_num . ' in a series — make it unique and distinctly different from other posts on the same topic.' .
305 ' The post content should be approximately ' . $word_count . ' words long.' .
306 $lang_instr .
307 ' Return ONLY a valid JSON object with these exact keys:' .
308 ' "title" (string — a compelling, SEO-friendly headline),' .
309 ' "content" (string — HTML using <p>, <h2>, <h3>, <ul>, <li> tags only, no inline styles, approximately ' . $word_count . ' words),' .
310 ' "excerpt" (string — 1–2 sentence summary),' .
311 ' "tags" (array of 5–8 relevant string tags),' .
312 $category_instruction .
313 ' Important: do NOT wrap the JSON in markdown code blocks. Return raw JSON only.';
314 }
315
316 private function generate_post_content(string $description, int $post_num, string $length = 'medium', string $category_mode = 'auto')
317 {
318 $options = get_option('king_addons_ai_options', []);
319 $api_key = \King_Addons\AI_Provider::getApiKey();
320 $model = \King_Addons\AI_Provider::getTextModel();
321
322 if ($api_key === '') {
323 return new \WP_Error('missing_api_key', sprintf(
324 /* translators: %s: provider name */
325 esc_html__('%s API key is missing.', 'king-addons'),
326 \King_Addons\AI_Provider::getLabel()
327 ));
328 }
329
330 $word_targets = ['short' => 300, 'medium' => 600, 'long' => 1200];
331 $word_count = $word_targets[$length] ?? 600;
332
333 $category_instruction = $category_mode === 'auto'
334 ? ' "category" (string — one concise, relevant category name for this post, 1–3 words),'
335 : '';
336
337 $lang_enabled = !empty($options['content_language_custom_enable']);
338 $lang = trim($options['content_language_custom'] ?? '');
339 $lang_instr = ($lang_enabled && $lang !== '') ? ' Write everything in ' . $lang . '.' : '';
340
341 $prompt =
342 'Generate a high-quality, original WordPress blog post about the following topic: "' . $description . '".' .
343 ' This is post number ' . $post_num . ' in a series — make it unique and distinctly different from other posts on the same topic.' .
344 ' The post content should be approximately ' . $word_count . ' words long.' .
345 $lang_instr .
346 ' Return ONLY a valid JSON object with these exact keys:' .
347 ' "title" (string — a compelling, SEO-friendly headline),' .
348 ' "content" (string — HTML using <p>, <h2>, <h3>, <ul>, <li> tags only, no inline styles, approximately ' . $word_count . ' words),' .
349 ' "excerpt" (string — 1–2 sentence summary),' .
350 ' "tags" (array of 5–8 relevant string tags),' .
351 $category_instruction .
352 ' Important: do NOT wrap the JSON in markdown code blocks. Return raw JSON only.';
353
354 $max_tokens_map = ['short' => 700, 'medium' => 1200, 'long' => 2400];
355 $max_tokens = $max_tokens_map[$length] ?? 1200;
356
357 $response = wp_remote_post(\King_Addons\AI_Provider::getChatEndpoint(), [
358 'headers' => \King_Addons\AI_Provider::getHeaders(),
359 'body' => wp_json_encode(\King_Addons\AI_Provider::prepareChatPayload([
360 'model' => $model,
361 'messages' => [['role' => 'user', 'content' => $prompt]],
362 'max_tokens' => $max_tokens,
363 ])),
364 'timeout' => 120,
365 'data_format' => 'body',
366 ]);
367
368 if (is_wp_error($response)) {
369 return $response;
370 }
371
372 $code = wp_remote_retrieve_response_code($response);
373 $body = json_decode(wp_remote_retrieve_body($response), true);
374
375 if ($code !== 200 || empty($body['choices'][0]['message']['content'])) {
376 $api_error = \King_Addons\AI_Provider::extractErrorMessage($body, esc_html__('API request failed.', 'king-addons'));
377 return new \WP_Error('api_error', $api_error);
378 }
379
380 $raw = trim((string) $body['choices'][0]['message']['content']);
381
382 // Strip potential markdown code fences that some models add despite instructions.
383 $raw = (string) preg_replace('/^```(?:json)?\s*/i', '', $raw);
384 $raw = (string) preg_replace('/\s*```$/m', '', $raw);
385
386 $data = json_decode($raw, true);
387 if (!is_array($data) || empty($data['title']) || empty($data['content'])) {
388 return new \WP_Error('parse_error', esc_html__('Could not parse AI response as JSON.', 'king-addons'));
389 }
390
391 return [
392 'title' => sanitize_text_field((string) $data['title']),
393 'content' => wp_kses_post((string) $data['content']),
394 'excerpt' => sanitize_textarea_field((string) ($data['excerpt'] ?? '')),
395 'tags' => is_array($data['tags'])
396 ? array_map('sanitize_text_field', $data['tags'])
397 : [],
398 'category' => sanitize_text_field((string) ($data['category'] ?? '')),
399 ];
400 }
401
402 /**
403 * Generate an image via the provider's Images API and attach it to a post.
404 *
405 * @param string $title Post title (used as image prompt context).
406 * @param string $description Overall topic description.
407 * @param string $model Image model id of the configured AI provider.
408 * @param string $quality Quality setting for the selected model.
409 * @param string $size Image dimensions string.
410 * @param int $post_id Post to attach the image to.
411 * @return int|\WP_Error Attachment ID on success, WP_Error on failure.
412 */
413 private function generate_and_attach_image(string $title, string $description, string $model, string $quality, string $size, int $post_id)
414 {
415 $api_key = \King_Addons\AI_Provider::getApiKey();
416
417 if ($api_key === '') {
418 return new \WP_Error('missing_api_key', sprintf(
419 /* translators: %s: provider name */
420 esc_html__('%s API key is missing.', 'king-addons'),
421 \King_Addons\AI_Provider::getLabel()
422 ));
423 }
424
425 $prompt = 'Professional blog featured image for an article titled: "' . $title . '". Topic: ' . $description . '. Photorealistic style, no text overlays, no watermarks.';
426
427 // OpenAI's image models take vendor specific size/quality options;
428 // OpenRouter fans out to many vendors that do not share them.
429 $body = ['model' => $model, 'prompt' => $prompt];
430
431 if (\King_Addons\AI_Provider::isOpenRouter()) {
432 $body['n'] = 1;
433 } else {
434 $body['size'] = $size;
435
436 if ($model === 'dall-e-3') {
437 $body['n'] = 1;
438 $body['quality'] = ($quality === 'hd') ? 'hd' : 'standard';
439 } elseif ($model === 'gpt-image-1') {
440 $body['quality'] = in_array($quality, ['low', 'medium', 'high', 'auto'], true) ? $quality : 'auto';
441 }
442 }
443
444 $data = \King_Addons\AI_Provider::decodeResponse(
445 wp_remote_post(\King_Addons\AI_Provider::getImagesEndpoint(), [
446 'headers' => \King_Addons\AI_Provider::getHeaders(),
447 'body' => wp_json_encode($body),
448 'timeout' => 120,
449 ]),
450 esc_html__('Image generation failed.', 'king-addons')
451 );
452
453 if (is_wp_error($data)) {
454 return $data;
455 }
456
457 require_once ABSPATH . 'wp-admin/includes/image.php';
458 require_once ABSPATH . 'wp-admin/includes/file.php';
459 require_once ABSPATH . 'wp-admin/includes/media.php';
460
461 // An image comes back either as a hosted URL or as inline base64.
462 $item = (isset($data['data'][0]) && is_array($data['data'][0])) ? $data['data'][0] : [];
463 $base64 = '';
464 $remote_url = '';
465 $mime = 'image/png';
466
467 if (!empty($item['b64_json']) && is_string($item['b64_json'])) {
468 $base64 = $item['b64_json'];
469 if (!empty($item['media_type']) && is_string($item['media_type'])) {
470 $mime = $item['media_type'];
471 }
472 } elseif (!empty($item['url']) && is_string($item['url'])) {
473 $remote_url = $item['url'];
474 } elseif (!empty($item['image_url']['url']) && is_string($item['image_url']['url'])) {
475 $remote_url = $item['image_url']['url'];
476 }
477
478 if ($remote_url !== '' && strpos($remote_url, 'data:') === 0 && preg_match('#^data:([^;,]+);base64,(.+)$#s', $remote_url, $matches)) {
479 $mime = $matches[1];
480 $base64 = $matches[2];
481 $remote_url = '';
482 }
483
484 if ($base64 === '' && $remote_url === '') {
485 return new \WP_Error('api_error', esc_html__('No image data returned.', 'king-addons'));
486 }
487
488 if ($base64 !== '') {
489 $bytes = base64_decode($base64, true);
490 if (!$bytes) {
491 return new \WP_Error('decode_error', esc_html__('Failed to decode image data.', 'king-addons'));
492 }
493
494 $type_map = ['image/jpeg' => 'jpg', 'image/jpg' => 'jpg', 'image/webp' => 'webp', 'image/gif' => 'gif'];
495 $extension = $type_map[$mime] ?? 'png';
496 $name = substr(sanitize_file_name($title), 0, 80);
497 if ($name === '') {
498 $name = 'postgen';
499 }
500
501 $tmp = wp_tempnam($name . '.' . $extension);
502 if (!$tmp || !file_put_contents($tmp, $bytes)) {
503 return new \WP_Error('write_error', esc_html__('Failed to write temp image file.', 'king-addons'));
504 }
505
506 return media_handle_sideload([
507 'name' => $name . '.' . $extension,
508 'tmp_name' => $tmp,
509 ], $post_id, $title);
510 }
511
512 return media_sideload_image(esc_url_raw($remote_url), $post_id, $title, 'id');
513 }
514
515 /**
516 * Kick the batch processor if cron missed its schedule or went stale.
517 * Works even when DISABLE_WP_CRON is set or cron misfires in local dev environments.
518 *
519 * @param array $progress Current progress array.
520 * @return array Possibly-refreshed progress array.
521 */
522 private function maybe_kick(array $progress): array
523 {
524 if (($progress['status'] ?? 'idle') !== 'running') {
525 return $progress;
526 }
527
528 $lock_held = (bool) get_transient(self::BULK_OPTION_LOCK);
529 $last = (int) ($progress['last_run'] ?? 0);
530 $started_at = (int) ($progress['started_at'] ?? 0);
531 $now = time();
532
533 // Lock age: how long ago the current lock was set (approximated via last_run or started_at).
534 $lock_age = $last > 0 ? ($now - $last) : ($started_at > 0 ? ($now - $started_at) : 999);
535
536 // Force-clear a lock that has clearly outlived any legitimate run (>200s = beyond the 180s TTL + buffer).
537 if ($lock_held && $lock_age > 200) {
538 delete_transient(self::BULK_OPTION_LOCK);
539 $lock_held = false;
540 }
541
542 if ($lock_held) {
543 // A batch is legitimately in progress — do nothing.
544 return $progress;
545 }
546
547 // No lock is held. Run the next batch directly from this AJAX request.
548 // This makes progress independent of WP Cron firing (works on Local, staging, etc.).
549 $this->process_batch();
550 return (array) get_option(self::BULK_OPTION_PROGRESS, $progress);
551 }
552
553 private function get_bulk_delay(): int
554 {
555 $options = get_option('king_addons_ai_options', []);
556 $delay = isset($options['ai_seo_bulk_processing_delay']) ? (int) $options['ai_seo_bulk_processing_delay'] : 3;
557 return max(1, min(30, $delay));
558 }
559 }
560