PluginProbe
BotWriter – AI Writer & SEO Content Generator / 3.2.8
BotWriter – AI Writer & SEO Content Generator v3.2.8
3.4.10 3.4.9 3.4.8 3.4.7 3.4.6 3.4.4 3.4.2 3.4.1 3.4.0 3.3.9 3.3.8 3.3.7 3.3.6 3.3.5 3.3.4 3.3.3 3.3.2 3.3.1 3.3.0 3.2.8 trunk 1.3.0 1.3.1 1.3.2 1.3.3 All 51 releases
botwriter / includes / functions.php

functions.php in BotWriter – AI Writer & SEO Content Generator 3.2.8, at includes/functions.php

1,263 lines 44.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 exit;
4 }
5
6 // varibales globales de opciones
7 $botwriter_countries = [
8 'af' => 'Afghanistan',
9 'al' => 'Albania',
10 'dz' => 'Algeria',
11 'as' => 'American Samoa',
12 'ad' => 'Andorra',
13 'ao' => 'Angola',
14 'ai' => 'Anguilla',
15 'aq' => 'Antarctica',
16 'ag' => 'Antigua and Barbuda',
17 'ar' => 'Argentina',
18 'am' => 'Armenia',
19 'aw' => 'Aruba',
20 'au' => 'Australia',
21 'at' => 'Austria',
22 'az' => 'Azerbaijan',
23 'bs' => 'Bahamas',
24 'bh' => 'Bahrain',
25 'bd' => 'Bangladesh',
26 'bb' => 'Barbados',
27 'by' => 'Belarus',
28 'be' => 'Belgium',
29 'bz' => 'Belize',
30 'bj' => 'Benin',
31 'bm' => 'Bermuda',
32 'bt' => 'Bhutan',
33 'bo' => 'Bolivia',
34 'ba' => 'Bosnia and Herzegovina',
35 'bw' => 'Botswana',
36 'bv' => 'Bouvet Island',
37 'br' => 'Brazil',
38 'io' => 'British Indian Ocean Territory',
39 'bn' => 'Brunei Darussalam',
40 'bg' => 'Bulgaria',
41 'bf' => 'Burkina Faso',
42 'bi' => 'Burundi',
43 'kh' => 'Cambodia',
44 'cm' => 'Cameroon',
45 'ca' => 'Canada',
46 'cv' => 'Cape Verde',
47 'ky' => 'Cayman Islands',
48 'cf' => 'Central African Republic',
49 'td' => 'Chad',
50 'cl' => 'Chile',
51 'cn' => 'China',
52 'cx' => 'Christmas Island',
53 'cc' => 'Cocos (Keeling) Islands',
54 'co' => 'Colombia',
55 'km' => 'Comoros',
56 'cg' => 'Congo',
57 'cd' => 'Congo, the Democratic Republic of the',
58 'ck' => 'Cook Islands',
59 'cr' => 'Costa Rica',
60 'ci' => 'Cote Divoire',
61 'hr' => 'Croatia',
62 'cu' => 'Cuba',
63 'cy' => 'Cyprus',
64 'cz' => 'Czech Republic',
65 'dk' => 'Denmark',
66 'dj' => 'Djibouti',
67 'dm' => 'Dominica',
68 'do' => 'Dominican Republic',
69 'ec' => 'Ecuador',
70 'eg' => 'Egypt',
71 'sv' => 'El Salvador',
72 'gq' => 'Equatorial Guinea',
73 'er' => 'Eritrea',
74 'ee' => 'Estonia',
75 'et' => 'Ethiopia',
76 'fk' => 'Falkland Islands (Malvinas)',
77 'fo' => 'Faroe Islands',
78 'fj' => 'Fiji',
79 'fi' => 'Finland',
80 'fr' => 'France',
81 'gf' => 'French Guiana',
82 'pf' => 'French Polynesia',
83 'tf' => 'French Southern Territories',
84 'ga' => 'Gabon',
85 'gm' => 'Gambia',
86 'ge' => 'Georgia',
87 'de' => 'Germany',
88 'gh' => 'Ghana',
89 'gi' => 'Gibraltar',
90 'gr' => 'Greece',
91 'gl' => 'Greenland',
92 'gd' => 'Grenada',
93 'gp' => 'Guadeloupe',
94 'gu' => 'Guam',
95 'gt' => 'Guatemala',
96 'gn' => 'Guinea',
97 'gw' => 'Guinea-Bissau',
98 'gy' => 'Guyana',
99 'ht' => 'Haiti',
100 'hm' => 'Heard Island and Mcdonald Islands',
101 'va' => 'Holy See (Vatican City State)',
102 'hn' => 'Honduras',
103 'hk' => 'Hong Kong',
104 'hu' => 'Hungary',
105 'is' => 'Iceland',
106 'in' => 'India',
107 'id' => 'Indonesia',
108 'ir' => 'Iran, Islamic Republic of',
109 'iq' => 'Iraq',
110 'ie' => 'Ireland',
111 'il' => 'Israel',
112 'it' => 'Italy',
113 'jm' => 'Jamaica',
114 'jp' => 'Japan',
115 'jo' => 'Jordan',
116 'kz' => 'Kazakhstan',
117 'ke' => 'Kenya',
118 'ki' => 'Kiribati',
119 'kp' => 'North Korea',
120 'kr' => 'South Korea',
121 'kw' => 'Kuwait',
122 'kg' => 'Kyrgyzstan',
123 'la' => 'Lao Peoples Democratic Republic',
124 'lv' => 'Latvia',
125 'lb' => 'Lebanon',
126 'ls' => 'Lesotho',
127 'lr' => 'Liberia',
128 'ly' => 'Libya',
129 'li' => 'Liechtenstein',
130 'lt' => 'Lithuania',
131 'lu' => 'Luxembourg',
132 'mo' => 'Macao',
133 'mk' => 'North Macedonia',
134 'mg' => 'Madagascar',
135 'mw' => 'Malawi',
136 'my' => 'Malaysia',
137 'mv' => 'Maldives',
138 'ml' => 'Mali',
139 'mt' => 'Malta',
140 'mh' => 'Marshall Islands',
141 'mq' => 'Martinique',
142 'mr' => 'Mauritania',
143 'mu' => 'Mauritius',
144 'yt' => 'Mayotte',
145 'mx' => 'Mexico',
146 'fm' => 'Micronesia, Federated States of',
147 'md' => 'Moldova, Republic of',
148 'mc' => 'Monaco',
149 'mn' => 'Mongolia',
150 'ms' => 'Montserrat',
151 'ma' => 'Morocco',
152 'mz' => 'Mozambique',
153 'mm' => 'Myanmar',
154 'na' => 'Namibia',
155 'nr' => 'Nauru',
156 'np' => 'Nepal',
157 'nl' => 'Netherlands',
158 'nc' => 'New Caledonia',
159 'nz' => 'New Zealand',
160 'ni' => 'Nicaragua',
161 'ne' => 'Niger',
162 'ng' => 'Nigeria',
163 'nu' => 'Niue',
164 'nf' => 'Norfolk Island',
165 'mp' => 'Northern Mariana Islands',
166 'no' => 'Norway',
167 'om' => 'Oman',
168 'pk' => 'Pakistan',
169 'pw' => 'Palau',
170 'ps' => 'Palestinian Territory, Occupied',
171 'pa' => 'Panama',
172 'pg' => 'Papua New Guinea',
173 'py' => 'Paraguay',
174 'pe' => 'Peru',
175 'ph' => 'Philippines',
176 'pn' => 'Pitcairn',
177 'pl' => 'Poland',
178 'pt' => 'Portugal',
179 'pr' => 'Puerto Rico',
180 'qa' => 'Qatar',
181 're' => 'Reunion',
182 'ro' => 'Romania',
183 'ru' => 'Russian Federation',
184 'rw' => 'Rwanda',
185 'sh' => 'Saint Helena',
186 'kn' => 'Saint Kitts and Nevis',
187 'lc' => 'Saint Lucia',
188 'pm' => 'Saint Pierre and Miquelon',
189 'vc' => 'Saint Vincent and the Grenadines',
190 'ws' => 'Samoa',
191 'sm' => 'San Marino',
192 'st' => 'Sao Tome and Principe',
193 'sa' => 'Saudi Arabia',
194 'sn' => 'Senegal',
195 'rs' => 'Serbia and Montenegro',
196 'sc' => 'Seychelles',
197 'sl' => 'Sierra Leone',
198 'sg' => 'Singapore',
199 'sk' => 'Slovakia',
200 'si' => 'Slovenia',
201 'sb' => 'Solomon Islands',
202 'so' => 'Somalia',
203 'za' => 'South Africa',
204 'gs' => 'South Georgia and the South Sandwich Islands',
205 'es' => 'Spain',
206 'lk' => 'Sri Lanka',
207 'sd' => 'Sudan',
208 'sr' => 'Suriname',
209 'sj' => 'Svalbard and Jan Mayen',
210 'sz' => 'Swaziland',
211 'se' => 'Sweden',
212 'ch' => 'Switzerland',
213 'sy' => 'Syrian Arab Republic',
214 'tw' => 'Taiwan',
215 'tj' => 'Tajikistan',
216 'tz' => 'Tanzania, United Republic of',
217 'th' => 'Thailand',
218 'tl' => 'Timor-Leste',
219 'tg' => 'Togo',
220 'tk' => 'Tokelau',
221 'to' => 'Tonga',
222 'tt' => 'Trinidad and Tobago',
223 'tn' => 'Tunisia',
224 'tr' => 'Turkey',
225 'tm' => 'Turkmenistan',
226 'tc' => 'Turks and Caicos Islands',
227 'tv' => 'Tuvalu',
228 'ug' => 'Uganda',
229 'ua' => 'Ukraine',
230 'ae' => 'United Arab Emirates',
231 'uk' => 'United Kingdom',
232 'gb' => 'United Kingdom',
233 'us' => 'United States',
234 'um' => 'United States Minor Outlying Islands',
235 'uy' => 'Uruguay',
236 'uz' => 'Uzbekistan',
237 'vu' => 'Vanuatu',
238 've' => 'Venezuela',
239 'vn' => 'Viet Nam',
240 'vg' => 'Virgin Islands, British',
241 'vi' => 'Virgin Islands, U.S.',
242 'wf' => 'Wallis and Futuna',
243 'eh' => 'Western Sahara',
244 'ye' => 'Yemen',
245 'zm' => 'Zambia',
246 'zw' => 'Zimbabwe',
247 'gg' => 'Guernsey',
248 'je' => 'Jersey',
249 'im' => 'Isle of Man',
250 'me' => 'Montenegro',
251 ];
252
253 $botwriter_languages = [
254 'any' => 'Any Language',
255 'af' => 'Afrikaans',
256 'sq' => 'Albanian',
257 'am' => 'Amharic',
258 'ar' => 'Arabic',
259 'hy' => 'Armenian',
260 'az' => 'Azerbaijani',
261 'eu' => 'Basque',
262 'be' => 'Belarusian',
263 'bn' => 'Bengali',
264 'bs' => 'Bosnian',
265 'bg' => 'Bulgarian',
266 'ca' => 'Catalan',
267 'ceb' => 'Cebuano',
268 'ny' => 'Chichewa',
269 'zh-CN' => 'Chinese',
270 'co' => 'Corsican',
271 'hr' => 'Croatian',
272 'cs' => 'Czech',
273 'da' => 'Danish',
274 'nl' => 'Dutch',
275 'en' => 'English',
276 'eo' => 'Esperanto',
277 'et' => 'Estonian',
278 'tl' => 'Filipino',
279 'fi' => 'Finnish',
280 'fr' => 'French',
281 'fy' => 'Frisian',
282 'gl' => 'Galician',
283 'ka' => 'Georgian',
284 'de' => 'German',
285 'el' => 'Greek',
286 'gu' => 'Gujarati',
287 'ht' => 'Haitian Creole',
288 'ha' => 'Hausa',
289 'haw' => 'Hawaiian',
290 'iw' => 'Hebrew',
291 'hi' => 'Hindi',
292 'hmn' => 'Hmong',
293 'hu' => 'Hungarian',
294 'is' => 'Icelandic',
295 'ig' => 'Igbo',
296 'id' => 'Indonesian',
297 'ga' => 'Irish',
298 'it' => 'Italian',
299 'ja' => 'Japanese',
300 'jw' => 'Javanese',
301 'kn' => 'Kannada',
302 'kk' => 'Kazakh',
303 'km' => 'Khmer',
304 'ko' => 'Korean',
305 'ku' => 'Kurdish (Kurmanji)',
306 'ky' => 'Kyrgyz',
307 'lo' => 'Lao',
308 'la' => 'Latin',
309 'lv' => 'Latvian',
310 'lt' => 'Lithuanian',
311 'lb' => 'Luxembourgish',
312 'mk' => 'Macedonian',
313 'mg' => 'Malagasy',
314 'ms' => 'Malay',
315 'ml' => 'Malayalam',
316 'mt' => 'Maltese',
317 'mi' => 'Maori',
318 'mr' => 'Marathi',
319 'mn' => 'Mongolian',
320 'my' => 'Myanmar (Burmese)',
321 'ne' => 'Nepali',
322 'no' => 'Norwegian',
323 'ps' => 'Pashto',
324 'fa' => 'Persian',
325 'pl' => 'Polish',
326 'pt' => 'Portuguese',
327 'pa' => 'Punjabi',
328 'ro' => 'Romanian',
329 'ru' => 'Russian',
330 'sm' => 'Samoan',
331 'gd' => 'Scots Gaelic',
332 'sr' => 'Serbian',
333 'st' => 'Sesotho',
334 'sn' => 'Shona',
335 'sd' => 'Sindhi',
336 'si' => 'Sinhala',
337 'sk' => 'Slovak',
338 'sl' => 'Slovenian',
339 'so' => 'Somali',
340 'es' => 'Spanish',
341 'su' => 'Sundanese',
342 'sw' => 'Swahili',
343 'sv' => 'Swedish',
344 'tg' => 'Tajik',
345 'ta' => 'Tamil',
346 'te' => 'Telugu',
347 'th' => 'Thai',
348 'tr' => 'Turkish',
349 'uk' => 'Ukrainian',
350 'ur' => 'Urdu',
351 'uz' => 'Uzbek',
352 'vi' => 'Vietnamese',
353 'cy' => 'Welsh',
354 'xh' => 'Xhosa',
355 'yi' => 'Yiddish',
356 'yo' => 'Yoruba',
357 'zu' => 'Zulu',
358 ];
359
360 /**
361 * Get list of available text AI providers
362 */
363 function botwriter_get_text_providers() {
364 return [
365 'openai' => 'OpenAI (GPT-4o, GPT-4.1)',
366 'anthropic' => 'Anthropic (Claude)',
367 'google' => 'Google (Gemini)',
368 'mistral' => 'Mistral AI',
369 'groq' => 'Groq (Llama, Mixtral)',
370 'openrouter' => 'OpenRouter (Multiple)',
371 ];
372 }
373
374 /**
375 * Get list of available image AI providers
376 */
377 function botwriter_get_image_providers() {
378 return [
379 'dalle' => 'DALL-E (OpenAI)',
380 'gemini' => 'Google Gemini',
381 'fal' => 'Fal.ai (Flux)',
382 'replicate' => 'Replicate',
383 'stability' => 'Stability AI',
384 'cloudflare' => 'Cloudflare AI',
385 ];
386 }
387
388 /**
389 * Get the current text model based on selected provider
390 */
391 function botwriter_get_current_text_model() {
392 $provider = get_option('botwriter_text_provider', 'openai');
393 $defaults = [
394 'openai' => 'gpt-5.4-mini',
395 'anthropic' => 'claude-sonnet-4-6',
396 'google' => 'gemini-2.5-flash',
397 'mistral' => 'mistral-large-latest',
398 'groq' => 'llama-3.3-70b-versatile',
399 'openrouter' => 'anthropic/claude-sonnet-4.6',
400 ];
401 return get_option("botwriter_{$provider}_model", $defaults[$provider] ?? 'gpt-5.4-mini');
402 }
403
404 /**
405 * Get the current image model based on selected provider
406 */
407 function botwriter_get_current_image_model() {
408 $provider = get_option('botwriter_image_provider', 'dalle');
409 $defaults = [
410 'dalle' => 'gpt-image-1',
411 'fal' => 'fal-ai/flux-pro/v1.1',
412 'replicate' => 'black-forest-labs/flux-1.1-pro',
413 'stability' => 'sd3-large-turbo',
414 'ideogram' => 'V_2_TURBO',
415 ];
416 return get_option("botwriter_{$provider}_model", $defaults[$provider] ?? 'gpt-image-1');
417 }
418
419 /**
420 * Get the API key for a specific provider
421 */
422 function botwriter_get_provider_api_key($provider) {
423 $key = get_option("botwriter_{$provider}_api_key", '');
424 if (!empty($key)) {
425 return botwriter_decrypt_api_key($key);
426 }
427 return '';
428 }
429
430 /**
431 * Count active BotWriter tasks (status = 1).
432 *
433 * @return int
434 */
435 function botwriter_get_enabled_tasks_count() {
436 static $enabled_tasks_count = null;
437
438 if ($enabled_tasks_count !== null) {
439 return $enabled_tasks_count;
440 }
441
442 global $wpdb;
443 $table_name = $wpdb->prefix . 'botwriter_tasks';
444
445 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table_name));
446 if ($table_exists !== $table_name) {
447 $enabled_tasks_count = 0;
448 return $enabled_tasks_count;
449 }
450
451 $enabled_tasks_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table_name} WHERE status = 1");
452 return $enabled_tasks_count;
453 }
454
455 /**
456 * Whether we should warn that WP-Cron is disabled while BotWriter has active tasks.
457 *
458 * @return bool
459 */
460 function botwriter_should_show_wp_cron_disabled_warning() {
461 $wp_cron_disabled = defined('DISABLE_WP_CRON') ? (bool) constant('DISABLE_WP_CRON') : false;
462 if (!$wp_cron_disabled) {
463 return false;
464 }
465
466 if (get_option('botwriter_cron_active', '1') !== '1') {
467 return false;
468 }
469
470 return botwriter_get_enabled_tasks_count() > 0;
471 }
472
473
474 /**
475 * SEO Slug Translation
476 *
477 * Translates post title, tags and image filename into SEO-friendly
478 * slugs in the target language using the configured AI text provider.
479 *
480 * @param string $title The post title to translate
481 * @param string $tags Comma-separated tags (optional)
482 * @param string $language Target language code (e.g., 'en')
483 * @return array|false Array with 'post_slug', 'tag_slugs', 'image_slug' or false on failure
484 */
485 function botwriter_translate_slugs($title, $tags = '', $language = 'en') {
486 // Check if translation is enabled
487 if (get_option('botwriter_seo_translation_enabled', '0') !== '1') {
488 return false;
489 }
490
491 $translate_title = get_option('botwriter_seo_translate_title', '1') === '1';
492 $translate_tags = get_option('botwriter_seo_translate_tags', '1') === '1';
493 $translate_image = get_option('botwriter_seo_translate_image', '1') === '1';
494 $target_lang = get_option('botwriter_seo_target_language', 'en');
495
496 // Nothing to translate
497 if (!$translate_title && !$translate_tags && !$translate_image) {
498 return false;
499 }
500
501 // Build the prompt
502 $items = array();
503 if ($translate_title || $translate_image) {
504 $items[] = 'Title: ' . $title;
505 }
506 if ($translate_tags && !empty($tags)) {
507 $items[] = 'Tags: ' . $tags;
508 }
509
510 $language_names = array(
511 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French', 'de' => 'German',
512 'it' => 'Italian', 'pt' => 'Portuguese', 'nl' => 'Dutch', 'ru' => 'Russian',
513 'ja' => 'Japanese', 'ko' => 'Korean', 'zh' => 'Chinese', 'ar' => 'Arabic',
514 'hi' => 'Hindi', 'tr' => 'Turkish', 'pl' => 'Polish', 'sv' => 'Swedish',
515 'da' => 'Danish', 'no' => 'Norwegian', 'fi' => 'Finnish', 'cs' => 'Czech',
516 'ro' => 'Romanian', 'hu' => 'Hungarian', 'el' => 'Greek', 'th' => 'Thai',
517 'vi' => 'Vietnamese', 'id' => 'Indonesian', 'ms' => 'Malay', 'uk' => 'Ukrainian',
518 );
519 $lang_name = $language_names[$target_lang] ?? 'English';
520
521 $prompt = "You are a translator. Your task is to TRANSLATE the following text into {$lang_name} and then convert it into URL-friendly slugs.\n";
522 $prompt .= "IMPORTANT: You MUST translate the meaning into {$lang_name} first, then format as a slug. Do NOT just remove accents or spaces from the original language.\n";
523 $prompt .= "Example: if the input is in Spanish 'Receta de Paella Valenciana' and target is English, the slug must be 'valencian-paella-recipe', NOT 'receta-de-paella-valenciana'.\n";
524 $prompt .= "Rules: lowercase, hyphens instead of spaces, no special characters, no accents, max 6 words per slug.\n\n";
525 $prompt .= implode("\n", $items) . "\n\n";
526 $prompt .= "Respond ONLY with valid JSON, no explanation, no markdown:\n";
527 $prompt .= '{"post_slug": "translated-title-slug", "tag_slugs": ["tag-one", "tag-two"], "image_slug": "image-filename-slug"}';
528
529 // Determine which provider to use
530 $provider = get_option('botwriter_text_provider', 'openai');
531
532 $api_key = botwriter_get_provider_api_key($provider);
533 if (empty($api_key)) {
534 botwriter_log('SEO Translation: No API key for provider ' . $provider, [], 'warning');
535 return false;
536 }
537
538 // Use a fast/cheap model for translations
539 $model = botwriter_get_seo_translation_model($provider);
540
541 // Route through Cloudflare Worker (no usage count)
542 $text = botwriter_call_worker_nocount($provider, $api_key, $model, $prompt, 5000, 0.3);
543
544 if (is_wp_error($text)) {
545 botwriter_log('SEO Translation API error', ['provider' => $provider, 'error' => $text->get_error_message()], 'error');
546 return false;
547 }
548
549 // Parse JSON from response
550 $result = botwriter_parse_slug_json($text);
551 if ($result === false) {
552 botwriter_log('SEO Translation: Failed to parse JSON', ['provider' => $provider, 'raw' => substr($text, 0, 500)], 'error');
553 return false;
554 }
555
556 botwriter_log('SEO Translation success', [
557 'provider' => $provider,
558 'post_slug' => $result['post_slug'] ?? '',
559 'tag_count' => count($result['tag_slugs'] ?? []),
560 ]);
561
562 return $result;
563 }
564
565 /**
566 * Get the fastest/cheapest model for SEO translation based on provider
567 */
568 function botwriter_get_seo_translation_model($provider) {
569 $fast_models = array(
570 'openai' => 'gpt-4o-mini',
571 'anthropic' => 'claude-haiku-4-20250414',
572 'google' => 'gemini-2.5-flash',
573 'mistral' => 'mistral-small-latest',
574 'groq' => 'llama-3.3-70b-versatile',
575 'openrouter' => 'google/gemini-2.0-flash-001',
576 );
577 return $fast_models[$provider] ?? 'gpt-4o-mini';
578 }
579
580 /**
581 * Call the Cloudflare Worker /woo endpoint without counting usage.
582 *
583 * Used by SEO slug translation and meta description generation to
584 * leverage the Worker's provider abstraction (handles max_tokens vs
585 * max_completion_tokens, Responses API, etc.) while keeping quota
586 * reserved for actual WooCommerce / post generation operations.
587 *
588 * @param string $provider Provider key (openai, anthropic, google, etc.).
589 * @param string $api_key The user's API key for this provider.
590 * @param string $model Model name to use.
591 * @param string $prompt The prompt text.
592 * @param int $max_tokens Maximum output tokens.
593 * @param float $temperature Sampling temperature.
594 * @return string|WP_Error Generated text content on success.
595 */
596 function botwriter_call_worker_nocount( $provider, $api_key, $model, $prompt, $max_tokens = 200, $temperature = 0.3 ) {
597 $ssl_verify = get_option( 'botwriter_sslverify', 'yes' ) === 'yes';
598
599 // Map provider names: PHP uses 'google', Worker uses 'gemini'
600 $provider_map = array( 'google' => 'gemini' );
601 $worker_provider = isset( $provider_map[ $provider ] ) ? $provider_map[ $provider ] : $provider;
602
603 // Map provider key → Worker body field
604 $key_field_map = array(
605 'openai' => 'openai_api_key',
606 'anthropic' => 'anthropic_api_key',
607 'google' => 'google_api_key',
608 'mistral' => 'mistral_api_key',
609 'groq' => 'groq_api_key',
610 'openrouter' => 'openrouter_api_key',
611 );
612
613 $domain = preg_replace( '#^https?://#', '', home_url() );
614 $domain = rtrim( $domain, '/' );
615
616 $payload = array(
617 'prompt' => $prompt,
618 'domain' => $domain,
619 'provider' => $worker_provider,
620 'model' => $model,
621 'max_tokens' => $max_tokens,
622 'temperature' => $temperature,
623 'no_count' => true,
624 'site_token' => get_option( 'botwriter_site_token', '' ),
625 );
626
627 if ( ! empty( $api_key ) && isset( $key_field_map[ $provider ] ) ) {
628 $payload[ $key_field_map[ $provider ] ] = $api_key;
629 }
630
631 $response = wp_remote_post( BOTWRITER_API_URL . 'woo', array(
632 'timeout' => 30,
633 'sslverify' => $ssl_verify,
634 'headers' => array( 'Content-Type' => 'application/json' ),
635 'body' => wp_json_encode( $payload ),
636 ) );
637
638 if ( is_wp_error( $response ) ) {
639 return $response;
640 }
641
642 $http_code = wp_remote_retrieve_response_code( $response );
643 $body = wp_remote_retrieve_body( $response );
644 $data = json_decode( $body, true );
645
646 if ( $http_code !== 200 || ( isset( $data['status'] ) && $data['status'] === 'error' ) ) {
647 $msg = $data['error'] ?? "HTTP {$http_code}";
648 return new WP_Error( 'worker_error', $msg );
649 }
650
651 $content = $data['content'] ?? '';
652 if ( empty( $content ) ) {
653 return new WP_Error( 'empty_response', 'AI returned an empty response.' );
654 }
655
656 return $content;
657 }
658
659 /**
660 * Extract the text content from provider-specific response formats
661 */
662 function botwriter_extract_translation_text($provider, $body) {
663 $decoded = json_decode($body, true);
664 if (json_last_error() !== JSON_ERROR_NONE) { return ''; }
665
666 switch ($provider) {
667 case 'anthropic':
668 return $decoded['content'][0]['text'] ?? '';
669 case 'google':
670 return $decoded['candidates'][0]['content']['parts'][0]['text'] ?? '';
671 default: // openai, mistral, groq, openrouter — all OpenAI-compatible
672 return $decoded['choices'][0]['message']['content'] ?? '';
673 }
674 }
675
676 /**
677 * Parse the JSON slug response from AI, with fallback for markdown-wrapped JSON
678 */
679 function botwriter_parse_slug_json($text) {
680 // Strip markdown code fences if present
681 $text = trim($text);
682 $text = preg_replace('/^```(?:json)?\s*/i', '', $text);
683 $text = preg_replace('/\s*```$/', '', $text);
684 $text = trim($text);
685
686 $data = json_decode($text, true);
687 if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
688 return false;
689 }
690
691 // Sanitize slugs
692 $result = array(
693 'post_slug' => isset($data['post_slug']) ? sanitize_title($data['post_slug']) : '',
694 'tag_slugs' => array(),
695 'image_slug' => isset($data['image_slug']) ? sanitize_file_name($data['image_slug']) : '',
696 );
697
698 if (isset($data['tag_slugs']) && is_array($data['tag_slugs'])) {
699 foreach ($data['tag_slugs'] as $tag_slug) {
700 $result['tag_slugs'][] = sanitize_title($tag_slug);
701 }
702 }
703
704 return $result;
705 }
706
707 /**
708 * Apply translated slugs to a post after it has been created
709 *
710 * @param int $post_id The post ID
711 * @param string $title Original post title
712 * @param string $tags_string Comma-separated tags
713 */
714 function botwriter_apply_translated_slugs($post_id, $title, $tags_string = '') {
715 if (!$post_id || get_option('botwriter_seo_translation_enabled', '0') !== '1') {
716 return;
717 }
718
719 $slugs = botwriter_translate_slugs($title, $tags_string);
720 if ($slugs === false) {
721 return;
722 }
723
724 $translate_title = get_option('botwriter_seo_translate_title', '1') === '1';
725 $translate_tags = get_option('botwriter_seo_translate_tags', '1') === '1';
726
727 // Apply post slug
728 if ($translate_title && !empty($slugs['post_slug'])) {
729 wp_update_post(array(
730 'ID' => $post_id,
731 'post_name' => wp_unique_post_slug(
732 $slugs['post_slug'],
733 $post_id,
734 get_post_status($post_id),
735 get_post_type($post_id),
736 wp_get_post_parent_id($post_id)
737 ),
738 ));
739 }
740
741 // Apply tag slugs — match by original tag order, not alphabetical wp_get_post_tags order
742 if ($translate_tags && !empty($slugs['tag_slugs']) && !empty($tags_string)) {
743 $original_tags = array_map('trim', explode(',', $tags_string));
744 $tag_slug_map = $slugs['tag_slugs'];
745
746 botwriter_log('SEO Translation: Applying tag slugs', [
747 'post_id' => $post_id,
748 'original_tags' => $original_tags,
749 'tag_slugs' => $tag_slug_map,
750 ]);
751
752 foreach ($original_tags as $index => $tag_name) {
753 if (!isset($tag_slug_map[$index]) || empty($tag_slug_map[$index])) {
754 continue;
755 }
756 // Find the term by its name (the original tag text)
757 $term = get_term_by('name', $tag_name, 'post_tag');
758 if (!$term) {
759 // Also try by slug WordPress auto-generated
760 $term = get_term_by('slug', sanitize_title($tag_name), 'post_tag');
761 }
762 if ($term && !is_wp_error($term)) {
763 $result = wp_update_term($term->term_id, 'post_tag', array(
764 'slug' => $tag_slug_map[$index],
765 ));
766 if (is_wp_error($result)) {
767 botwriter_log('SEO Translation: Failed to update tag slug', [
768 'tag_name' => $tag_name,
769 'term_id' => $term->term_id,
770 'error' => $result->get_error_message(),
771 ], 'warning');
772 }
773 } else {
774 botwriter_log('SEO Translation: Tag term not found', [
775 'tag_name' => $tag_name,
776 'post_id' => $post_id,
777 ], 'warning');
778 }
779 }
780 }
781
782 // Return image slug for use during image attachment
783 return $slugs['image_slug'] ?? '';
784 }
785
786
787 /**
788 * Fetch and parse an RSS/Atom feed from the client side using wp_remote_get.
789 *
790 * Inspired by the server-side leerRSSFuente() in api_rss.php.
791 *
792 * @param string $rss_url The RSS feed URL.
793 * @param string $published_links Comma-separated list of already-published links.
794 * @return array {
795 * @type bool $success Whether a new article was found.
796 * @type string $error Error message (only when success is false).
797 * @type string $source_title Title of the selected article.
798 * @type string $source_content Description/content of the selected article.
799 * @type string $link_original Permalink of the selected article.
800 * }
801 */
802 function botwriter_fetch_rss_content( $rss_url, $published_links = '' ) {
803
804 // Validate URL
805 if ( empty( $rss_url ) ) {
806 return [ 'success' => false, 'error' => 'RSS source URL is empty or not configured.' ];
807 }
808 $rss_url = filter_var( $rss_url, FILTER_SANITIZE_URL );
809 if ( ! filter_var( $rss_url, FILTER_VALIDATE_URL ) ) {
810 return [ 'success' => false, 'error' => 'Invalid RSS feed URL.' ];
811 }
812
813 $ssl_verify = get_option( 'botwriter_sslverify', 'yes' );
814
815 botwriter_log( 'Client RSS fetch', [ 'url' => $rss_url ] );
816
817 // Fetch the feed
818 $response = wp_remote_get( $rss_url, [
819 'timeout' => 30,
820 'sslverify' => ( $ssl_verify !== 'no' ),
821 'user-agent' => 'BotWriter/' . BOTWRITER_VERSION . ' WordPress Plugin',
822 ] );
823
824 if ( is_wp_error( $response ) ) {
825 return [ 'success' => false, 'error' => 'Failed to fetch RSS feed: ' . $response->get_error_message() ];
826 }
827
828 $http_code = wp_remote_retrieve_response_code( $response );
829 if ( $http_code !== 200 ) {
830 return [ 'success' => false, 'error' => "RSS feed returned HTTP {$http_code}" ];
831 }
832
833 $body = wp_remote_retrieve_body( $response );
834
835 // Parse XML
836 libxml_use_internal_errors( true );
837 try {
838 $xml = new SimpleXMLElement( $body, LIBXML_NOCDATA );
839 } catch ( Exception $e ) {
840 return [ 'success' => false, 'error' => 'Error parsing RSS feed: ' . $e->getMessage() ];
841 }
842
843 // Build items array (Atom or RSS)
844 $items = [];
845
846 if ( isset( $xml->entry ) ) {
847 // Atom format
848 foreach ( $xml->entry as $entry ) {
849 $link = '';
850 if ( isset( $entry->link['href'] ) ) {
851 $link = (string) $entry->link['href'];
852 } else {
853 foreach ( $entry->link as $l ) {
854 if ( (string) $l['rel'] === 'alternate' || empty( (string) $l['rel'] ) ) {
855 $link = (string) $l['href'];
856 break;
857 }
858 }
859 }
860 $items[] = [
861 'title' => wp_strip_all_tags( (string) $entry->title ),
862 'link' => $link,
863 'description' => wp_strip_all_tags( (string) ( $entry->summary ?? $entry->content ?? '' ) ),
864 'content' => wp_strip_all_tags( (string) ( $entry->content ?? $entry->summary ?? '' ) ),
865 'date' => (string) ( $entry->updated ?? $entry->published ?? '' ),
866 ];
867 }
868 } else {
869 // Standard RSS
870 $channel = $xml->channel ?? $xml;
871 if ( isset( $channel->item ) ) {
872 foreach ( $channel->item as $item ) {
873 $content = '';
874 $namespaces = $item->getNamespaces( true );
875 if ( isset( $namespaces['content'] ) ) {
876 $content_ns = $item->children( $namespaces['content'] );
877 if ( isset( $content_ns->encoded ) ) {
878 $content = wp_strip_all_tags( (string) $content_ns->encoded );
879 }
880 }
881 $description = wp_strip_all_tags( (string) $item->description );
882
883 $items[] = [
884 'title' => wp_strip_all_tags( (string) $item->title ),
885 'link' => (string) $item->link,
886 'description' => $description,
887 'content' => ! empty( $content ) ? $content : $description,
888 'date' => (string) ( $item->pubDate ?? '' ),
889 ];
890 }
891 }
892 }
893
894 if ( empty( $items ) ) {
895 return [ 'success' => false, 'error' => 'No items found in RSS feed or failed to parse.' ];
896 }
897
898 // Find first article not already published
899 $published_array = array_filter( array_map( 'trim', explode( ',', $published_links ) ) );
900
901 foreach ( $items as $article ) {
902 if ( ! in_array( $article['link'], $published_array, true ) ) {
903 botwriter_log( 'Client RSS article selected', [
904 'title' => $article['title'],
905 'link' => $article['link'],
906 ] );
907 return [
908 'success' => true,
909 'source_title' => $article['title'],
910 'source_content' => ! empty( $article['content'] ) ? $article['content'] : $article['description'],
911 'link_original' => $article['link'],
912 ];
913 }
914 }
915
916 return [ 'success' => false, 'error' => 'No new articles found in RSS feed. All articles have already been published.' ];
917 }
918
919
920 /**
921 * Fetch a single unpublished article from a remote WordPress site via its REST API.
922 *
923 * Mirrors the logic previously handled server-side in getWebsitePosts.php.
924 * Uses wp_remote_get to call /wp-json/wp/v2/posts on the target domain,
925 * then picks the first post whose link is not in $published_links.
926 *
927 * @param string $domain_name URL of the remote WordPress site.
928 * @param string $category_ids Comma-separated list of category IDs.
929 * @param string $published_links Comma-separated list of already-published links.
930 * @return array {
931 * @type bool $success Whether an article was found.
932 * @type string $error Error message (when success is false).
933 * @type string $source_title Title of the selected article.
934 * @type string $source_content Content of the selected article (HTML stripped).
935 * @type string $link_original Permalink of the selected article.
936 * }
937 */
938 function botwriter_fetch_wordpress_content( $domain_name, $category_ids, $published_links = '' ) {
939
940 if ( empty( $domain_name ) ) {
941 return [ 'success' => false, 'error' => 'WordPress domain name is empty or not configured.' ];
942 }
943
944 if ( empty( $category_ids ) ) {
945 return [ 'success' => false, 'error' => 'WordPress category IDs are not configured.' ];
946 }
947
948 // Ensure the domain has a scheme
949 if ( ! preg_match( '#^https?://#i', $domain_name ) ) {
950 $domain_name = 'https://' . ltrim( $domain_name, '/' );
951 }
952 $domain_name = filter_var( $domain_name, FILTER_SANITIZE_URL );
953 if ( ! filter_var( $domain_name, FILTER_VALIDATE_URL ) ) {
954 return [ 'success' => false, 'error' => 'Invalid WordPress domain: ' . $domain_name ];
955 }
956
957 // Sanitise category IDs (only digits and commas)
958 $category_ids = preg_replace( '/[^0-9,]/', '', $category_ids );
959 if ( empty( $category_ids ) ) {
960 return [ 'success' => false, 'error' => 'Invalid category IDs.' ];
961 }
962
963 // Build REST API URL
964 $api_url = rtrim( $domain_name, '/' ) . '/wp-json/wp/v2/posts';
965 $api_url .= '?categories=' . urlencode( $category_ids );
966 $api_url .= '&per_page=15';
967
968 $ssl_verify = get_option( 'botwriter_sslverify', 'yes' );
969
970 botwriter_log( 'Client WordPress fetch', [ 'url' => $api_url ] );
971
972 $response = wp_remote_get( $api_url, [
973 'timeout' => 30,
974 'sslverify' => ( $ssl_verify !== 'no' ),
975 'user-agent' => 'BotWriter/' . BOTWRITER_VERSION . ' WordPress Plugin',
976 ] );
977
978 if ( is_wp_error( $response ) ) {
979 return [ 'success' => false, 'error' => 'Failed to connect to WordPress site: ' . $response->get_error_message() ];
980 }
981
982 $http_code = wp_remote_retrieve_response_code( $response );
983 if ( $http_code !== 200 ) {
984 return [ 'success' => false, 'error' => "WordPress REST API returned HTTP {$http_code}. Make sure the site has REST API enabled." ];
985 }
986
987 $body = wp_remote_retrieve_body( $response );
988 $posts = json_decode( $body, true );
989
990 if ( ! is_array( $posts ) || empty( $posts ) ) {
991 return [ 'success' => false, 'error' => 'No posts found in WordPress site or failed to parse response.' ];
992 }
993
994 // Find first unpublished post
995 $published_array = array_filter( array_map( 'trim', explode( ',', $published_links ) ) );
996
997 foreach ( $posts as $post ) {
998 $post_link = $post['link'] ?? '';
999
1000 if ( ! in_array( $post_link, $published_array, true ) ) {
1001 $title = wp_strip_all_tags( html_entity_decode( $post['title']['rendered'] ?? '' ) );
1002 $post_content = wp_strip_all_tags( html_entity_decode( $post['content']['rendered'] ?? '' ) );
1003
1004 botwriter_log( 'Client WordPress article selected', [
1005 'title' => $title,
1006 'link' => $post_link,
1007 ] );
1008
1009 return [
1010 'success' => true,
1011 'source_title' => $title,
1012 'source_content' => $post_content,
1013 'link_original' => $post_link,
1014 ];
1015 }
1016 }
1017
1018 return [ 'success' => false, 'error' => 'No new posts found in WordPress site. All posts have already been published.' ];
1019 }
1020
1021
1022 /**
1023 * Fetch categories from a remote WordPress site via its REST API.
1024 *
1025 * Mirrors the logic previously handled server-side in getWebsiteCategories.php.
1026 *
1027 * @param string $domain_name URL of the remote WordPress site.
1028 * @return array {
1029 * @type bool $success Whether categories were retrieved.
1030 * @type string $error Error message (when success is false).
1031 * @type array $categories Array of [ id, name, slug ] objects.
1032 * }
1033 */
1034 function botwriter_fetch_wordpress_categories( $domain_name ) {
1035
1036 if ( empty( $domain_name ) ) {
1037 return [ 'success' => false, 'error' => 'WordPress domain name is empty.' ];
1038 }
1039
1040 // Ensure the domain has a scheme
1041 if ( ! preg_match( '#^https?://#i', $domain_name ) ) {
1042 $domain_name = 'https://' . ltrim( $domain_name, '/' );
1043 }
1044 $domain_name = filter_var( $domain_name, FILTER_SANITIZE_URL );
1045 if ( ! filter_var( $domain_name, FILTER_VALIDATE_URL ) ) {
1046 return [ 'success' => false, 'error' => 'Invalid WordPress domain: ' . $domain_name ];
1047 }
1048
1049 $api_url = rtrim( $domain_name, '/' ) . '/wp-json/wp/v2/categories';
1050 $ssl_verify = get_option( 'botwriter_sslverify', 'yes' );
1051
1052 $response = wp_remote_get( $api_url, [
1053 'timeout' => 15,
1054 'sslverify' => ( $ssl_verify !== 'no' ),
1055 'user-agent' => 'BotWriter/' . BOTWRITER_VERSION . ' WordPress Plugin',
1056 ] );
1057
1058 if ( is_wp_error( $response ) ) {
1059 return [ 'success' => false, 'error' => 'Failed to connect to WordPress site: ' . $response->get_error_message() ];
1060 }
1061
1062 $http_code = wp_remote_retrieve_response_code( $response );
1063 if ( $http_code !== 200 ) {
1064 return [ 'success' => false, 'error' => "WordPress REST API returned HTTP {$http_code}." ];
1065 }
1066
1067 $body = wp_remote_retrieve_body( $response );
1068 $categories = json_decode( $body, true );
1069
1070 if ( ! is_array( $categories ) || empty( $categories ) ) {
1071 return [ 'success' => false, 'error' => 'No categories found or failed to parse response.' ];
1072 }
1073
1074 $filtered = array_map( function ( $cat ) {
1075 return [
1076 'id' => $cat['id'] ?? 0,
1077 'name' => $cat['name'] ?? '',
1078 'slug' => $cat['slug'] ?? '',
1079 ];
1080 }, $categories );
1081
1082 return [ 'success' => true, 'categories' => $filtered ];
1083 }
1084
1085
1086 /**
1087 * Generate an SEO meta description for a post using the configured AI text provider.
1088 *
1089 * Uses the same lightweight fast-model strategy as SEO Slug Translation.
1090 * The result is a single sentence of ≤160 characters suitable for the
1091 * <meta name="description"> tag.
1092 *
1093 * @param string $title Post title.
1094 * @param string $content Post HTML content (will be stripped to plain text).
1095 * @param string $language_code Post language code (e.g. 'es', 'en').
1096 * @return string|false The meta description, or false on failure.
1097 */
1098 function botwriter_generate_seo_meta( $title, $content, $language_code = '' ) {
1099 global $botwriter_languages;
1100
1101 // Feature disabled?
1102 if ( get_option( 'botwriter_meta_disabled', '0' ) === '1' ) {
1103 return false;
1104 }
1105
1106 // Strip HTML and trim content for the prompt (first ~800 chars is enough context)
1107 $plain = wp_strip_all_tags( $content );
1108 $plain = preg_replace( '/\s+/', ' ', $plain );
1109 $plain = mb_substr( $plain, 0, 800 );
1110
1111 // Resolve language name
1112 $lang_name = '';
1113 if ( ! empty( $language_code ) && isset( $botwriter_languages[ $language_code ] ) ) {
1114 $lang_name = $botwriter_languages[ $language_code ];
1115 }
1116
1117 $prompt = "You are an expert SEO copywriter.\n";
1118 $prompt .= "Write a compelling meta description for the following blog post.\n";
1119 $prompt .= "Rules:\n";
1120 $prompt .= "- Exactly ONE sentence, maximum 155 characters.\n";
1121 $prompt .= "- Must summarize the article accurately and entice clicks.\n";
1122 $prompt .= "- Do NOT include the title verbatim.\n";
1123 $prompt .= "- Do NOT use quotes or special characters.\n";
1124 if ( ! empty( $lang_name ) ) {
1125 $prompt .= "- The meta description language MUST be: {$lang_name}.\n";
1126 }
1127 $prompt .= "\nTitle: {$title}\n";
1128 $prompt .= "Content excerpt: {$plain}\n\n";
1129 $prompt .= "Respond ONLY with the meta description text, nothing else.";
1130
1131 // Determine provider
1132 $provider = get_option( 'botwriter_text_provider', 'openai' );
1133
1134 $api_key = botwriter_get_provider_api_key( $provider );
1135 if ( empty( $api_key ) ) {
1136 botwriter_log( 'SEO Meta: No API key for provider ' . $provider, [], 'warning' );
1137 return false;
1138 }
1139
1140 $model = botwriter_get_seo_translation_model( $provider ); // fast & cheap model
1141
1142 // Route through Cloudflare Worker (no usage count)
1143 $text = botwriter_call_worker_nocount( $provider, $api_key, $model, $prompt, 5000, 0.4 );
1144
1145 if ( is_wp_error( $text ) ) {
1146 botwriter_log( 'SEO Meta API error', [ 'provider' => $provider, 'error' => $text->get_error_message() ], 'error' );
1147 return false;
1148 }
1149
1150 $raw_text = (string) $text;
1151 botwriter_log( 'SEO Meta raw response', [
1152 'provider' => $provider,
1153 'raw_length_bytes' => strlen( $raw_text ),
1154 'raw_length_chars' => mb_strlen( $raw_text ),
1155 'raw_response' => $raw_text,
1156 'contains_markdown_fence' => strpos( $raw_text, '```' ) !== false,
1157 'contains_html' => preg_match( '/<[^>]+>/', $raw_text ) === 1,
1158 'contains_html_entities' => preg_match( '/&[A-Za-z0-9#]+;/', $raw_text ) === 1,
1159 ] );
1160
1161 $meta = botwriter_sanitize_meta_description( $raw_text );
1162
1163 botwriter_log( 'SEO Meta sanitized response', [
1164 'provider' => $provider,
1165 'sanitized_length' => mb_strlen( $meta ),
1166 'sanitized_response' => $meta,
1167 'was_modified' => $meta !== trim( $raw_text, " \t\n\r\0\x0B\"'" ),
1168 ] );
1169
1170 botwriter_log( 'SEO Meta generated', [
1171 'provider' => $provider,
1172 'length' => mb_strlen( $meta ),
1173 ] );
1174
1175 return $meta;
1176 }
1177
1178 /**
1179 * Clean and trim an AI-generated meta description so it is safe for SEO tags.
1180 *
1181 * @param string $text Raw AI output.
1182 * @return string
1183 */
1184 function botwriter_sanitize_meta_description( $text ) {
1185 $text = wp_strip_all_tags( $text );
1186 $text = trim( $text, " \t\n\r\0\x0B\"'" );
1187 // Ensure ≤ 160 characters, cut at last word boundary
1188 if ( mb_strlen( $text ) > 160 ) {
1189 $text = mb_substr( $text, 0, 157 );
1190 $text = preg_replace( '/\s+\S*$/', '', $text );
1191 $text .= '...';
1192 }
1193 return $text;
1194 }
1195
1196 /**
1197 * Save SEO meta description to the post's excerpt and to popular SEO plugin fields.
1198 *
1199 * Supports: Yoast SEO, Rank Math, All in One SEO, SEOPress, The SEO Framework.
1200 *
1201 * @param int $post_id The post ID.
1202 * @param string $meta_description The meta description.
1203 */
1204 function botwriter_apply_seo_meta( $post_id, $meta_description ) {
1205 if ( empty( $meta_description ) || ! $post_id ) {
1206 return;
1207 }
1208
1209 // Always save as post excerpt (native WP)
1210 wp_update_post( [
1211 'ID' => $post_id,
1212 'post_excerpt' => $meta_description,
1213 ] );
1214
1215 // Yoast SEO
1216 if ( defined( 'WPSEO_VERSION' ) || metadata_exists( 'post', $post_id, '_yoast_wpseo_metadesc' ) ) {
1217 update_post_meta( $post_id, '_yoast_wpseo_metadesc', $meta_description );
1218 }
1219
1220 // Rank Math
1221 if ( class_exists( 'RankMath' ) || metadata_exists( 'post', $post_id, 'rank_math_description' ) ) {
1222 update_post_meta( $post_id, 'rank_math_description', $meta_description );
1223 }
1224
1225 // All in One SEO (AIOSEO)
1226 if ( function_exists( 'aioseo' ) ) {
1227 global $wpdb;
1228 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
1229 if ( $wpdb->get_var( "SHOW TABLES LIKE '{$aioseo_table}'" ) === $aioseo_table ) {
1230 $exists = $wpdb->get_var( $wpdb->prepare(
1231 "SELECT id FROM {$aioseo_table} WHERE post_id = %d", $post_id
1232 ) );
1233 if ( $exists ) {
1234 $wpdb->update( $aioseo_table, [ 'description' => $meta_description ], [ 'post_id' => $post_id ] );
1235 } else {
1236 $wpdb->insert( $aioseo_table, [ 'post_id' => $post_id, 'description' => $meta_description ] );
1237 }
1238 }
1239 }
1240
1241 // SEOPress
1242 if ( defined( 'SEOPRESS_VERSION' ) ) {
1243 update_post_meta( $post_id, '_seopress_titles_desc', $meta_description );
1244 }
1245
1246 // The SEO Framework
1247 if ( function_exists( 'the_seo_framework' ) ) {
1248 update_post_meta( $post_id, '_genesis_description', $meta_description );
1249 }
1250
1251 botwriter_log( 'SEO Meta applied to post', [
1252 'post_id' => $post_id,
1253 'length' => mb_strlen( $meta_description ),
1254 'plugins' => implode( ', ', array_filter( [
1255 defined( 'WPSEO_VERSION' ) ? 'Yoast' : '',
1256 class_exists( 'RankMath' ) ? 'RankMath' : '',
1257 function_exists( 'aioseo' ) ? 'AIOSEO' : '',
1258 defined( 'SEOPRESS_VERSION' ) ? 'SEOPress' : '',
1259 function_exists( 'the_seo_framework' ) ? 'TSF' : '',
1260 ] ) ) ?: 'none (excerpt only)',
1261 ] );
1262 }
1263