PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 1.11.0 All 47 releases
thinkrank / includes / ai / class-openrouter-client.php

class-openrouter-client.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/ai/class-openrouter-client.php

772 lines 29.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenRouter API Client
4 *
5 * Handles communication with the OpenRouter API. OpenRouter exposes an
6 * OpenAI-compatible Chat Completions endpoint that proxies many underlying
7 * models (OpenAI, Anthropic, Google, Meta, DeepSeek, …) behind a single key,
8 * so this client mirrors the OpenAI_Client request/response handling.
9 *
10 * @package ThinkRank\AI
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\AI;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * OpenRouter Client Class
25 *
26 * Single Responsibility: Handle OpenRouter API communication
27 *
28 * @since 1.0.0
29 */
30 class OpenRouter_Client {
31
32 /**
33 * OpenRouter API base URL
34 */
35 private const API_BASE_URL = 'https://openrouter.ai/api/v1';
36
37 /**
38 * API key
39 *
40 * @var string
41 */
42 private string $api_key;
43
44 /**
45 * Default model
46 *
47 * @var string
48 */
49 private string $model;
50
51 /**
52 * Request timeout in seconds
53 *
54 * @var int
55 */
56 private int $timeout;
57
58 /**
59 * Prompt Builder instance
60 *
61 * @since 1.0.0
62 * @var Prompt_Builder|null
63 */
64 private ?Prompt_Builder $prompt_builder = null;
65
66 /**
67 * Constructor
68 *
69 * @param string $api_key OpenRouter API key
70 * @param string $model Default model to use
71 * @param int $timeout Request timeout
72 */
73 public function __construct(string $api_key, string $model = \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL, int $timeout = 30) {
74 $this->api_key = $api_key;
75 $this->model = $model;
76 $this->timeout = $timeout;
77 }
78
79 /**
80 * Get Prompt Builder instance
81 *
82 * @since 1.0.0
83 *
84 * @return Prompt_Builder Prompt Builder instance
85 */
86 private function get_prompt_builder(): Prompt_Builder {
87 if (!$this->prompt_builder) {
88 // Ensure Prompt Builder is loaded
89 if (!class_exists('ThinkRank\\AI\\Prompt_Builder')) {
90 require_once THINKRANK_PLUGIN_DIR . 'includes/ai/class-prompt-builder.php';
91 }
92 $this->prompt_builder = new Prompt_Builder();
93 }
94 return $this->prompt_builder;
95 }
96
97 /**
98 * Get current provider key.
99 *
100 * @return string Provider identifier.
101 */
102 public function get_provider(): string {
103 return 'openrouter';
104 }
105
106 /**
107 * Generate completion using OpenRouter
108 *
109 * @param string $prompt The prompt to send
110 * @param array $options Additional options
111 * @return array Response data
112 * @throws \Exception If API request fails
113 */
114 public function generate_completion(string $prompt, array $options = []): array {
115 $default_options = [
116 'model' => $this->model,
117 'max_tokens' => 1000,
118 'temperature' => 0.7,
119 'top_p' => 1,
120 'frequency_penalty' => 0,
121 'presence_penalty' => 0,
122 ];
123
124 $options = array_merge($default_options, $options);
125
126 // Cap the request to a safe ceiling so an arbitrary downstream model is
127 // never asked for more than it can return.
128 $safe_tokens = $this->get_safe_token_limit($options['model'], $options['max_tokens']);
129
130 $body = [
131 'model' => $options['model'],
132 'messages' => [
133 [
134 'role' => 'user',
135 'content' => $prompt,
136 ]
137 ],
138 'temperature' => $options['temperature'],
139 'top_p' => $options['top_p'],
140 'frequency_penalty' => $options['frequency_penalty'],
141 'presence_penalty' => $options['presence_penalty'],
142 'max_tokens' => $safe_tokens,
143 ];
144
145 return $this->make_request('chat/completions', $body);
146 }
147
148 /**
149 * Generate SEO metadata
150 *
151 * @param string $content Content to analyze
152 * @param array $options Generation options
153 * @return array Generated metadata
154 * @throws \Exception If generation fails
155 */
156 public function generate_seo_metadata(string $content, array $options = []): array {
157 $target_keyword = $options['target_keyword'] ?? '';
158 $content_type = $options['content_type'] ?? 'blog_post';
159 $tone = $options['tone'] ?? 'professional';
160
161 $prompt_builder = $this->get_prompt_builder();
162 $language = is_string($options['language'] ?? null) ? $options['language'] : '';
163 $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'openrouter', $language);
164
165 $response = $this->generate_completion($prompt, [
166 'max_tokens' => $this->get_recommended_tokens('seo_metadata'),
167 'temperature' => 0.3, // Lower temperature for more consistent SEO output
168 ]);
169
170 return $this->parse_seo_response($response);
171 }
172
173 /**
174 * Analyze content for SEO optimization
175 *
176 * @param string $content Content to analyze
177 * @param array $metadata Existing metadata
178 * @return array Analysis results
179 * @throws \Exception If analysis fails
180 */
181 public function analyze_content(string $content, array $metadata = []): array {
182 $prompt_builder = $this->get_prompt_builder();
183 $prompt = $prompt_builder->build_analysis_prompt($content, $metadata, 'openrouter');
184
185 $response = $this->generate_completion($prompt, [
186 'max_tokens' => $this->get_recommended_tokens('analysis'),
187 'temperature' => 0.3, // Lower temperature for more consistent analysis
188 ]);
189
190 return $this->parse_analysis_response($response);
191 }
192
193 /**
194 * Get maximum completion tokens for a model.
195 *
196 * OpenRouter normalises `max_tokens` across very different underlying
197 * models, so we apply a single conservative ceiling rather than per-model
198 * limits.
199 *
200 * @param string $model Model name
201 * @return int Maximum completion tokens
202 */
203 private function get_max_completion_tokens(string $model): int {
204 return 8192;
205 }
206
207 /**
208 * Get safe token limit for a request
209 *
210 * @param string $model Model name
211 * @param int $requested_tokens Requested token count
212 * @return int Safe token count (capped at model limit)
213 */
214 public function get_safe_token_limit(string $model, int $requested_tokens): int {
215 $max_tokens = $this->get_max_completion_tokens($model);
216 return min($requested_tokens, $max_tokens);
217 }
218
219 /**
220 * Get recommended token limit for specific use cases
221 *
222 * @param string $use_case Use case (e.g., 'content_brief', 'seo_metadata', 'analysis')
223 * @return int Recommended token limit
224 */
225 public function get_recommended_tokens(string $use_case): int {
226 $max_tokens = $this->get_max_completion_tokens($this->model);
227
228 $recommendations = [
229 'content_brief' => 0.9, // 90% of max tokens for comprehensive briefs
230 'seo_metadata' => 0.2, // 20% of max tokens for metadata
231 'analysis' => 0.3, // 30% of max tokens for analysis
232 'llms_txt' => 0.5, // 50% of max tokens for llms.txt
233 'optimization' => 0.2, // 20% of max tokens for optimization
234 ];
235 $percentage = $recommendations[$use_case] ?? 0.2;
236
237 return (int) ($max_tokens * $percentage);
238 }
239
240 /**
241 * Build request body for chat completions.
242 *
243 * @param string $user_prompt User prompt
244 * @param string|null $system_prompt Optional system prompt
245 * @param int $max_tokens Maximum tokens
246 * @param float $temperature Temperature
247 * @return array Request body
248 */
249 private function build_chat_request(string $user_prompt, ?string $system_prompt = null, int $max_tokens = 600, float $temperature = 0.4): array {
250 $messages = [];
251 if ($system_prompt) {
252 $messages[] = [
253 'role' => 'system',
254 'content' => $system_prompt,
255 ];
256 }
257 $messages[] = [
258 'role' => 'user',
259 'content' => $user_prompt,
260 ];
261
262 return [
263 'model' => $this->model,
264 'messages' => $messages,
265 'temperature' => $temperature,
266 'max_tokens' => $this->get_safe_token_limit($this->model, $max_tokens),
267 ];
268 }
269
270 /**
271 * Get current model
272 *
273 * @return string Current model name
274 */
275 public function get_model(): string {
276 return $this->model;
277 }
278
279 /**
280 * Test API connection
281 *
282 * @return bool True if connection successful
283 */
284 public function test_connection(): bool {
285 try {
286 // The key endpoint validates the credential and returns its metadata.
287 $response = $this->make_request('key');
288 return isset($response['data']) && is_array($response['data']);
289 } catch (\Exception $e) {
290 return false;
291 }
292 }
293
294 /**
295 * Make API request to OpenRouter
296 *
297 * @param string $endpoint API endpoint
298 * @param array $body Request body
299 * @return array Response data
300 * @throws \Exception If request fails
301 */
302 private function make_request(string $endpoint, array $body = []): array {
303 $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
304
305 $args = [
306 'timeout' => $this->timeout,
307 'headers' => [
308 'Authorization' => 'Bearer ' . $this->api_key,
309 'Content-Type' => 'application/json',
310 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
311 // Optional attribution headers used by OpenRouter for ranking.
312 'HTTP-Referer' => home_url('/'),
313 'X-Title' => 'ThinkRank',
314 ],
315 ];
316
317 if (!empty($body)) {
318 $args['method'] = 'POST';
319 $args['body'] = wp_json_encode($body);
320 }
321
322 // Keep PHP alive for the whole blocking call (see method docblock).
323 $this->raise_request_time_limit();
324
325 $response = wp_remote_request($url, $args);
326
327 if (is_wp_error($response)) {
328 throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
329 }
330
331 $status_code = wp_remote_retrieve_response_code($response);
332 $response_body = wp_remote_retrieve_body($response);
333
334 if ($status_code >= 400) {
335 $error_data = json_decode($response_body, true);
336 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
337 throw new \Exception(sprintf('OpenRouter API error (%d): %s', (int) $status_code, esc_html($error_message)));
338 }
339
340 $data = json_decode($response_body, true);
341
342 if (json_last_error() !== JSON_ERROR_NONE) {
343 throw new \Exception('Invalid JSON response from OpenRouter API');
344 }
345
346 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
347 // 2xx) would violate this method's : array return type; reject it here so
348 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
349 if (!is_array($data)) {
350 throw new \Exception('Unexpected non-array response from OpenRouter API');
351 }
352
353 return $data;
354 }
355
356 /**
357 * Give PHP enough execution time to outlive a blocking AI HTTP request.
358 *
359 * The provider call blocks for up to $this->timeout seconds, but the web
360 * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
361 * fatally terminates the script mid-request (inside the cURL transport),
362 * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
363 * before the call keeps the script alive for the full request; PHP-FPM's
364 * request_terminate_timeout still caps the absolute maximum. No-op when
365 * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
366 *
367 * @return void
368 */
369 private function raise_request_time_limit(): void {
370 if (function_exists('set_time_limit')) {
371 @set_time_limit($this->timeout + 45); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- set_time_limit() warns when disabled by host policy; the guard is intentional.
372 }
373 }
374
375 /**
376 * Parse SEO response from OpenRouter
377 *
378 * @param array $response OpenRouter response
379 * @return array Parsed metadata
380 * @throws \Exception If parsing fails
381 */
382 private function parse_seo_response(array $response): array {
383 if (!isset($response['choices'][0]['message']['content'])) {
384 throw new \Exception('Invalid response format from OpenRouter');
385 }
386
387 $content = $response['choices'][0]['message']['content'];
388 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
389
390 // Try to extract JSON from the response
391 $json_start = strpos($content, '{');
392 $json_end = strrpos($content, '}');
393
394 if (false === $json_start || false === $json_end) {
395 throw new \Exception('No valid JSON found in OpenRouter response');
396 }
397
398 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
399 $metadata = json_decode($json_content, true);
400
401 if (json_last_error() !== JSON_ERROR_NONE) {
402 throw new \Exception('Failed to parse JSON from OpenRouter response');
403 }
404
405 // Validate required fields
406 $required_fields = ['title', 'description', 'focus_keyword'];
407 foreach ($required_fields as $field) {
408 if (!isset($metadata[$field])) {
409 throw new \Exception(sprintf('Missing required field: %s', esc_html($field)));
410 }
411 }
412
413 return [
414 'title' => sanitize_text_field($metadata['title']),
415 'description' => sanitize_text_field($metadata['description']),
416 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
417 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
418 'generated_at' => current_time('mysql'),
419 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
420 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
421 ];
422 }
423
424 /**
425 * Parse analysis response from OpenRouter
426 *
427 * @param array $response OpenRouter API response
428 * @return array Parsed analysis data
429 * @throws \Exception If parsing fails
430 */
431 private function parse_analysis_response(array $response): array {
432 if (!isset($response['choices'][0]['message']['content'])) {
433 throw new \Exception('Invalid response format from OpenRouter');
434 }
435
436 $content = trim($response['choices'][0]['message']['content']);
437 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
438
439 // Extract JSON from response
440 $json_start = strpos($content, '{');
441 $json_end = strrpos($content, '}');
442
443 if (false === $json_start || false === $json_end) {
444 throw new \Exception('No valid JSON found in response');
445 }
446
447 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
448 $analysis = json_decode($json_content, true);
449
450 if (json_last_error() !== JSON_ERROR_NONE) {
451 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
452 }
453
454 // Validate and sanitize response
455 return [
456 'seo_score' => min(100, max(0, (int) ($analysis['seo_score'] ?? 0))),
457 'content_analysis' => [
458 'word_count' => (int) ($analysis['content_analysis']['word_count'] ?? 0),
459 'readability' => sanitize_text_field($analysis['content_analysis']['readability'] ?? 'unknown'),
460 'keyword_density' => sanitize_text_field($analysis['content_analysis']['keyword_density'] ?? 'unknown'),
461 'structure' => sanitize_text_field($analysis['content_analysis']['structure'] ?? 'unknown'),
462 ],
463 'suggestions' => array_map('sanitize_text_field', $analysis['suggestions'] ?? []),
464 'strengths' => array_map('sanitize_text_field', $analysis['strengths'] ?? []),
465 'weaknesses' => array_map('sanitize_text_field', $analysis['weaknesses'] ?? []),
466 'analyzed_at' => current_time('mysql'),
467 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
468 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
469 ];
470 }
471
472 /**
473 * Optimize site identity using OpenRouter
474 *
475 * @since 1.0.0
476 *
477 * @param array $site_data Site data to optimize
478 * @param array $options Optimization options
479 * @return array Optimization results
480 * @throws \Exception If optimization fails
481 */
482 public function optimize_site_identity(array $site_data, array $options = []): array {
483 $business_type = $options['business_type'] ?? 'website';
484 $target_audience = $options['target_audience'] ?? 'general';
485 $tone = $options['tone'] ?? 'professional';
486
487 $prompt_builder = $this->get_prompt_builder();
488 $prompt = $prompt_builder->build_site_identity_prompt($site_data, $business_type, $target_audience, $tone, 'openrouter');
489
490 $body = $this->build_chat_request(
491 $prompt,
492 'You are an expert SEO consultant specializing in site identity optimization. Provide actionable, specific recommendations in JSON format.',
493 $this->get_recommended_tokens('optimization'),
494 0.4
495 );
496
497 $response = $this->make_request('chat/completions', $body);
498
499 return $this->parse_site_identity_response($response);
500 }
501
502 /**
503 * Parse site identity optimization response
504 *
505 * @param array $response OpenRouter API response
506 * @return array Parsed optimization data
507 * @throws \Exception If parsing fails
508 */
509 private function parse_site_identity_response(array $response): array {
510 if (!isset($response['choices'][0]['message']['content'])) {
511 throw new \Exception('Invalid response format from OpenRouter');
512 }
513
514 $content = trim($response['choices'][0]['message']['content']);
515 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
516
517 // Extract JSON from response
518 $json_start = strpos($content, '{');
519 $json_end = strrpos($content, '}');
520
521 if (false === $json_start || false === $json_end) {
522 throw new \Exception('No valid JSON found in response');
523 }
524
525 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
526 $optimization = json_decode($json_content, true);
527
528 if (json_last_error() !== JSON_ERROR_NONE) {
529 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
530 }
531
532 // Validate and sanitize response
533 return [
534 'optimized_data' => [
535 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
536 'site_description' => sanitize_text_field($optimization['optimized_data']['site_description'] ?? ''),
537 'tagline' => sanitize_text_field($optimization['optimized_data']['tagline'] ?? ''),
538 'default_meta_description' => sanitize_text_field($optimization['optimized_data']['default_meta_description'] ?? ''),
539 ],
540 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
541 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
542 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
543 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
544 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
545 ];
546 }
547
548 /**
549 * Optimize homepage meta content using OpenRouter
550 *
551 * @since 1.0.0
552 *
553 * @param array $content_data Meta content data to optimize
554 * @param array $options Optimization options
555 * @return array Optimization results
556 * @throws \Exception If optimization fails
557 */
558 public function optimize_homepage_meta(array $content_data, array $options = []): array {
559 $business_type = $options['business_type'] ?? 'website';
560 $target_audience = $options['target_audience'] ?? 'general';
561 $tone = $options['tone'] ?? 'professional';
562 $context = $options['context'] ?? [];
563
564 $prompt_builder = $this->get_prompt_builder();
565 $prompt = $prompt_builder->build_homepage_meta_prompt($content_data, $business_type, $target_audience, $tone, $context, 'openrouter');
566
567 $body = $this->build_chat_request(
568 $prompt,
569 'You are an expert SEO consultant specializing in homepage meta optimization. Provide actionable, specific recommendations in JSON format.',
570 $this->get_recommended_tokens('optimization'),
571 0.4
572 );
573
574 $response = $this->make_request('chat/completions', $body);
575
576 return $this->parse_homepage_meta_response($response);
577 }
578
579 /**
580 * Optimize homepage hero content using OpenRouter
581 *
582 * @since 1.0.0
583 *
584 * @param array $hero_data Hero content data to optimize
585 * @param array $options Optimization options
586 * @return array Optimization results
587 * @throws \Exception If optimization fails
588 */
589 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
590 $business_type = $options['business_type'] ?? 'website';
591 $target_audience = $options['target_audience'] ?? 'general';
592 $tone = $options['tone'] ?? 'professional';
593 $context = $options['context'] ?? [];
594
595 $prompt_builder = $this->get_prompt_builder();
596 $prompt = $prompt_builder->build_homepage_hero_prompt($hero_data, $business_type, $target_audience, $tone, $context, 'openrouter');
597
598 $body = $this->build_chat_request(
599 $prompt,
600 'You are an expert conversion optimization specialist specializing in homepage hero sections. Provide actionable, specific recommendations in JSON format.',
601 $this->get_recommended_tokens('optimization'),
602 0.4
603 );
604
605 $response = $this->make_request('chat/completions', $body);
606
607 return $this->parse_homepage_hero_response($response);
608 }
609
610 /**
611 * Optimize LLMs.txt content using OpenRouter
612 *
613 * @since 1.0.0
614 *
615 * @param array $website_data Website data to optimize
616 * @param array $options Optimization options
617 * @return array Optimization results
618 * @throws \Exception If optimization fails
619 */
620 public function optimize_llms_txt(array $website_data, array $options = []): array {
621 // Use shared prompt builder for consistent prompts across all AI providers
622 $prompt_builder = $this->get_prompt_builder();
623 $prompt = $prompt_builder->build_llms_txt_prompt($website_data, $options, 'openrouter');
624
625 $body = $this->build_chat_request(
626 $prompt,
627 'You are an expert technical writer specializing in creating llms.txt files for AI assistants. Provide structured, comprehensive content in JSON format.',
628 $this->get_recommended_tokens('llms_txt'),
629 0.4
630 );
631
632 $response = $this->make_request('chat/completions', $body);
633
634 return $this->parse_llms_txt_response($response);
635 }
636
637 /**
638 * Parse LLMs.txt optimization response
639 *
640 * @param array $response OpenRouter API response
641 * @return array Parsed optimization data
642 * @throws \Exception If parsing fails
643 */
644 private function parse_llms_txt_response(array $response): array {
645 if (!isset($response['choices'][0]['message']['content'])) {
646 throw new \Exception('Invalid response format from OpenRouter');
647 }
648
649 $content = trim($response['choices'][0]['message']['content']);
650 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
651
652 // Extract JSON from response
653 $json_start = strpos($content, '{');
654 $json_end = strrpos($content, '}');
655
656 if (false === $json_start || false === $json_end) {
657 throw new \Exception('No valid JSON found in response');
658 }
659
660 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
661 $optimization = json_decode($json_content, true);
662
663 if (json_last_error() !== JSON_ERROR_NONE) {
664 throw new \Exception('Invalid JSON in response: ' . esc_html(json_last_error_msg()));
665 }
666
667 // Validate and sanitize response
668 return [
669 'optimized_data' => [
670 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
671 'project_overview' => sanitize_textarea_field($optimization['optimized_data']['project_overview'] ?? ''),
672 'key_features' => sanitize_textarea_field($optimization['optimized_data']['key_features'] ?? ''),
673 'architecture' => sanitize_textarea_field($optimization['optimized_data']['architecture'] ?? ''),
674 'development_guidelines' => sanitize_textarea_field($optimization['optimized_data']['development_guidelines'] ?? ''),
675 'ai_context' => sanitize_textarea_field($optimization['optimized_data']['ai_context'] ?? ''),
676 ],
677 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
678 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
679 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
680 ];
681 }
682
683 /**
684 * Parse homepage meta optimization response
685 *
686 * @param array $response OpenRouter API response
687 * @return array Parsed optimization data
688 * @throws \Exception If parsing fails
689 */
690 private function parse_homepage_meta_response(array $response): array {
691 if (!isset($response['choices'][0]['message']['content'])) {
692 throw new \Exception('Invalid response format from OpenRouter');
693 }
694
695 $content = trim($response['choices'][0]['message']['content']);
696 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
697
698 // Extract JSON from response
699 $json_start = strpos($content, '{');
700 $json_end = strrpos($content, '}');
701
702 if (false === $json_start || false === $json_end) {
703 throw new \Exception('No valid JSON found in response');
704 }
705
706 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
707 $optimization = json_decode($json_content, true);
708
709 if (json_last_error() !== JSON_ERROR_NONE) {
710 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
711 }
712
713 // Validate and sanitize response
714 return [
715 'optimized_data' => [
716 'title' => sanitize_text_field($optimization['optimized_data']['title'] ?? ''),
717 'meta_description' => sanitize_text_field($optimization['optimized_data']['meta_description'] ?? ''),
718 ],
719 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
720 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
721 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
722 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
723 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
724 ];
725 }
726
727 /**
728 * Parse homepage hero optimization response
729 *
730 * @param array $response OpenRouter API response
731 * @return array Parsed optimization data
732 * @throws \Exception If parsing fails
733 */
734 private function parse_homepage_hero_response(array $response): array {
735 if (!isset($response['choices'][0]['message']['content'])) {
736 throw new \Exception('Invalid response format from OpenRouter');
737 }
738
739 $content = trim($response['choices'][0]['message']['content']);
740 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
741
742 // Extract JSON from response
743 $json_start = strpos($content, '{');
744 $json_end = strrpos($content, '}');
745
746 if (false === $json_start || false === $json_end) {
747 throw new \Exception('No valid JSON found in response');
748 }
749
750 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
751 $optimization = json_decode($json_content, true);
752
753 if (json_last_error() !== JSON_ERROR_NONE) {
754 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
755 }
756
757 // Validate and sanitize response
758 return [
759 'optimized_data' => [
760 'hero_title' => sanitize_text_field($optimization['optimized_data']['hero_title'] ?? ''),
761 'hero_subtitle' => sanitize_text_field($optimization['optimized_data']['hero_subtitle'] ?? ''),
762 'hero_cta_text' => sanitize_text_field($optimization['optimized_data']['hero_cta_text'] ?? '')
763 ],
764 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
765 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
766 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
767 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
768 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
769 ];
770 }
771 }
772