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

776 lines 29.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * 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 = \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL, 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 $language = is_string($options['language'] ?? null) ? $options['language'] : '';
195 $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'claude', $language);
196
197 $response = $this->generate_completion($prompt, [
198 'max_tokens' => 500,
199 'temperature' => 0.3,
200 ]);
201
202 return $this->parse_seo_response($response);
203 }
204
205 /**
206 * Analyze content for SEO optimization
207 *
208 * @param string $content Content to analyze
209 * @param array $metadata Existing metadata
210 * @return array Analysis results
211 * @throws \Exception If analysis fails
212 */
213 public function analyze_content(string $content, array $metadata = []): array {
214 $prompt_builder = $this->get_prompt_builder();
215 $prompt = $prompt_builder->build_analysis_prompt($content, $metadata, 'claude');
216
217 $response = $this->generate_completion($prompt, [
218 'max_tokens' => 800,
219 'temperature' => 0.3,
220 ]);
221
222 return $this->parse_analysis_response($response);
223 }
224
225 /**
226 * Get current model
227 *
228 * @return string Current model name
229 */
230 public function get_model(): string {
231 return $this->model;
232 }
233
234 /**
235 * Test API connection
236 *
237 * @return bool True if connection successful
238 */
239 public function test_connection(): bool {
240 try {
241 // Claude doesn't have a models endpoint, so we'll test with a simple message
242 $response = $this->generate_completion('Hello', ['max_tokens' => 10]);
243 return isset($response['content']) && is_array($response['content']);
244 } catch (\Exception $e) {
245 return false;
246 }
247 }
248
249 /**
250 * Make API request to Claude
251 *
252 * @param string $endpoint API endpoint
253 * @param array $body Request body
254 * @return array Response data
255 * @throws \Exception If request fails
256 */
257 private function make_request(string $endpoint, array $body = []): array {
258 $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
259
260 $args = [
261 'timeout' => $this->timeout,
262 'headers' => [
263 'x-api-key' => $this->api_key,
264 'Content-Type' => 'application/json',
265 'anthropic-version' => '2023-06-01',
266 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
267 ],
268 'method' => 'POST',
269 'body' => wp_json_encode($body),
270 ];
271
272 $response = $this->request_with_retry($url, $args);
273
274 if (is_wp_error($response)) {
275 throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
276 }
277
278 $status_code = wp_remote_retrieve_response_code($response);
279 $response_body = wp_remote_retrieve_body($response);
280
281 if ($status_code >= 400) {
282 $error_data = json_decode($response_body, true);
283 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
284 throw new \Exception(sprintf('Claude API error (%d): %s', (int) $status_code, esc_html($error_message)));
285 }
286
287 $data = json_decode($response_body, true);
288
289 if (json_last_error() !== JSON_ERROR_NONE) {
290 throw new \Exception('Invalid JSON response from Claude API');
291 }
292
293 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
294 // 2xx) would violate this method's : array return type; reject it here so
295 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
296 if (!is_array($data)) {
297 throw new \Exception('Unexpected non-array response from Claude API');
298 }
299
300 return $data;
301 }
302
303 /**
304 * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
305 * per the plugin's retry settings, honoring a Retry-After header when given.
306 *
307 * @param string $url Request URL
308 * @param array $args wp_remote_request arguments
309 * @return array|\WP_Error Final response (or last error after retries)
310 */
311 private function request_with_retry(string $url, array $args) {
312 $settings = \ThinkRank\Core\Settings::instance();
313 $retry_enabled = (bool) $settings->get('retry_failed_requests', true);
314 $max_attempts = $retry_enabled ? max(1, (int) $settings->get('retry_attempts', 3)) : 1;
315
316 $response = null;
317 for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
318 // Keep PHP alive for the whole blocking call (see method docblock).
319 $this->raise_request_time_limit();
320
321 $response = wp_remote_request($url, $args);
322
323 $is_transient = false;
324 $retry_after = 0;
325 if (is_wp_error($response)) {
326 $is_transient = true;
327 } else {
328 $status = wp_remote_retrieve_response_code($response);
329 if (429 === $status || $status >= 500) {
330 $is_transient = true;
331 $retry_after = (int) wp_remote_retrieve_header($response, 'retry-after');
332 }
333 }
334
335 if (!$is_transient || $attempt === $max_attempts) {
336 break;
337 }
338
339 $delay = $retry_after > 0 ? min($retry_after, 30) : min(2 ** ($attempt - 1), 8);
340 sleep($delay);
341 }
342
343 return $response;
344 }
345
346 /**
347 * Give PHP enough execution time to outlive a blocking AI HTTP request.
348 *
349 * The provider call blocks for up to $this->timeout seconds, but the web
350 * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
351 * fatally terminates the script mid-request (inside the cURL transport),
352 * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
353 * before each attempt keeps the script alive for the full call; PHP-FPM's
354 * request_terminate_timeout still caps the absolute maximum. No-op when
355 * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
356 *
357 * @return void
358 */
359 private function raise_request_time_limit(): void {
360 if (function_exists('set_time_limit')) {
361 @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.
362 }
363 }
364
365 /**
366 * Parse SEO response from Claude
367 *
368 * @param array $response Claude response
369 * @return array Parsed metadata
370 * @throws \Exception If parsing fails
371 */
372 private function parse_seo_response(array $response): array {
373 if (!isset($response['content'][0]['text'])) {
374 throw new \Exception('Invalid response format from Claude');
375 }
376
377 $content = $response['content'][0]['text'];
378 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
379
380 // Try to extract JSON from the response
381 $json_start = strpos($content, '{');
382 $json_end = strrpos($content, '}');
383
384 if (false === $json_start || false === $json_end) {
385 throw new \Exception('No valid JSON found in Claude response');
386 }
387
388 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
389 $metadata = json_decode($json_content, true);
390
391 if (json_last_error() !== JSON_ERROR_NONE) {
392 throw new \Exception('Failed to parse JSON from Claude response');
393 }
394
395 // Validate required fields
396 $required_fields = ['title', 'description', 'focus_keyword'];
397 foreach ($required_fields as $field) {
398 if (!isset($metadata[$field])) {
399 throw new \Exception(sprintf('Missing required field: %s', esc_html($field)));
400 }
401 }
402
403 return [
404 'title' => sanitize_text_field($metadata['title']),
405 'description' => sanitize_text_field($metadata['description']),
406 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
407 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
408 'generated_at' => current_time('mysql'),
409 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
410 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
411 ];
412 }
413
414 /**
415 * Parse analysis response from Claude
416 *
417 * @param array $response Claude API response
418 * @return array Parsed analysis data
419 * @throws \Exception If parsing fails
420 */
421 private function parse_analysis_response(array $response): array {
422 if (!isset($response['content'][0]['text'])) {
423 throw new \Exception('Invalid response format from Claude');
424 }
425
426 $content = trim($response['content'][0]['text']);
427 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
428
429 // Extract JSON from response
430 $json_start = strpos($content, '{');
431 $json_end = strrpos($content, '}');
432
433 if (false === $json_start || false === $json_end) {
434 throw new \Exception('No valid JSON found in response');
435 }
436
437 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
438 $analysis = json_decode($json_content, true);
439
440 if (json_last_error() !== JSON_ERROR_NONE) {
441 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
442 }
443
444 // Validate and sanitize response
445 return [
446 'seo_score' => min(100, max(0, (int) ($analysis['seo_score'] ?? 0))),
447 'content_analysis' => [
448 'word_count' => (int) ($analysis['content_analysis']['word_count'] ?? 0),
449 'readability' => sanitize_text_field($analysis['content_analysis']['readability'] ?? 'unknown'),
450 'keyword_density' => sanitize_text_field($analysis['content_analysis']['keyword_density'] ?? 'unknown'),
451 'structure' => sanitize_text_field($analysis['content_analysis']['structure'] ?? 'unknown'),
452 ],
453 'suggestions' => array_map('sanitize_text_field', $analysis['suggestions'] ?? []),
454 'strengths' => array_map('sanitize_text_field', $analysis['strengths'] ?? []),
455 'weaknesses' => array_map('sanitize_text_field', $analysis['weaknesses'] ?? []),
456 'analyzed_at' => current_time('mysql'),
457 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
458 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
459 ];
460 }
461
462 /**
463 * Optimize site identity using Claude
464 *
465 * @since 1.0.0
466 *
467 * @param array $site_data Site data to optimize
468 * @param array $options Optimization options
469 * @return array Optimization results
470 * @throws \Exception If optimization fails
471 */
472 public function optimize_site_identity(array $site_data, array $options = []): array {
473 $business_type = $options['business_type'] ?? 'website';
474 $target_audience = $options['target_audience'] ?? 'general';
475 $tone = $options['tone'] ?? 'professional';
476
477 $prompt_builder = $this->get_prompt_builder();
478 $prompt = $prompt_builder->build_site_identity_prompt($site_data, $business_type, $target_audience, $tone, 'claude');
479
480 $response = $this->make_request('messages', [
481 'model' => $this->model,
482 'max_tokens' => 600,
483 'temperature' => 0.4,
484 'messages' => [
485 [
486 'role' => 'user',
487 'content' => $prompt
488 ]
489 ]
490 ]);
491
492 return $this->parse_site_identity_response($response);
493 }
494
495 /**
496 * Parse site identity optimization response
497 *
498 * @param array $response Claude API response
499 * @return array Parsed optimization data
500 * @throws \Exception If parsing fails
501 */
502 private function parse_site_identity_response(array $response): array {
503 if (!isset($response['content'][0]['text'])) {
504 throw new \Exception('Invalid response format from Claude');
505 }
506
507 $content = trim($response['content'][0]['text']);
508 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
509
510 // Extract JSON from response
511 $json_start = strpos($content, '{');
512 $json_end = strrpos($content, '}');
513
514 if (false === $json_start || false === $json_end) {
515 throw new \Exception('No valid JSON found in response');
516 }
517
518 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
519 $optimization = json_decode($json_content, true);
520
521 if (json_last_error() !== JSON_ERROR_NONE) {
522 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
523 }
524
525 // Validate and sanitize response
526 return [
527 'optimized_data' => [
528 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
529 'site_description' => sanitize_text_field($optimization['optimized_data']['site_description'] ?? ''),
530 'tagline' => sanitize_text_field($optimization['optimized_data']['tagline'] ?? ''),
531 'default_meta_description' => sanitize_text_field($optimization['optimized_data']['default_meta_description'] ?? ''),
532 ],
533 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
534 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
535 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
536 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
537 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
538 ];
539 }
540
541 /**
542 * Optimize homepage meta content using AI (copying Site Identity pattern exactly)
543 *
544 * @since 1.0.0
545 *
546 * @param array $content_data Meta content data to optimize
547 * @param array $options Optimization options
548 * @return array Optimization results
549 * @throws \Exception If optimization fails
550 */
551 public function optimize_homepage_meta(array $content_data, array $options = []): array {
552 $business_type = $options['business_type'] ?? 'website';
553 $target_audience = $options['target_audience'] ?? 'general';
554 $tone = $options['tone'] ?? 'professional';
555 $context = $options['context'] ?? [];
556
557 $prompt_builder = $this->get_prompt_builder();
558 $prompt = $prompt_builder->build_homepage_meta_prompt($content_data, $business_type, $target_audience, $tone, $context, 'claude');
559
560 $response = $this->make_request('messages', [
561 'model' => $this->model,
562 'max_tokens' => 600,
563 'temperature' => 0.4,
564 'messages' => [
565 [
566 'role' => 'user',
567 'content' => $prompt
568 ]
569 ]
570 ]);
571
572 return $this->parse_homepage_meta_response($response);
573 }
574
575 /**
576 * Optimize homepage hero content using AI (copying Site Identity pattern exactly)
577 *
578 * @since 1.0.0
579 *
580 * @param array $hero_data Hero content data to optimize
581 * @param array $options Optimization options
582 * @return array Optimization results
583 * @throws \Exception If optimization fails
584 */
585 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
586 $business_type = $options['business_type'] ?? 'website';
587 $target_audience = $options['target_audience'] ?? 'general';
588 $tone = $options['tone'] ?? 'professional';
589 $context = $options['context'] ?? [];
590
591 $prompt_builder = $this->get_prompt_builder();
592 $prompt = $prompt_builder->build_homepage_hero_prompt($hero_data, $business_type, $target_audience, $tone, $context, 'claude');
593
594 $response = $this->make_request('messages', [
595 'model' => $this->model,
596 'max_tokens' => 600,
597 'temperature' => 0.4,
598 'messages' => [
599 [
600 'role' => 'user',
601 'content' => $prompt
602 ]
603 ]
604 ]);
605
606 return $this->parse_homepage_hero_response($response);
607 }
608
609 /**
610 * Optimize LLMs.txt content using Claude
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, 'claude');
623
624 $response = $this->make_request('messages', [
625 'model' => $this->model,
626 'max_tokens' => 2000, // Increased for consistency with other providers
627 'temperature' => 0.4,
628 'messages' => [
629 [
630 'role' => 'user',
631 'content' => $prompt
632 ]
633 ]
634 ]);
635
636 return $this->parse_llms_txt_response($response);
637 }
638
639
640
641 /**
642 * Parse LLMs.txt optimization response
643 *
644 * @param array $response Claude API response
645 * @return array Parsed optimization data
646 * @throws \Exception If parsing fails
647 */
648 private function parse_llms_txt_response(array $response): array {
649 if (!isset($response['content'][0]['text'])) {
650 throw new \Exception('Invalid response format from Claude');
651 }
652
653 $content = trim($response['content'][0]['text']);
654 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
655
656 // Extract JSON from response
657 $json_start = strpos($content, '{');
658 $json_end = strrpos($content, '}');
659
660 if (false === $json_start || false === $json_end) {
661 throw new \Exception('No valid JSON found in response');
662 }
663
664 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
665 $optimization = json_decode($json_content, true);
666
667 if (json_last_error() !== JSON_ERROR_NONE) {
668 throw new \Exception('Invalid JSON in response: ' . esc_html(json_last_error_msg()));
669 }
670
671 // Validate and sanitize response
672 return [
673 'optimized_data' => [
674 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
675 'project_overview' => sanitize_textarea_field($optimization['optimized_data']['project_overview'] ?? ''),
676 'key_features' => sanitize_textarea_field($optimization['optimized_data']['key_features'] ?? ''),
677 'architecture' => sanitize_textarea_field($optimization['optimized_data']['architecture'] ?? ''),
678 'development_guidelines' => sanitize_textarea_field($optimization['optimized_data']['development_guidelines'] ?? ''),
679 'ai_context' => sanitize_textarea_field($optimization['optimized_data']['ai_context'] ?? ''),
680 ],
681 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
682 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
683 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
684 ];
685 }
686
687 /**
688 * Parse homepage meta optimization response
689 *
690 * @param array $response Claude API response
691 * @return array Parsed optimization data
692 * @throws \Exception If parsing fails
693 */
694 private function parse_homepage_meta_response(array $response): array {
695 if (!isset($response['content'][0]['text'])) {
696 throw new \Exception('Invalid response format from Claude');
697 }
698
699 $content = trim($response['content'][0]['text']);
700 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
701
702 // Extract JSON from response
703 $json_start = strpos($content, '{');
704 $json_end = strrpos($content, '}');
705
706 if (false === $json_start || false === $json_end) {
707 throw new \Exception('No valid JSON found in response');
708 }
709
710 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
711 $optimization = json_decode($json_content, true);
712
713 if (json_last_error() !== JSON_ERROR_NONE) {
714 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
715 }
716
717 // Validate and sanitize response
718 return [
719 'optimized_data' => [
720 'title' => sanitize_text_field($optimization['optimized_data']['title'] ?? ''),
721 'meta_description' => sanitize_text_field($optimization['optimized_data']['meta_description'] ?? ''),
722 ],
723 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
724 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
725 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
726 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
727 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
728 ];
729 }
730
731 /**
732 * Parse homepage hero optimization response
733 *
734 * @param array $response Claude API response
735 * @return array Parsed optimization data
736 * @throws \Exception If parsing fails
737 */
738 private function parse_homepage_hero_response(array $response): array {
739 if (!isset($response['content'][0]['text'])) {
740 throw new \Exception('Invalid response format from Claude');
741 }
742
743 $content = trim($response['content'][0]['text']);
744 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
745
746 // Extract JSON from response
747 $json_start = strpos($content, '{');
748 $json_end = strrpos($content, '}');
749
750 if (false === $json_start || false === $json_end) {
751 throw new \Exception('No valid JSON found in response');
752 }
753
754 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
755 $optimization = json_decode($json_content, true);
756
757 if (json_last_error() !== JSON_ERROR_NONE) {
758 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
759 }
760
761 // Validate and sanitize response
762 return [
763 'optimized_data' => [
764 'hero_title' => sanitize_text_field($optimization['optimized_data']['hero_title'] ?? ''),
765 'hero_subtitle' => sanitize_text_field($optimization['optimized_data']['hero_subtitle'] ?? ''),
766 'hero_cta_text' => sanitize_text_field($optimization['optimized_data']['hero_cta_text'] ?? '')
767 ],
768 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
769 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
770 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
771 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
772 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
773 ];
774 }
775 }
776