PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.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 All 51 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 2.10.0, at includes/ai/class-openrouter-client.php

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