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-claude-client.php

class-claude-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-claude-client.php

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