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

783 lines 29.9 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 $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
315
316 $args = [
317 'timeout' => $this->timeout,
318 'headers' => [
319 'Authorization' => 'Bearer ' . $this->api_key,
320 'Content-Type' => 'application/json',
321 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
322 // Optional attribution headers used by OpenRouter for ranking.
323 'HTTP-Referer' => home_url('/'),
324 'X-Title' => 'ThinkRank',
325 ],
326 ];
327
328 if (!empty($body)) {
329 $args['method'] = 'POST';
330 $args['body'] = wp_json_encode($body);
331 }
332
333 // Keep PHP alive for the whole blocking call (see method docblock).
334 $this->raise_request_time_limit();
335
336 $response = wp_remote_request($url, $args);
337
338 if (is_wp_error($response)) {
339 throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
340 }
341
342 $status_code = wp_remote_retrieve_response_code($response);
343 $response_body = wp_remote_retrieve_body($response);
344
345 if ($status_code >= 400) {
346 $error_data = json_decode($response_body, true);
347 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
348 throw new \Exception(sprintf('OpenRouter API error (%d): %s', (int) $status_code, esc_html($error_message)));
349 }
350
351 $data = json_decode($response_body, true);
352
353 if (json_last_error() !== JSON_ERROR_NONE) {
354 throw new \Exception('Invalid JSON response from OpenRouter API');
355 }
356
357 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
358 // 2xx) would violate this method's : array return type; reject it here so
359 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
360 if (!is_array($data)) {
361 throw new \Exception('Unexpected non-array response from OpenRouter API');
362 }
363
364 return $data;
365 }
366
367 /**
368 * Give PHP enough execution time to outlive a blocking AI HTTP request.
369 *
370 * The provider call blocks for up to $this->timeout seconds, but the web
371 * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
372 * fatally terminates the script mid-request (inside the cURL transport),
373 * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
374 * before the call keeps the script alive for the full request; PHP-FPM's
375 * request_terminate_timeout still caps the absolute maximum. No-op when
376 * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
377 *
378 * @return void
379 */
380 private function raise_request_time_limit(): void {
381 if (function_exists('set_time_limit')) {
382 @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.
383 }
384 }
385
386 /**
387 * Parse SEO response from OpenRouter
388 *
389 * @param array $response OpenRouter response
390 * @return array Parsed metadata
391 * @throws \Exception If parsing fails
392 */
393 private function parse_seo_response(array $response): array {
394 if (!isset($response['choices'][0]['message']['content'])) {
395 throw new \Exception('Invalid response format from OpenRouter');
396 }
397
398 $content = $response['choices'][0]['message']['content'];
399 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
400
401 // Try to extract JSON from the response
402 $json_start = strpos($content, '{');
403 $json_end = strrpos($content, '}');
404
405 if (false === $json_start || false === $json_end) {
406 throw new \Exception('No valid JSON found in OpenRouter response');
407 }
408
409 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
410 $metadata = json_decode($json_content, true);
411
412 if (json_last_error() !== JSON_ERROR_NONE) {
413 throw new \Exception('Failed to parse JSON from OpenRouter response');
414 }
415
416 // Validate required fields
417 $required_fields = ['title', 'description', 'focus_keyword'];
418 foreach ($required_fields as $field) {
419 if (!isset($metadata[$field])) {
420 throw new \Exception(sprintf('Missing required field: %s', esc_html($field)));
421 }
422 }
423
424 return [
425 'title' => sanitize_text_field($metadata['title']),
426 'description' => sanitize_text_field($metadata['description']),
427 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
428 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
429 'generated_at' => current_time('mysql'),
430 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
431 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
432 ];
433 }
434
435 /**
436 * Parse analysis response from OpenRouter
437 *
438 * @param array $response OpenRouter API response
439 * @return array Parsed analysis data
440 * @throws \Exception If parsing fails
441 */
442 private function parse_analysis_response(array $response): array {
443 if (!isset($response['choices'][0]['message']['content'])) {
444 throw new \Exception('Invalid response format from OpenRouter');
445 }
446
447 $content = trim($response['choices'][0]['message']['content']);
448 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
449
450 // Extract JSON from response
451 $json_start = strpos($content, '{');
452 $json_end = strrpos($content, '}');
453
454 if (false === $json_start || false === $json_end) {
455 throw new \Exception('No valid JSON found in response');
456 }
457
458 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
459 $analysis = json_decode($json_content, true);
460
461 if (json_last_error() !== JSON_ERROR_NONE) {
462 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
463 }
464
465 // Validate and sanitize response
466 return [
467 'seo_score' => min(100, max(0, (int) ($analysis['seo_score'] ?? 0))),
468 'content_analysis' => [
469 'word_count' => (int) ($analysis['content_analysis']['word_count'] ?? 0),
470 'readability' => sanitize_text_field($analysis['content_analysis']['readability'] ?? 'unknown'),
471 'keyword_density' => sanitize_text_field($analysis['content_analysis']['keyword_density'] ?? 'unknown'),
472 'structure' => sanitize_text_field($analysis['content_analysis']['structure'] ?? 'unknown'),
473 ],
474 'suggestions' => array_map('sanitize_text_field', $analysis['suggestions'] ?? []),
475 'strengths' => array_map('sanitize_text_field', $analysis['strengths'] ?? []),
476 'weaknesses' => array_map('sanitize_text_field', $analysis['weaknesses'] ?? []),
477 'analyzed_at' => current_time('mysql'),
478 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
479 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
480 ];
481 }
482
483 /**
484 * Optimize site identity using OpenRouter
485 *
486 * @since 1.0.0
487 *
488 * @param array $site_data Site data to optimize
489 * @param array $options Optimization options
490 * @return array Optimization results
491 * @throws \Exception If optimization fails
492 */
493 public function optimize_site_identity(array $site_data, array $options = []): array {
494 $business_type = $options['business_type'] ?? 'website';
495 $target_audience = $options['target_audience'] ?? 'general';
496 $tone = $options['tone'] ?? 'professional';
497
498 $prompt_builder = $this->get_prompt_builder();
499 $prompt = $prompt_builder->build_site_identity_prompt($site_data, $business_type, $target_audience, $tone, 'openrouter');
500
501 $body = $this->build_chat_request(
502 $prompt,
503 'You are an expert SEO consultant specializing in site identity optimization. Provide actionable, specific recommendations in JSON format.',
504 $this->get_recommended_tokens('optimization'),
505 0.4
506 );
507
508 $response = $this->make_request('chat/completions', $body);
509
510 return $this->parse_site_identity_response($response);
511 }
512
513 /**
514 * Parse site identity optimization response
515 *
516 * @param array $response OpenRouter API response
517 * @return array Parsed optimization data
518 * @throws \Exception If parsing fails
519 */
520 private function parse_site_identity_response(array $response): array {
521 if (!isset($response['choices'][0]['message']['content'])) {
522 throw new \Exception('Invalid response format from OpenRouter');
523 }
524
525 $content = trim($response['choices'][0]['message']['content']);
526 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
527
528 // Extract JSON from response
529 $json_start = strpos($content, '{');
530 $json_end = strrpos($content, '}');
531
532 if (false === $json_start || false === $json_end) {
533 throw new \Exception('No valid JSON found in response');
534 }
535
536 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
537 $optimization = json_decode($json_content, true);
538
539 if (json_last_error() !== JSON_ERROR_NONE) {
540 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
541 }
542
543 // Validate and sanitize response
544 return [
545 'optimized_data' => [
546 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
547 'site_description' => sanitize_text_field($optimization['optimized_data']['site_description'] ?? ''),
548 'tagline' => sanitize_text_field($optimization['optimized_data']['tagline'] ?? ''),
549 'default_meta_description' => sanitize_text_field($optimization['optimized_data']['default_meta_description'] ?? ''),
550 ],
551 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
552 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
553 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
554 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
555 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
556 ];
557 }
558
559 /**
560 * Optimize homepage meta content using OpenRouter
561 *
562 * @since 1.0.0
563 *
564 * @param array $content_data Meta content data to optimize
565 * @param array $options Optimization options
566 * @return array Optimization results
567 * @throws \Exception If optimization fails
568 */
569 public function optimize_homepage_meta(array $content_data, array $options = []): array {
570 $business_type = $options['business_type'] ?? 'website';
571 $target_audience = $options['target_audience'] ?? 'general';
572 $tone = $options['tone'] ?? 'professional';
573 $context = $options['context'] ?? [];
574
575 $prompt_builder = $this->get_prompt_builder();
576 $prompt = $prompt_builder->build_homepage_meta_prompt($content_data, $business_type, $target_audience, $tone, $context, 'openrouter');
577
578 $body = $this->build_chat_request(
579 $prompt,
580 'You are an expert SEO consultant specializing in homepage meta optimization. Provide actionable, specific recommendations in JSON format.',
581 $this->get_recommended_tokens('optimization'),
582 0.4
583 );
584
585 $response = $this->make_request('chat/completions', $body);
586
587 return $this->parse_homepage_meta_response($response);
588 }
589
590 /**
591 * Optimize homepage hero content using OpenRouter
592 *
593 * @since 1.0.0
594 *
595 * @param array $hero_data Hero content data to optimize
596 * @param array $options Optimization options
597 * @return array Optimization results
598 * @throws \Exception If optimization fails
599 */
600 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
601 $business_type = $options['business_type'] ?? 'website';
602 $target_audience = $options['target_audience'] ?? 'general';
603 $tone = $options['tone'] ?? 'professional';
604 $context = $options['context'] ?? [];
605
606 $prompt_builder = $this->get_prompt_builder();
607 $prompt = $prompt_builder->build_homepage_hero_prompt($hero_data, $business_type, $target_audience, $tone, $context, 'openrouter');
608
609 $body = $this->build_chat_request(
610 $prompt,
611 'You are an expert conversion optimization specialist specializing in homepage hero sections. Provide actionable, specific recommendations in JSON format.',
612 $this->get_recommended_tokens('optimization'),
613 0.4
614 );
615
616 $response = $this->make_request('chat/completions', $body);
617
618 return $this->parse_homepage_hero_response($response);
619 }
620
621 /**
622 * Optimize LLMs.txt content using OpenRouter
623 *
624 * @since 1.0.0
625 *
626 * @param array $website_data Website data to optimize
627 * @param array $options Optimization options
628 * @return array Optimization results
629 * @throws \Exception If optimization fails
630 */
631 public function optimize_llms_txt(array $website_data, array $options = []): array {
632 // Use shared prompt builder for consistent prompts across all AI providers
633 $prompt_builder = $this->get_prompt_builder();
634 $prompt = $prompt_builder->build_llms_txt_prompt($website_data, $options, 'openrouter');
635
636 $body = $this->build_chat_request(
637 $prompt,
638 'You are an expert technical writer specializing in creating llms.txt files for AI assistants. Provide structured, comprehensive content in JSON format.',
639 $this->get_recommended_tokens('llms_txt'),
640 0.4
641 );
642
643 $response = $this->make_request('chat/completions', $body);
644
645 return $this->parse_llms_txt_response($response);
646 }
647
648 /**
649 * Parse LLMs.txt optimization response
650 *
651 * @param array $response OpenRouter API response
652 * @return array Parsed optimization data
653 * @throws \Exception If parsing fails
654 */
655 private function parse_llms_txt_response(array $response): array {
656 if (!isset($response['choices'][0]['message']['content'])) {
657 throw new \Exception('Invalid response format from OpenRouter');
658 }
659
660 $content = trim($response['choices'][0]['message']['content']);
661 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
662
663 // Extract JSON from response
664 $json_start = strpos($content, '{');
665 $json_end = strrpos($content, '}');
666
667 if (false === $json_start || false === $json_end) {
668 throw new \Exception('No valid JSON found in response');
669 }
670
671 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
672 $optimization = json_decode($json_content, true);
673
674 if (json_last_error() !== JSON_ERROR_NONE) {
675 throw new \Exception('Invalid JSON in response: ' . esc_html(json_last_error_msg()));
676 }
677
678 // Validate and sanitize response
679 return [
680 'optimized_data' => [
681 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
682 'project_overview' => sanitize_textarea_field($optimization['optimized_data']['project_overview'] ?? ''),
683 'key_features' => sanitize_textarea_field($optimization['optimized_data']['key_features'] ?? ''),
684 'architecture' => sanitize_textarea_field($optimization['optimized_data']['architecture'] ?? ''),
685 'development_guidelines' => sanitize_textarea_field($optimization['optimized_data']['development_guidelines'] ?? ''),
686 'ai_context' => sanitize_textarea_field($optimization['optimized_data']['ai_context'] ?? ''),
687 ],
688 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
689 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
690 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
691 ];
692 }
693
694 /**
695 * Parse homepage meta optimization response
696 *
697 * @param array $response OpenRouter API response
698 * @return array Parsed optimization data
699 * @throws \Exception If parsing fails
700 */
701 private function parse_homepage_meta_response(array $response): array {
702 if (!isset($response['choices'][0]['message']['content'])) {
703 throw new \Exception('Invalid response format from OpenRouter');
704 }
705
706 $content = trim($response['choices'][0]['message']['content']);
707 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
708
709 // Extract JSON from response
710 $json_start = strpos($content, '{');
711 $json_end = strrpos($content, '}');
712
713 if (false === $json_start || false === $json_end) {
714 throw new \Exception('No valid JSON found in response');
715 }
716
717 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
718 $optimization = json_decode($json_content, true);
719
720 if (json_last_error() !== JSON_ERROR_NONE) {
721 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
722 }
723
724 // Validate and sanitize response
725 return [
726 'optimized_data' => [
727 'title' => sanitize_text_field($optimization['optimized_data']['title'] ?? ''),
728 'meta_description' => sanitize_text_field($optimization['optimized_data']['meta_description'] ?? ''),
729 ],
730 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
731 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
732 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
733 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
734 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
735 ];
736 }
737
738 /**
739 * Parse homepage hero optimization response
740 *
741 * @param array $response OpenRouter API response
742 * @return array Parsed optimization data
743 * @throws \Exception If parsing fails
744 */
745 private function parse_homepage_hero_response(array $response): array {
746 if (!isset($response['choices'][0]['message']['content'])) {
747 throw new \Exception('Invalid response format from OpenRouter');
748 }
749
750 $content = trim($response['choices'][0]['message']['content']);
751 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
752
753 // Extract JSON from response
754 $json_start = strpos($content, '{');
755 $json_end = strrpos($content, '}');
756
757 if (false === $json_start || false === $json_end) {
758 throw new \Exception('No valid JSON found in response');
759 }
760
761 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
762 $optimization = json_decode($json_content, true);
763
764 if (json_last_error() !== JSON_ERROR_NONE) {
765 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
766 }
767
768 // Validate and sanitize response
769 return [
770 'optimized_data' => [
771 'hero_title' => sanitize_text_field($optimization['optimized_data']['hero_title'] ?? ''),
772 'hero_subtitle' => sanitize_text_field($optimization['optimized_data']['hero_subtitle'] ?? ''),
773 'hero_cta_text' => sanitize_text_field($optimization['optimized_data']['hero_cta_text'] ?? '')
774 ],
775 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
776 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
777 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
778 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
779 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
780 ];
781 }
782 }
783