| 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 |
|