PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.26.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.26.0
2.7.0 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 All 48 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 1.26.0, at includes/ai/class-openrouter-client.php

771 lines 29.1 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 = 'openai/gpt-4o-mini', 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 $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'openrouter');
163
164 $response = $this->generate_completion($prompt, [
165 'max_tokens' => $this->get_recommended_tokens('seo_metadata'),
166 'temperature' => 0.3, // Lower temperature for more consistent SEO output
167 ]);
168
169 return $this->parse_seo_response($response);
170 }
171
172 /**
173 * Analyze content for SEO optimization
174 *
175 * @param string $content Content to analyze
176 * @param array $metadata Existing metadata
177 * @return array Analysis results
178 * @throws \Exception If analysis fails
179 */
180 public function analyze_content(string $content, array $metadata = []): array {
181 $prompt_builder = $this->get_prompt_builder();
182 $prompt = $prompt_builder->build_analysis_prompt($content, $metadata, 'openrouter');
183
184 $response = $this->generate_completion($prompt, [
185 'max_tokens' => $this->get_recommended_tokens('analysis'),
186 'temperature' => 0.3, // Lower temperature for more consistent analysis
187 ]);
188
189 return $this->parse_analysis_response($response);
190 }
191
192 /**
193 * Get maximum completion tokens for a model.
194 *
195 * OpenRouter normalises `max_tokens` across very different underlying
196 * models, so we apply a single conservative ceiling rather than per-model
197 * limits.
198 *
199 * @param string $model Model name
200 * @return int Maximum completion tokens
201 */
202 private function get_max_completion_tokens(string $model): int {
203 return 8192;
204 }
205
206 /**
207 * Get safe token limit for a request
208 *
209 * @param string $model Model name
210 * @param int $requested_tokens Requested token count
211 * @return int Safe token count (capped at model limit)
212 */
213 public function get_safe_token_limit(string $model, int $requested_tokens): int {
214 $max_tokens = $this->get_max_completion_tokens($model);
215 return min($requested_tokens, $max_tokens);
216 }
217
218 /**
219 * Get recommended token limit for specific use cases
220 *
221 * @param string $use_case Use case (e.g., 'content_brief', 'seo_metadata', 'analysis')
222 * @return int Recommended token limit
223 */
224 public function get_recommended_tokens(string $use_case): int {
225 $max_tokens = $this->get_max_completion_tokens($this->model);
226
227 $recommendations = [
228 'content_brief' => 0.9, // 90% of max tokens for comprehensive briefs
229 'seo_metadata' => 0.2, // 20% of max tokens for metadata
230 'analysis' => 0.3, // 30% of max tokens for analysis
231 'llms_txt' => 0.5, // 50% of max tokens for llms.txt
232 'optimization' => 0.2, // 20% of max tokens for optimization
233 ];
234 $percentage = $recommendations[$use_case] ?? 0.2;
235
236 return (int) ($max_tokens * $percentage);
237 }
238
239 /**
240 * Build request body for chat completions.
241 *
242 * @param string $user_prompt User prompt
243 * @param string|null $system_prompt Optional system prompt
244 * @param int $max_tokens Maximum tokens
245 * @param float $temperature Temperature
246 * @return array Request body
247 */
248 private function build_chat_request(string $user_prompt, ?string $system_prompt = null, int $max_tokens = 600, float $temperature = 0.4): array {
249 $messages = [];
250 if ($system_prompt) {
251 $messages[] = [
252 'role' => 'system',
253 'content' => $system_prompt,
254 ];
255 }
256 $messages[] = [
257 'role' => 'user',
258 'content' => $user_prompt,
259 ];
260
261 return [
262 'model' => $this->model,
263 'messages' => $messages,
264 'temperature' => $temperature,
265 'max_tokens' => $this->get_safe_token_limit($this->model, $max_tokens),
266 ];
267 }
268
269 /**
270 * Get current model
271 *
272 * @return string Current model name
273 */
274 public function get_model(): string {
275 return $this->model;
276 }
277
278 /**
279 * Test API connection
280 *
281 * @return bool True if connection successful
282 */
283 public function test_connection(): bool {
284 try {
285 // The key endpoint validates the credential and returns its metadata.
286 $response = $this->make_request('key');
287 return isset($response['data']) && is_array($response['data']);
288 } catch (\Exception $e) {
289 return false;
290 }
291 }
292
293 /**
294 * Make API request to OpenRouter
295 *
296 * @param string $endpoint API endpoint
297 * @param array $body Request body
298 * @return array Response data
299 * @throws \Exception If request fails
300 */
301 private function make_request(string $endpoint, array $body = []): array {
302 $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
303
304 $args = [
305 'timeout' => $this->timeout,
306 'headers' => [
307 'Authorization' => 'Bearer ' . $this->api_key,
308 'Content-Type' => 'application/json',
309 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
310 // Optional attribution headers used by OpenRouter for ranking.
311 'HTTP-Referer' => home_url('/'),
312 'X-Title' => 'ThinkRank',
313 ],
314 ];
315
316 if (!empty($body)) {
317 $args['method'] = 'POST';
318 $args['body'] = wp_json_encode($body);
319 }
320
321 // Keep PHP alive for the whole blocking call (see method docblock).
322 $this->raise_request_time_limit();
323
324 $response = wp_remote_request($url, $args);
325
326 if (is_wp_error($response)) {
327 throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
328 }
329
330 $status_code = wp_remote_retrieve_response_code($response);
331 $response_body = wp_remote_retrieve_body($response);
332
333 if ($status_code >= 400) {
334 $error_data = json_decode($response_body, true);
335 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
336 throw new \Exception(sprintf('OpenRouter API error (%d): %s', (int) $status_code, esc_html($error_message)));
337 }
338
339 $data = json_decode($response_body, true);
340
341 if (json_last_error() !== JSON_ERROR_NONE) {
342 throw new \Exception('Invalid JSON response from OpenRouter API');
343 }
344
345 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
346 // 2xx) would violate this method's : array return type; reject it here so
347 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
348 if (!is_array($data)) {
349 throw new \Exception('Unexpected non-array response from OpenRouter API');
350 }
351
352 return $data;
353 }
354
355 /**
356 * Give PHP enough execution time to outlive a blocking AI HTTP request.
357 *
358 * The provider call blocks for up to $this->timeout seconds, but the web
359 * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
360 * fatally terminates the script mid-request (inside the cURL transport),
361 * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
362 * before the call keeps the script alive for the full request; PHP-FPM's
363 * request_terminate_timeout still caps the absolute maximum. No-op when
364 * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
365 *
366 * @return void
367 */
368 private function raise_request_time_limit(): void {
369 if (function_exists('set_time_limit')) {
370 @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.
371 }
372 }
373
374 /**
375 * Parse SEO response from OpenRouter
376 *
377 * @param array $response OpenRouter response
378 * @return array Parsed metadata
379 * @throws \Exception If parsing fails
380 */
381 private function parse_seo_response(array $response): array {
382 if (!isset($response['choices'][0]['message']['content'])) {
383 throw new \Exception('Invalid response format from OpenRouter');
384 }
385
386 $content = $response['choices'][0]['message']['content'];
387 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
388
389 // Try to extract JSON from the response
390 $json_start = strpos($content, '{');
391 $json_end = strrpos($content, '}');
392
393 if (false === $json_start || false === $json_end) {
394 throw new \Exception('No valid JSON found in OpenRouter response');
395 }
396
397 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
398 $metadata = json_decode($json_content, true);
399
400 if (json_last_error() !== JSON_ERROR_NONE) {
401 throw new \Exception('Failed to parse JSON from OpenRouter response');
402 }
403
404 // Validate required fields
405 $required_fields = ['title', 'description', 'focus_keyword'];
406 foreach ($required_fields as $field) {
407 if (!isset($metadata[$field])) {
408 throw new \Exception(sprintf('Missing required field: %s', esc_html($field)));
409 }
410 }
411
412 return [
413 'title' => sanitize_text_field($metadata['title']),
414 'description' => sanitize_text_field($metadata['description']),
415 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
416 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
417 'generated_at' => current_time('mysql'),
418 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
419 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
420 ];
421 }
422
423 /**
424 * Parse analysis response from OpenRouter
425 *
426 * @param array $response OpenRouter API response
427 * @return array Parsed analysis data
428 * @throws \Exception If parsing fails
429 */
430 private function parse_analysis_response(array $response): array {
431 if (!isset($response['choices'][0]['message']['content'])) {
432 throw new \Exception('Invalid response format from OpenRouter');
433 }
434
435 $content = trim($response['choices'][0]['message']['content']);
436 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
437
438 // Extract JSON from response
439 $json_start = strpos($content, '{');
440 $json_end = strrpos($content, '}');
441
442 if (false === $json_start || false === $json_end) {
443 throw new \Exception('No valid JSON found in response');
444 }
445
446 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
447 $analysis = json_decode($json_content, true);
448
449 if (json_last_error() !== JSON_ERROR_NONE) {
450 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
451 }
452
453 // Validate and sanitize response
454 return [
455 'seo_score' => min(100, max(0, (int) ($analysis['seo_score'] ?? 0))),
456 'content_analysis' => [
457 'word_count' => (int) ($analysis['content_analysis']['word_count'] ?? 0),
458 'readability' => sanitize_text_field($analysis['content_analysis']['readability'] ?? 'unknown'),
459 'keyword_density' => sanitize_text_field($analysis['content_analysis']['keyword_density'] ?? 'unknown'),
460 'structure' => sanitize_text_field($analysis['content_analysis']['structure'] ?? 'unknown'),
461 ],
462 'suggestions' => array_map('sanitize_text_field', $analysis['suggestions'] ?? []),
463 'strengths' => array_map('sanitize_text_field', $analysis['strengths'] ?? []),
464 'weaknesses' => array_map('sanitize_text_field', $analysis['weaknesses'] ?? []),
465 'analyzed_at' => current_time('mysql'),
466 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
467 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
468 ];
469 }
470
471 /**
472 * Optimize site identity using OpenRouter
473 *
474 * @since 1.0.0
475 *
476 * @param array $site_data Site data to optimize
477 * @param array $options Optimization options
478 * @return array Optimization results
479 * @throws \Exception If optimization fails
480 */
481 public function optimize_site_identity(array $site_data, array $options = []): array {
482 $business_type = $options['business_type'] ?? 'website';
483 $target_audience = $options['target_audience'] ?? 'general';
484 $tone = $options['tone'] ?? 'professional';
485
486 $prompt_builder = $this->get_prompt_builder();
487 $prompt = $prompt_builder->build_site_identity_prompt($site_data, $business_type, $target_audience, $tone, 'openrouter');
488
489 $body = $this->build_chat_request(
490 $prompt,
491 'You are an expert SEO consultant specializing in site identity optimization. Provide actionable, specific recommendations in JSON format.',
492 $this->get_recommended_tokens('optimization'),
493 0.4
494 );
495
496 $response = $this->make_request('chat/completions', $body);
497
498 return $this->parse_site_identity_response($response);
499 }
500
501 /**
502 * Parse site identity optimization response
503 *
504 * @param array $response OpenRouter API response
505 * @return array Parsed optimization data
506 * @throws \Exception If parsing fails
507 */
508 private function parse_site_identity_response(array $response): array {
509 if (!isset($response['choices'][0]['message']['content'])) {
510 throw new \Exception('Invalid response format from OpenRouter');
511 }
512
513 $content = trim($response['choices'][0]['message']['content']);
514 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
515
516 // Extract JSON from response
517 $json_start = strpos($content, '{');
518 $json_end = strrpos($content, '}');
519
520 if (false === $json_start || false === $json_end) {
521 throw new \Exception('No valid JSON found in response');
522 }
523
524 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
525 $optimization = json_decode($json_content, true);
526
527 if (json_last_error() !== JSON_ERROR_NONE) {
528 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
529 }
530
531 // Validate and sanitize response
532 return [
533 'optimized_data' => [
534 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
535 'site_description' => sanitize_text_field($optimization['optimized_data']['site_description'] ?? ''),
536 'tagline' => sanitize_text_field($optimization['optimized_data']['tagline'] ?? ''),
537 'default_meta_description' => sanitize_text_field($optimization['optimized_data']['default_meta_description'] ?? ''),
538 ],
539 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
540 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
541 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
542 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
543 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
544 ];
545 }
546
547 /**
548 * Optimize homepage meta content using OpenRouter
549 *
550 * @since 1.0.0
551 *
552 * @param array $content_data Meta content data to optimize
553 * @param array $options Optimization options
554 * @return array Optimization results
555 * @throws \Exception If optimization fails
556 */
557 public function optimize_homepage_meta(array $content_data, array $options = []): array {
558 $business_type = $options['business_type'] ?? 'website';
559 $target_audience = $options['target_audience'] ?? 'general';
560 $tone = $options['tone'] ?? 'professional';
561 $context = $options['context'] ?? [];
562
563 $prompt_builder = $this->get_prompt_builder();
564 $prompt = $prompt_builder->build_homepage_meta_prompt($content_data, $business_type, $target_audience, $tone, $context, 'openrouter');
565
566 $body = $this->build_chat_request(
567 $prompt,
568 'You are an expert SEO consultant specializing in homepage meta optimization. Provide actionable, specific recommendations in JSON format.',
569 $this->get_recommended_tokens('optimization'),
570 0.4
571 );
572
573 $response = $this->make_request('chat/completions', $body);
574
575 return $this->parse_homepage_meta_response($response);
576 }
577
578 /**
579 * Optimize homepage hero content using OpenRouter
580 *
581 * @since 1.0.0
582 *
583 * @param array $hero_data Hero content data to optimize
584 * @param array $options Optimization options
585 * @return array Optimization results
586 * @throws \Exception If optimization fails
587 */
588 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
589 $business_type = $options['business_type'] ?? 'website';
590 $target_audience = $options['target_audience'] ?? 'general';
591 $tone = $options['tone'] ?? 'professional';
592 $context = $options['context'] ?? [];
593
594 $prompt_builder = $this->get_prompt_builder();
595 $prompt = $prompt_builder->build_homepage_hero_prompt($hero_data, $business_type, $target_audience, $tone, $context, 'openrouter');
596
597 $body = $this->build_chat_request(
598 $prompt,
599 'You are an expert conversion optimization specialist specializing in homepage hero sections. Provide actionable, specific recommendations in JSON format.',
600 $this->get_recommended_tokens('optimization'),
601 0.4
602 );
603
604 $response = $this->make_request('chat/completions', $body);
605
606 return $this->parse_homepage_hero_response($response);
607 }
608
609 /**
610 * Optimize LLMs.txt content using OpenRouter
611 *
612 * @since 1.0.0
613 *
614 * @param array $website_data Website data to optimize
615 * @param array $options Optimization options
616 * @return array Optimization results
617 * @throws \Exception If optimization fails
618 */
619 public function optimize_llms_txt(array $website_data, array $options = []): array {
620 // Use shared prompt builder for consistent prompts across all AI providers
621 $prompt_builder = $this->get_prompt_builder();
622 $prompt = $prompt_builder->build_llms_txt_prompt($website_data, $options, 'openrouter');
623
624 $body = $this->build_chat_request(
625 $prompt,
626 'You are an expert technical writer specializing in creating llms.txt files for AI assistants. Provide structured, comprehensive content in JSON format.',
627 $this->get_recommended_tokens('llms_txt'),
628 0.4
629 );
630
631 $response = $this->make_request('chat/completions', $body);
632
633 return $this->parse_llms_txt_response($response);
634 }
635
636 /**
637 * Parse LLMs.txt optimization response
638 *
639 * @param array $response OpenRouter API response
640 * @return array Parsed optimization data
641 * @throws \Exception If parsing fails
642 */
643 private function parse_llms_txt_response(array $response): array {
644 if (!isset($response['choices'][0]['message']['content'])) {
645 throw new \Exception('Invalid response format from OpenRouter');
646 }
647
648 $content = trim($response['choices'][0]['message']['content']);
649 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
650
651 // Extract JSON from response
652 $json_start = strpos($content, '{');
653 $json_end = strrpos($content, '}');
654
655 if (false === $json_start || false === $json_end) {
656 throw new \Exception('No valid JSON found in response');
657 }
658
659 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
660 $optimization = json_decode($json_content, true);
661
662 if (json_last_error() !== JSON_ERROR_NONE) {
663 throw new \Exception('Invalid JSON in response: ' . esc_html(json_last_error_msg()));
664 }
665
666 // Validate and sanitize response
667 return [
668 'optimized_data' => [
669 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
670 'project_overview' => sanitize_textarea_field($optimization['optimized_data']['project_overview'] ?? ''),
671 'key_features' => sanitize_textarea_field($optimization['optimized_data']['key_features'] ?? ''),
672 'architecture' => sanitize_textarea_field($optimization['optimized_data']['architecture'] ?? ''),
673 'development_guidelines' => sanitize_textarea_field($optimization['optimized_data']['development_guidelines'] ?? ''),
674 'ai_context' => sanitize_textarea_field($optimization['optimized_data']['ai_context'] ?? ''),
675 ],
676 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
677 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
678 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
679 ];
680 }
681
682 /**
683 * Parse homepage meta optimization response
684 *
685 * @param array $response OpenRouter API response
686 * @return array Parsed optimization data
687 * @throws \Exception If parsing fails
688 */
689 private function parse_homepage_meta_response(array $response): array {
690 if (!isset($response['choices'][0]['message']['content'])) {
691 throw new \Exception('Invalid response format from OpenRouter');
692 }
693
694 $content = trim($response['choices'][0]['message']['content']);
695 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
696
697 // Extract JSON from response
698 $json_start = strpos($content, '{');
699 $json_end = strrpos($content, '}');
700
701 if (false === $json_start || false === $json_end) {
702 throw new \Exception('No valid JSON found in response');
703 }
704
705 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
706 $optimization = json_decode($json_content, true);
707
708 if (json_last_error() !== JSON_ERROR_NONE) {
709 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
710 }
711
712 // Validate and sanitize response
713 return [
714 'optimized_data' => [
715 'title' => sanitize_text_field($optimization['optimized_data']['title'] ?? ''),
716 'meta_description' => sanitize_text_field($optimization['optimized_data']['meta_description'] ?? ''),
717 ],
718 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
719 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
720 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
721 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
722 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
723 ];
724 }
725
726 /**
727 * Parse homepage hero optimization response
728 *
729 * @param array $response OpenRouter API response
730 * @return array Parsed optimization data
731 * @throws \Exception If parsing fails
732 */
733 private function parse_homepage_hero_response(array $response): array {
734 if (!isset($response['choices'][0]['message']['content'])) {
735 throw new \Exception('Invalid response format from OpenRouter');
736 }
737
738 $content = trim($response['choices'][0]['message']['content']);
739 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
740
741 // Extract JSON from response
742 $json_start = strpos($content, '{');
743 $json_end = strrpos($content, '}');
744
745 if (false === $json_start || false === $json_end) {
746 throw new \Exception('No valid JSON found in response');
747 }
748
749 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
750 $optimization = json_decode($json_content, true);
751
752 if (json_last_error() !== JSON_ERROR_NONE) {
753 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
754 }
755
756 // Validate and sanitize response
757 return [
758 'optimized_data' => [
759 'hero_title' => sanitize_text_field($optimization['optimized_data']['hero_title'] ?? ''),
760 'hero_subtitle' => sanitize_text_field($optimization['optimized_data']['hero_subtitle'] ?? ''),
761 'hero_cta_text' => sanitize_text_field($optimization['optimized_data']['hero_cta_text'] ?? '')
762 ],
763 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
764 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
765 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
766 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
767 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
768 ];
769 }
770 }
771