PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.78
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.78
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.78, at includes/extensions/AI_SEO_Tools/Post_Generator_Module.php

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