PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.27.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.27.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-content-brief-generator.php

class-content-brief-generator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.27.0, at includes/ai/class-content-brief-generator.php

1,743 lines 67.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Content Brief Generator
4 *
5 * Handles AI-powered content brief generation with competitor analysis
6 *
7 * @package ThinkRank
8 * @subpackage AI
9 * @since 1.0.0
10 */
11
12 namespace ThinkRank\AI;
13
14 use ThinkRank\Core\Settings;
15 use ThinkRank\AI\OpenAI_Client;
16 use ThinkRank\AI\Claude_Client;
17 use ThinkRank\AI\OpenRouter_Client;
18
19 // Prevent direct access
20 if (!defined('ABSPATH')) {
21 exit;
22 }
23
24 /**
25 * Content Brief Generator class
26 */
27 class Content_Brief_Generator {
28
29 /**
30 * AI request timeout (seconds) for brief generation.
31 *
32 * Content briefs request a very large completion (~0.9–0.95 of the model's
33 * max tokens) from reasoning models, which routinely take 40–90s — far
34 * longer than the AI clients' 30s default. Without this the HTTP call is
35 * aborted with cURL error 28 and the brief never completes. PHP execution
36 * time is covered by each client's raise_request_time_limit() (timeout+45).
37 */
38 private const AI_REQUEST_TIMEOUT = 120;
39
40 /**
41 * AI request timeout (seconds) for OpenAI specifically.
42 *
43 * OpenAI's reasoning models (GPT-5 / o-series) burn reasoning tokens before
44 * emitting any content, and briefs request ~95% of the model's completion
45 * limit — so the call frequently runs past the 120s the other providers
46 * need. PHP execution time is covered by raise_request_time_limit()
47 * (timeout+45); the web server's own read timeout still caps the maximum.
48 */
49 private const OPENAI_REQUEST_TIMEOUT = 300;
50
51 /**
52 * Settings instance
53 *
54 * @var Settings
55 */
56 private Settings $settings;
57
58 /**
59 * AI client instance
60 *
61 * @var OpenAI_Client|Claude_Client
62 */
63 private $ai_client;
64
65 /**
66 * Constructor
67 *
68 * @param Settings|null $settings Settings instance
69 * @param OpenAI_Client|Claude_Client|null $ai_client AI client instance
70 */
71 public function __construct(?Settings $settings = null, $ai_client = null) {
72 $this->settings = $settings ?? Settings::instance();
73
74 if ($ai_client) {
75 $this->ai_client = $ai_client;
76 } else {
77 // Fallback to creating own client for backward compatibility
78 $this->init_ai_client();
79 }
80 }
81
82 /**
83 * Initialize AI client based on available API keys
84 *
85 * @return void
86 */
87 private function init_ai_client(): void {
88 $provider = $this->settings->get('ai_provider', 'openai');
89
90 if ($provider === 'openai') {
91 $api_key = $this->settings->get('openai_api_key');
92 if ($api_key) {
93 $model = $this->settings->get('openai_model', 'gpt-5-nano');
94 $this->ai_client = new OpenAI_Client($api_key, $model, self::OPENAI_REQUEST_TIMEOUT);
95 }
96 } elseif ($provider === 'claude') {
97 $api_key = $this->settings->get('claude_api_key');
98 if ($api_key) {
99 $model = $this->settings->get('claude_model', 'claude-3.7-sonnet');
100 $this->ai_client = new Claude_Client($api_key, $model, self::AI_REQUEST_TIMEOUT);
101 }
102 } elseif ($provider === 'gemini') {
103 $api_key = $this->settings->get('gemini_api_key');
104 if ($api_key) {
105 $model = $this->settings->get('gemini_model', 'gemini-2.5-flash');
106 $this->ai_client = new Gemini_Client($api_key, $model, self::AI_REQUEST_TIMEOUT);
107 }
108 } elseif ($provider === 'openrouter') {
109 $api_key = $this->settings->get('openrouter_api_key');
110 if ($api_key) {
111 $model = $this->settings->get('openrouter_model', 'openai/gpt-4o-mini');
112 $this->ai_client = new OpenRouter_Client($api_key, $model, self::AI_REQUEST_TIMEOUT);
113 }
114 }
115
116 if (!$this->ai_client) {
117 throw new \Exception('Please configure your AI provider API key in ThinkRank settings.');
118 }
119 }
120
121 /**
122 * Get current AI model being used
123 *
124 * @return string Current model name
125 */
126 private function get_current_model(): string {
127 // Try to get model from the actual AI client if available
128 if ($this->ai_client && method_exists($this->ai_client, 'get_model')) {
129 return $this->ai_client->get_model();
130 }
131
132 // Fallback to settings
133 $provider = $this->settings->get('ai_provider', 'openai');
134 if ($provider === 'claude') {
135 return $this->settings->get('claude_model', 'claude-sonnet-5');
136 } elseif ($provider === 'gemini') {
137 return $this->settings->get('gemini_model', 'gemini-2.5-flash');
138 } elseif ($provider === 'openrouter') {
139 return $this->settings->get('openrouter_model', 'openai/gpt-4o-mini');
140 } else {
141 return $this->settings->get('openai_model', 'gpt-5-nano');
142 }
143 }
144
145 /**
146 * Get current AI provider
147 *
148 * @return string Current provider name
149 */
150 private function get_current_provider(): string {
151 return $this->settings->get('ai_provider', 'openai');
152 }
153
154 /**
155 * Extract token usage from AI response
156 *
157 * @param array $ai_response AI response data
158 * @return int Number of tokens used
159 */
160 private function extract_token_usage(array $ai_response): int {
161 $provider = $this->get_current_provider();
162
163 if ($provider === 'openai' || $provider === 'openrouter') {
164 // OpenAI-compatible format: response['usage']['total_tokens']
165 return (int) ($ai_response['usage']['total_tokens'] ?? 0);
166 } elseif ($provider === 'claude') {
167 // Claude format: response['usage']['input_tokens'] + response['usage']['output_tokens']
168 $input_tokens = (int) ($ai_response['usage']['input_tokens'] ?? 0);
169 $output_tokens = (int) ($ai_response['usage']['output_tokens'] ?? 0);
170 return $input_tokens + $output_tokens;
171 } elseif ($provider === 'gemini') {
172 // Gemini format: response['usageMetadata']['totalTokenCount']
173 return (int) ($ai_response['usageMetadata']['totalTokenCount'] ?? 0);
174 }
175
176 // Fallback: return 0 if provider not recognized or no usage data
177 return 0;
178 }
179
180 /**
181 * Extract actual model used from AI response
182 *
183 * @param array $ai_response AI response data
184 * @return string|null Actual model used or null if not found
185 */
186 private function extract_model_from_response(array $ai_response): ?string {
187 // OpenAI format: response['model']
188 if (isset($ai_response['model'])) {
189 return $ai_response['model'];
190 }
191
192 // Claude format: response['model']
193 if (isset($ai_response['model'])) {
194 return $ai_response['model'];
195 }
196
197 // Gemini doesn't include model in response, fallback to client model
198 return null;
199 }
200
201 /**
202 * Generate content brief
203 *
204 * @param array $params Brief generation parameters
205 * @return array Generated brief data
206 * @throws \Exception If generation fails
207 */
208 public function generate_brief(array $params): array {
209 // Validate required parameters
210 $this->validate_brief_params($params);
211
212 // Extract parameters
213 $target_keywords = $params['target_keywords'] ?? [];
214 $content_type = $params['content_type'] ?? 'blog_post';
215 $target_audience = $params['target_audience'] ?? 'general';
216 $content_length = $params['content_length'] ?? 'medium';
217 $tone = $params['tone'] ?? 'professional';
218 $competitor_urls = $params['competitor_urls'] ?? [];
219 $additional_context = $params['additional_context'] ?? '';
220 // Write the brief in the site (or related post's) language rather than
221 // defaulting to English on non-English sites (issue #234).
222 $language = \ThinkRank\AI\Language_Resolver::resolve((int) ($params['post_id'] ?? 0));
223
224 // Analyze competitor URLs if provided
225 $competitor_analysis = '';
226 if (!empty($competitor_urls)) {
227 $competitor_analysis = $this->analyze_competitor_urls($competitor_urls);
228 }
229
230 // Build AI prompt using shared Prompt Builder
231 $prompt_builder = $this->get_prompt_builder();
232 $prompt = $prompt_builder->build_content_brief_prompt(
233 $target_keywords,
234 $content_type,
235 $target_audience,
236 $content_length,
237 $tone,
238 $competitor_analysis,
239 $additional_context,
240 $this->get_current_provider(),
241 $language
242 );
243
244 try {
245 // Get recommended token limit for content briefs (model-specific)
246 $max_tokens = method_exists($this->ai_client, 'get_recommended_tokens')
247 ? $this->ai_client->get_recommended_tokens('content_brief')
248 : 4000; // Fallback for non-OpenAI clients
249
250 // Generate brief using AI
251 $ai_response = $this->ai_client->generate_completion($prompt, [
252 // For GPT‑5 family the client will translate to max_completion_tokens internally
253 'max_tokens' => $max_tokens,
254 'temperature' => 0.7,
255 ]);
256
257 // Extract text content from AI response
258 $ai_text = '';
259
260 // Handle OpenAI response format
261 if (isset($ai_response['choices'][0]['message']['content'])) {
262 $contentField = $ai_response['choices'][0]['message']['content'];
263 if (is_string($contentField)) {
264 $ai_text = $contentField;
265 } elseif (is_array($contentField)) {
266 // Concatenate text parts from array-based content (Chat Completions multimodal)
267 $parts = array_map(function($part) {
268 if (is_array($part)) {
269 return $part['text'] ?? '';
270 }
271 return is_string($part) ? $part : '';
272 }, $contentField);
273 $ai_text = trim(implode("\n", array_filter($parts)));
274 }
275 }
276 // Handle Claude response format
277 elseif (isset($ai_response['content'][0]['text'])) {
278 $ai_text = $ai_response['content'][0]['text'];
279 }
280 // Handle Gemini response format
281 elseif (isset($ai_response['candidates'][0]['content']['parts'][0]['text'])) {
282 $ai_text = $ai_response['candidates'][0]['content']['parts'][0]['text'];
283
284 // A reply cut off at the token limit is incomplete JSON, so it
285 // can never be parsed. Fail with the real reason instead of
286 // saving a brief titled "Unable to parse AI response".
287 if (($ai_response['candidates'][0]['finishReason'] ?? '') === 'MAX_TOKENS') {
288 throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.');
289 }
290 }
291 // Handle direct content field
292 elseif (isset($ai_response['content']) && is_string($ai_response['content'])) {
293 $ai_text = $ai_response['content'];
294 }
295 // Handle direct string response
296 elseif (is_string($ai_response)) {
297 $ai_text = $ai_response;
298 }
299 // If we still don't have text, log the response structure for debugging
300 else {
301 // As a last resort, stringify the response for visibility (prevents empty content error)
302 $ai_text = is_array($ai_response) ? wp_json_encode($ai_response) : (string) $ai_response;
303 }
304
305 // Ensure we have actual text content
306 if (empty(trim($ai_text))) {
307 throw new \Exception('AI response was empty or contained no text content.');
308 }
309
310 // Extract token usage for analytics tracking
311 $tokens_used = $this->extract_token_usage($ai_response);
312
313 // Parse and structure the response
314 $brief_data = $this->parse_ai_response($ai_text, $params);
315
316 // Extract actual model from response before using it
317 $actual_model = $this->extract_model_from_response($ai_response);
318
319 // Add generation metadata (use actual model from response if available)
320 $brief_data['generation_meta'] = [
321 'provider' => $this->get_current_provider(),
322 'model' => $actual_model ?: $this->get_current_model(),
323 'generated_at' => current_time('mysql'),
324 'version' => '1.0'
325 ];
326
327 // Save brief to database
328 $brief_id = $this->save_brief($brief_data);
329 $brief_data['id'] = $brief_id;
330
331 // Log AI usage for analytics tracking (including raw response and actual model used)
332 $usage_id = $this->log_ai_usage(get_current_user_id(), 'Content Brief', $tokens_used, $brief_id, $ai_text, $actual_model);
333
334 // Set raw response for immediate display
335 $brief_data['raw_response'] = $ai_text;
336
337 // Apply normalization for React compatibility
338 $brief_data = $this->normalize_brief_data($brief_data);
339
340 return $brief_data;
341
342 } catch (\Exception $e) {
343 // Provide more specific error messages
344 $error_message = $e->getMessage();
345 if (strpos($error_message, 'API key') !== false) {
346 throw new \Exception('API key configuration error. Please check your AI provider settings.');
347 } elseif (strpos($error_message, 'Invalid AI response format') !== false) {
348 throw new \Exception('AI service returned an unexpected response format. Please try again.');
349 } elseif (strpos($error_message, 'empty') !== false) {
350 throw new \Exception('AI service returned empty content. Please try again with different parameters.');
351 } else {
352 throw new \Exception('Failed to generate content brief: ' . esc_html($error_message));
353 }
354 }
355 }
356
357 /**
358 * Validate brief generation parameters
359 *
360 * @param array $params Parameters to validate
361 * @throws \Exception If validation fails
362 */
363 private function validate_brief_params(array $params): void {
364 if (empty($params['target_keywords']) || !is_array($params['target_keywords'])) {
365 throw new \Exception('Target keywords are required and must be an array.');
366 }
367
368 $valid_content_types = ['blog_post', 'product_page', 'landing_page', 'tutorial'];
369 if (!empty($params['content_type']) && !in_array($params['content_type'], $valid_content_types)) {
370 throw new \Exception('Invalid content type specified.');
371 }
372
373 $valid_lengths = ['short', 'medium', 'long'];
374 if (!empty($params['content_length']) && !in_array($params['content_length'], $valid_lengths)) {
375 throw new \Exception('Invalid content length specified.');
376 }
377
378 $valid_tones = ['professional', 'casual', 'technical', 'friendly'];
379 if (!empty($params['tone']) && !in_array($params['tone'], $valid_tones)) {
380 throw new \Exception('Invalid tone specified.');
381 }
382 }
383
384 /**
385 * Parse AI response into structured data
386 *
387 * @param string $ai_response Raw AI response
388 * @param array $original_params Original generation parameters
389 * @return array Structured brief data
390 */
391 private function parse_ai_response(string $ai_response, array $original_params): array {
392 $json_data = $this->parse_json_response($ai_response);
393
394 if (null === $json_data) {
395 // JSON parsing failed - return error structure
396 return $this->create_parsing_error_response($ai_response, $original_params);
397 }
398
399 return $this->structure_json_data($json_data, $original_params);
400 }
401
402 /**
403 * Parse JSON response from AI
404 *
405 * @param string $ai_response Raw AI response
406 * @return array|null Parsed JSON data or null if parsing fails
407 */
408 private function parse_json_response(string $ai_response): ?array {
409 // Clean the response - remove any text before/after JSON
410 $ai_response = trim($ai_response);
411
412 // Handle markdown code blocks (```json ... ```)
413 if (preg_match('/```(?:json)?\s*\n?(.*?)\n?```/s', $ai_response, $matches)) {
414 $json_string = trim($matches[1]);
415 } else {
416 // Find JSON object boundaries
417 $start = strpos($ai_response, '{');
418 $end = strrpos($ai_response, '}');
419
420 if (false === $start || false === $end || $start >= $end) {
421 return null;
422 }
423
424 $json_string = substr($ai_response, $start, $end - $start + 1);
425 }
426
427 $json_data = json_decode($json_string, true);
428
429 if (json_last_error() !== JSON_ERROR_NONE) {
430 return null;
431 }
432
433 return $json_data;
434 }
435
436 /**
437 * Structure JSON data into expected format
438 *
439 * @param array $json_data Parsed JSON data
440 * @param array $original_params Original generation parameters
441 * @return array Structured brief data
442 */
443 private function structure_json_data(array $json_data, array $original_params): array {
444 return [
445 'title' => $json_data['title_suggestions'] ?? [],
446 'meta_description' => $json_data['meta_descriptions'][0] ?? '',
447 'meta_descriptions' => $json_data['meta_descriptions'] ?? [],
448 'url_slugs' => $json_data['url_slugs'] ?? [],
449 'outline' => $json_data['outline'] ?? [],
450 'seo_recommendations' => [
451 'title_suggestions' => $json_data['title_suggestions'] ?? [],
452 'meta_description' => $json_data['meta_descriptions'][0] ?? '',
453 'meta_descriptions' => $json_data['meta_descriptions'] ?? [],
454 'url_slugs' => $json_data['url_slugs'] ?? [],
455 'focus_keyword_analysis' => $this->normalize_focus_keyword_analysis($json_data['focus_keyword_analysis'] ?? []),
456 'internal_links' => $json_data['internal_linking'] ?? [],
457 'related_keywords' => $json_data['related_keywords'] ?? [],
458 'long_tail_keywords' => []
459 ],
460 'social_media' => $json_data['social_media'] ?? [
461 'open_graph' => ['title' => '', 'description' => ''],
462 'twitter_card' => ['title' => '', 'description' => '']
463 ],
464 'schema_markup' => $json_data['schema_markup'] ?? [
465 'recommended_types' => [],
466 'key_properties' => [],
467 'faq_questions' => []
468 ],
469 'visual_content' => $json_data['visual_content'] ?? [
470 'image_recommendations' => [],
471 'alt_text_suggestions' => [],
472 'infographic_opportunities' => []
473 ],
474 'competitor_gaps' => $json_data['competitor_analysis']['content_gaps'] ?? [],
475 'call_to_actions' => $json_data['call_to_actions'] ?? [],
476 'writing_guidelines' => $json_data['writing_guidelines'] ?? [],
477 'content_body' => $json_data['content_body'] ?? '',
478 'estimated_word_count' => $this->get_word_count_estimate($original_params['content_length'] ?? 'medium'),
479 'raw_response' => '', // Will be retrieved from ai_usage table
480 'generation_params' => $original_params,
481 'parsing_status' => 'success',
482 'created_at' => current_time('mysql')
483 ];
484 }
485
486 /**
487 * Create error response when JSON parsing fails
488 *
489 * @param string $ai_response Raw AI response
490 * @param array $original_params Original generation parameters
491 * @return array Error response structure
492 */
493 private function create_parsing_error_response(string $ai_response, array $original_params): array {
494 return [
495 'title' => ['Error: Unable to parse AI response'],
496 'meta_description' => 'AI response could not be parsed as valid JSON.',
497 'meta_descriptions' => ['AI response could not be parsed as valid JSON.'],
498 'url_slugs' => ['error-parsing-response'],
499 'outline' => [],
500 'seo_recommendations' => [
501 'title_suggestions' => ['Error: Unable to parse AI response'],
502 'meta_description' => 'AI response could not be parsed as valid JSON.',
503 'meta_descriptions' => ['AI response could not be parsed as valid JSON.'],
504 'url_slugs' => ['error-parsing-response'],
505 'focus_keyword_analysis' => [
506 'primary_placement' => [],
507 'secondary_integration' => [],
508 'density_guidelines' => []
509 ],
510 'internal_links' => [],
511 'related_keywords' => [],
512 'long_tail_keywords' => []
513 ],
514 'social_media' => [
515 'open_graph' => ['title' => 'Error', 'description' => 'Parsing failed'],
516 'twitter_card' => ['title' => 'Error', 'description' => 'Parsing failed']
517 ],
518 'schema_markup' => [
519 'recommended_types' => [],
520 'key_properties' => [],
521 'faq_questions' => []
522 ],
523 'visual_content' => [
524 'image_recommendations' => [],
525 'alt_text_suggestions' => [],
526 'infographic_opportunities' => []
527 ],
528 'competitor_gaps' => [],
529 'call_to_actions' => [],
530 'writing_guidelines' => [],
531 'content_body' => '',
532 'estimated_word_count' => $this->get_word_count_estimate($original_params['content_length'] ?? 'medium'),
533 'raw_response' => $ai_response, // Store raw response in error case
534 'generation_params' => $original_params,
535 'parsing_status' => 'failed',
536 'created_at' => current_time('mysql')
537 ];
538 }
539
540 /**
541 * Normalize focus keyword analysis to ensure proper array structure
542 *
543 * @param array $focus_keyword_analysis Raw focus keyword analysis data
544 * @return array Normalized focus keyword analysis
545 */
546 private function normalize_focus_keyword_analysis(array $focus_keyword_analysis): array {
547 $normalized = [
548 'primary_placement' => [],
549 'secondary_integration' => [],
550 'density_guidelines' => []
551 ];
552
553 // Normalize primary_placement
554 if (isset($focus_keyword_analysis['primary_placement'])) {
555 if (is_array($focus_keyword_analysis['primary_placement'])) {
556 $normalized['primary_placement'] = $focus_keyword_analysis['primary_placement'];
557 } elseif (is_string($focus_keyword_analysis['primary_placement'])) {
558 // Convert string to array by splitting on common delimiters
559 $normalized['primary_placement'] = array_filter(array_map('trim', preg_split('/[,;]/', $focus_keyword_analysis['primary_placement'])));
560 }
561 }
562
563 // Normalize secondary_integration - this is the problematic field
564 if (isset($focus_keyword_analysis['secondary_integration'])) {
565 if (is_array($focus_keyword_analysis['secondary_integration'])) {
566 $normalized['secondary_integration'] = $focus_keyword_analysis['secondary_integration'];
567 } elseif (is_string($focus_keyword_analysis['secondary_integration'])) {
568 // Convert string to array - split by sentences or use as single item
569 $text = trim($focus_keyword_analysis['secondary_integration']);
570 if (!empty($text)) {
571 // Split by sentences if it contains periods, otherwise use as single item
572 if (strpos($text, '.') !== false) {
573 $sentences = array_filter(array_map('trim', explode('.', $text)));
574 $normalized['secondary_integration'] = array_map(function($sentence) {
575 return $sentence . (substr($sentence, -1) !== '.' ? '.' : '');
576 }, $sentences);
577 } else {
578 $normalized['secondary_integration'] = [$text];
579 }
580 }
581 }
582 }
583
584 // Normalize density_guidelines
585 if (isset($focus_keyword_analysis['density_guidelines'])) {
586 if (is_array($focus_keyword_analysis['density_guidelines'])) {
587 $normalized['density_guidelines'] = $focus_keyword_analysis['density_guidelines'];
588 } elseif (is_string($focus_keyword_analysis['density_guidelines'])) {
589 // Convert string to array by splitting on common delimiters
590 $guidelines = array_filter(array_map('trim', preg_split('/[,;]/', $focus_keyword_analysis['density_guidelines'])));
591 $normalized['density_guidelines'] = $guidelines ?: [$focus_keyword_analysis['density_guidelines']];
592 }
593 }
594
595 return $normalized;
596 }
597 private function analyze_competitor_urls(array $urls): string {
598 $analysis_results = [];
599 $failed_urls = [];
600
601 // Limit to first 3 URLs to prevent timeout
602 $urls = array_slice($urls, 0, 3);
603
604 foreach ($urls as $url) {
605 $url = trim($url);
606 if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
607 $failed_urls[] = $url . " (invalid URL)";
608 continue;
609 }
610
611 // SSRF guard: only fetch public http/https hosts. Blocks loopback,
612 // link-local (cloud metadata), private and reserved ranges before any
613 // request is made.
614 if (!$this->is_safe_public_url($url)) {
615 $failed_urls[] = $url . " (blocked: non-public host)";
616 continue;
617 }
618
619 $content_data = $this->scrape_competitor_content($url);
620 if ($content_data) {
621 $analysis_results[] = $this->format_competitor_analysis($url, $content_data);
622 } else {
623 $failed_urls[] = $url . " (failed to scrape)";
624 }
625 }
626
627 $result = "";
628
629 if (!empty($analysis_results)) {
630 $result .= implode("\n\n", $analysis_results);
631 }
632
633 if (!empty($failed_urls)) {
634 $result .= "\n\nNote: The following URLs could not be analyzed:\n";
635 $result .= "- " . implode("\n- ", $failed_urls);
636 }
637
638 if (empty($analysis_results)) {
639 return "No competitor URLs could be successfully analyzed. Please ensure URLs are accessible and valid.";
640 }
641
642 return $result;
643 }
644
645 /**
646 * Whether a competitor URL is safe to fetch: an http/https URL whose host
647 * resolves only to public IP addresses.
648 *
649 * Prevents SSRF — a low-privilege user could otherwise point competitor
650 * scraping at loopback, link-local (e.g. 169.254.169.254 cloud metadata),
651 * private or reserved addresses to probe internal services.
652 *
653 * @param string $url URL to validate.
654 * @return bool True when safe to fetch.
655 */
656 private function is_safe_public_url(string $url): bool {
657 $parts = wp_parse_url($url);
658 if (empty($parts['scheme']) || empty($parts['host'])) {
659 return false;
660 }
661
662 // Only ever fetch over http/https.
663 if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
664 return false;
665 }
666
667 $host = $parts['host'];
668
669 // Resolve the host to the IP(s) it points at (literal IPs pass through).
670 $ips = [];
671 if (filter_var($host, FILTER_VALIDATE_IP)) {
672 $ips[] = $host;
673 } else {
674 $records = @dns_get_record($host, DNS_A + DNS_AAAA);
675 if (is_array($records)) {
676 foreach ($records as $record) {
677 if (!empty($record['ip'])) {
678 $ips[] = $record['ip'];
679 } elseif (!empty($record['ipv6'])) {
680 $ips[] = $record['ipv6'];
681 }
682 }
683 }
684 // Fallback for hosts dns_get_record can't resolve.
685 if (empty($ips)) {
686 $resolved = gethostbyname($host);
687 if ($resolved !== $host) {
688 $ips[] = $resolved;
689 }
690 }
691 }
692
693 // Unresolvable host → don't fetch.
694 if (empty($ips)) {
695 return false;
696 }
697
698 // Reject if any resolved address is private, reserved, loopback or link-local.
699 foreach ($ips as $ip) {
700 if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
701 return false;
702 }
703 }
704
705 return true;
706 }
707
708 /**
709 * Scrape content from a competitor URL
710 *
711 * @param string $url The URL to scrape
712 * @return array|null Content data or null if failed
713 */
714 private function scrape_competitor_content(string $url): ?array {
715 // wp_safe_remote_get() (reject_unsafe_urls) re-validates the URL and, unlike
716 // wp_remote_get(), rejects redirects to internal/private hosts — closing the
717 // redirect-based SSRF bypass on top of the pre-flight is_safe_public_url() check.
718 $response = wp_safe_remote_get($url, [
719 'timeout' => 8, // Reduced from 15 to 8 seconds
720 'user-agent' => 'Mozilla/5.0 (compatible; ThinkRank SEO Bot)',
721 'headers' => [
722 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
723 'Accept-Language' => 'en-US,en;q=0.5',
724 ]
725 ]);
726
727 if (is_wp_error($response)) {
728 return null;
729 }
730
731 $status_code = wp_remote_retrieve_response_code($response);
732 if ($status_code !== 200) {
733 return null;
734 }
735
736 $html = wp_remote_retrieve_body($response);
737 if (empty($html)) {
738 return null;
739 }
740
741 return $this->parse_html_content($html, $url);
742 }
743
744 /**
745 * Parse HTML content and extract key SEO elements
746 *
747 * @param string $html HTML content
748 * @param string $url Original URL for context
749 * @return array Parsed content data
750 */
751 private function parse_html_content(string $html, string $url): array {
752 // Create DOMDocument to parse HTML
753 $dom = new \DOMDocument();
754
755 // Suppress warnings for malformed HTML
756 libxml_use_internal_errors(true);
757 $dom->loadHTML('<?xml encoding="UTF-8">' . $html);
758 libxml_clear_errors();
759
760 $xpath = new \DOMXPath($dom);
761
762 // Extract title
763 $title_nodes = $xpath->query('//title');
764 $title = $title_nodes->length > 0 ? trim($title_nodes->item(0)->textContent) : '';
765
766 // Extract meta description
767 $meta_desc_nodes = $xpath->query('//meta[@name="description"]/@content');
768 $meta_description = $meta_desc_nodes->length > 0 ? trim($meta_desc_nodes->item(0)->textContent) : '';
769
770 // Extract headings (H1-H6)
771 $headings = [];
772 for ($i = 1; $i <= 6; $i++) {
773 $heading_nodes = $xpath->query("//h{$i}");
774 foreach ($heading_nodes as $node) {
775 $text = trim($node->textContent);
776 if (!empty($text)) {
777 $headings["h{$i}"][] = $text;
778 }
779 }
780 }
781
782 // Extract body text and calculate word count
783 $body_nodes = $xpath->query('//body');
784 $body_text = '';
785 if ($body_nodes->length > 0) {
786 $body_text = $this->extract_clean_text($body_nodes->item(0));
787 }
788
789 $word_count = str_word_count($body_text);
790
791 // Extract meta keywords if present
792 $meta_keywords_nodes = $xpath->query('//meta[@name="keywords"]/@content');
793 $meta_keywords = $meta_keywords_nodes->length > 0 ? trim($meta_keywords_nodes->item(0)->textContent) : '';
794
795 // Extract internal links count
796 $internal_links = $xpath->query('//a[starts-with(@href, "/") or contains(@href, "' . wp_parse_url($url, PHP_URL_HOST) . '")]');
797 $internal_link_count = $internal_links->length;
798
799 // Extract external links count
800 $external_links = $xpath->query('//a[starts-with(@href, "http") and not(contains(@href, "' . wp_parse_url($url, PHP_URL_HOST) . '"))]');
801 $external_link_count = $external_links->length;
802
803 // Extract images count and alt text analysis
804 $images = $xpath->query('//img');
805 $image_count = $images->length;
806 $images_with_alt = $xpath->query('//img[@alt and @alt!=""]');
807 $images_with_alt_count = $images_with_alt->length;
808
809 // Extract schema markup
810 $schema_scripts = $xpath->query('//script[@type="application/ld+json"]');
811 $has_schema = $schema_scripts->length > 0;
812
813 // Extract last modified date if available
814 $last_modified_nodes = $xpath->query('//meta[@name="last-modified"]/@content | //meta[@property="article:modified_time"]/@content');
815 $last_modified = $last_modified_nodes->length > 0 ? $last_modified_nodes->item(0)->textContent : '';
816
817 // Calculate readability metrics
818 $readability_score = $this->calculate_readability_score($body_text);
819
820 // Extract keyword density for target keywords (if provided)
821 $keyword_density = $this->analyze_keyword_density($body_text, $title);
822
823 // Detect content freshness indicators
824 $freshness_indicators = $this->detect_freshness_indicators($html, $body_text);
825
826 return [
827 'url' => $url,
828 'title' => $title,
829 'meta_description' => $meta_description,
830 'meta_keywords' => $meta_keywords,
831 'headings' => $headings,
832 'word_count' => $word_count,
833 'internal_links' => $internal_link_count,
834 'external_links' => $external_link_count,
835 'images' => [
836 'total' => $image_count,
837 'with_alt' => $images_with_alt_count,
838 'alt_ratio' => $image_count > 0 ? round(($images_with_alt_count / $image_count) * 100, 1) : 0
839 ],
840 'seo' => [
841 'has_schema' => $has_schema,
842 'title_length' => strlen($title),
843 'meta_desc_length' => strlen($meta_description),
844 'title_score' => $this->score_title_seo($title),
845 'meta_desc_score' => $this->score_meta_description($meta_description)
846 ],
847 'content_quality' => [
848 'readability_score' => $readability_score,
849 'keyword_density' => $keyword_density,
850 'freshness_indicators' => $freshness_indicators,
851 'content_depth' => $this->assess_content_depth($headings, $word_count)
852 ],
853 'last_modified' => $last_modified,
854 'content_preview' => substr($body_text, 0, 500) . '...',
855 'analysis_timestamp' => current_time('mysql')
856 ];
857 }
858
859 /**
860 * Extract clean text from DOM node, removing scripts and styles
861 *
862 * @param \DOMNode $node DOM node to extract text from
863 * @return string Clean text content
864 */
865 private function extract_clean_text(\DOMNode $node): string {
866 // Remove script and style elements
867 $xpath = new \DOMXPath($node->ownerDocument);
868 $scripts = $xpath->query('.//script | .//style', $node);
869
870 foreach ($scripts as $script) {
871 $script->parentNode->removeChild($script);
872 }
873
874 // Get text content and clean it up
875 $text = $node->textContent;
876
877 // Remove extra whitespace and normalize
878 $text = preg_replace('/\s+/', ' ', $text);
879 $text = trim($text);
880
881 return $text;
882 }
883
884 /**
885 * Format competitor analysis for AI prompt
886 *
887 * @param string $url Competitor URL
888 * @param array $content_data Parsed content data
889 * @return string Formatted analysis
890 */
891 private function format_competitor_analysis(string $url, array $content_data): string {
892 $analysis = "=== COMPETITOR ANALYSIS ===\n";
893 $analysis .= "URL: {$url}\n";
894 $analysis .= "Title: {$content_data['title']} (Length: {$content_data['seo']['title_length']} chars, Score: {$content_data['seo']['title_score']['grade']})\n";
895
896 if (!empty($content_data['meta_description'])) {
897 $analysis .= "Meta Description: {$content_data['meta_description']} (Length: {$content_data['seo']['meta_desc_length']} chars, Score: {$content_data['seo']['meta_desc_score']['grade']})\n";
898 }
899
900 $analysis .= "\nCONTENT METRICS:\n";
901 $analysis .= "- Word Count: {$content_data['word_count']} words\n";
902 $analysis .= "- Content Depth: {$content_data['content_quality']['content_depth']['level']} (Score: {$content_data['content_quality']['content_depth']['score']}/100)\n";
903 $analysis .= "- Readability: {$content_data['content_quality']['readability_score']['level']} (Score: {$content_data['content_quality']['readability_score']['score']}/100)\n";
904 $analysis .= "- Internal Links: {$content_data['internal_links']}\n";
905 $analysis .= "- External Links: {$content_data['external_links']}\n";
906 $analysis .= "- Images: {$content_data['images']['total']} total, {$content_data['images']['with_alt']} with alt text ({$content_data['images']['alt_ratio']}%)\n";
907
908 // Add heading structure
909 if (!empty($content_data['headings'])) {
910 $analysis .= "\nCONTENT STRUCTURE:\n";
911 foreach ($content_data['headings'] as $level => $headings) {
912 $analysis .= "- " . strtoupper($level) . " ({count}): " . implode(', ', array_slice($headings, 0, 3));
913 if (count($headings) > 3) {
914 $analysis .= "... (+" . (count($headings) - 3) . " more)";
915 }
916 $analysis .= "\n";
917 }
918 }
919
920 // Add SEO features
921 $analysis .= "\nSEO FEATURES:\n";
922 $analysis .= "- Schema Markup: " . ($content_data['seo']['has_schema'] ? 'Yes' : 'No') . "\n";
923 if (!empty($content_data['meta_keywords'])) {
924 $analysis .= "- Meta Keywords: {$content_data['meta_keywords']}\n";
925 }
926
927 // Add content quality insights
928 if (!empty($content_data['content_quality']['keyword_density']['top_keywords'])) {
929 $analysis .= "\nTOP KEYWORDS:\n";
930 foreach (array_slice($content_data['content_quality']['keyword_density']['top_keywords'], 0, 5) as $kw) {
931 $analysis .= "- {$kw['keyword']}: {$kw['count']} times ({$kw['density']}%)\n";
932 }
933 }
934
935 // Add freshness indicators
936 if (!empty($content_data['content_quality']['freshness_indicators'])) {
937 $analysis .= "\nCONTENT FRESHNESS:\n";
938 foreach ($content_data['content_quality']['freshness_indicators'] as $indicator) {
939 $analysis .= "- {$indicator}\n";
940 }
941 }
942
943 $analysis .= "\n" . str_repeat("=", 50) . "\n";
944
945 return $analysis;
946 }
947
948 /**
949 * Get word count estimate based on content length
950 *
951 * @param string $content_length Content length setting
952 * @return int Estimated word count
953 */
954 private function get_word_count_estimate(string $content_length): int {
955 $estimates = [
956 'short' => 650,
957 'medium' => 1250,
958 'long' => 2500
959 ];
960
961 return $estimates[$content_length] ?? 1250;
962 }
963 private function save_brief(array $brief_data): int {
964 global $wpdb;
965
966 $table_name = $wpdb->prefix . 'thinkrank_content_briefs';
967
968 // Prepare data for insertion
969 $insert_data = [
970 'user_id' => get_current_user_id(),
971 'title' => $brief_data['title'][0] ?? 'Untitled Brief',
972 'target_keywords' => wp_json_encode($brief_data['generation_params']['target_keywords'] ?? []),
973 'content_type' => $brief_data['generation_params']['content_type'] ?? 'blog_post',
974 'brief_data' => wp_json_encode($brief_data),
975 'created_at' => current_time('mysql'),
976 'updated_at' => current_time('mysql')
977 ];
978
979 $insert_format = [
980 '%d', // user_id
981 '%s', // title
982 '%s', // target_keywords
983 '%s', // content_type
984 '%s', // brief_data
985 '%s', // created_at
986 '%s' // updated_at
987 ];
988
989 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief storage requires direct database access
990 $result = $wpdb->insert($table_name, $insert_data, $insert_format);
991
992 if (false === $result) {
993 throw new \Exception('Failed to save content brief to database.');
994 }
995
996 return $wpdb->insert_id;
997 }
998
999 /**
1000 * Normalize brief data for React compatibility
1001 *
1002 * @param array $brief_data Brief data to normalize
1003 * @return array Normalized brief data
1004 */
1005 private function normalize_brief_data(array $brief_data): array {
1006 // Normalize focus_keyword_analysis
1007 if (isset($brief_data['seo_recommendations']['focus_keyword_analysis'])) {
1008 $brief_data['seo_recommendations']['focus_keyword_analysis'] =
1009 $this->normalize_focus_keyword_analysis($brief_data['seo_recommendations']['focus_keyword_analysis']);
1010 }
1011
1012 // Normalize call_to_actions (convert objects to strings)
1013 if (isset($brief_data['call_to_actions']) && is_array($brief_data['call_to_actions'])) {
1014 $brief_data['call_to_actions'] = array_map(function($cta) {
1015 if (is_array($cta) && isset($cta['text'])) {
1016 return $cta['text'] . (isset($cta['placement']) ? ' (' . $cta['placement'] . ')' : '');
1017 }
1018 return is_string($cta) ? $cta : '';
1019 }, $brief_data['call_to_actions']);
1020 }
1021
1022 // Normalize visual content image_recommendations (convert objects to strings)
1023 if (isset($brief_data['visual_content']['image_recommendations']) && is_array($brief_data['visual_content']['image_recommendations'])) {
1024 $brief_data['visual_content']['image_recommendations'] = array_map(function($rec) {
1025 if (is_array($rec)) {
1026 $text = '';
1027 if (isset($rec['type'])) $text .= $rec['type'] . ': ';
1028 if (isset($rec['description'])) $text .= $rec['description'];
1029 if (isset($rec['alt_text'])) $text .= ' (Alt: ' . $rec['alt_text'] . ')';
1030 return $text ?: 'Image recommendation';
1031 }
1032 return is_string($rec) ? $rec : 'Image recommendation';
1033 }, $brief_data['visual_content']['image_recommendations']);
1034 }
1035
1036 return $brief_data;
1037 }
1038
1039 /**
1040 * Get saved briefs for current user
1041 *
1042 * @param int $limit Number of briefs to retrieve
1043 * @param int $offset Offset for pagination
1044 * @return array Array of saved briefs
1045 */
1046 public function get_user_briefs(int $limit = 10, int $offset = 0): array {
1047 global $wpdb;
1048
1049 // Get table name and escape it properly (table names cannot be parameterized)
1050 $table_name = esc_sql($wpdb->prefix . 'thinkrank_content_briefs');
1051 $user_id = get_current_user_id();
1052
1053 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief retrieval requires direct database access
1054 $results = $wpdb->get_results(
1055 $wpdb->prepare(
1056 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1057 "SELECT * FROM `{$table_name}` WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d",
1058 $user_id,
1059 $limit,
1060 $offset
1061 ),
1062 ARRAY_A
1063 );
1064
1065 // $wpdb->get_results() returns null on a DB error; this method's return
1066 // type is : array, so normalize before iterating/returning.
1067 if (!is_array($results)) {
1068 return [];
1069 }
1070
1071 // Decode JSON data and normalize for React compatibility
1072 foreach ($results as &$brief) {
1073 $brief = $this->hydrate_brief_row($brief);
1074 }
1075 unset($brief);
1076
1077 return $results;
1078 }
1079
1080 /**
1081 * Get a single saved brief by id, scoped to the current user.
1082 *
1083 * @param int $brief_id Brief ID.
1084 * @return array|null Hydrated brief, or null if it doesn't exist or does not
1085 * belong to the current user.
1086 */
1087 public function get_brief(int $brief_id): ?array {
1088 global $wpdb;
1089
1090 // Table names cannot be parameterized; escape it.
1091 $table_name = esc_sql($wpdb->prefix . 'thinkrank_content_briefs');
1092 $user_id = get_current_user_id();
1093
1094 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief retrieval requires direct database access
1095 $brief = $wpdb->get_row(
1096 $wpdb->prepare(
1097 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1098 "SELECT * FROM `{$table_name}` WHERE id = %d AND user_id = %d LIMIT 1",
1099 $brief_id,
1100 $user_id
1101 ),
1102 ARRAY_A
1103 );
1104
1105 if (!$brief) {
1106 return null;
1107 }
1108
1109 return $this->hydrate_brief_row($brief);
1110 }
1111
1112 /**
1113 * Decode + normalize a raw content-brief DB row for API/React consumption.
1114 *
1115 * @param array $brief Raw database row.
1116 * @return array Hydrated brief.
1117 */
1118 private function hydrate_brief_row(array $brief): array {
1119 $brief['target_keywords'] = json_decode($brief['target_keywords'], true);
1120 $brief['brief_data'] = json_decode($brief['brief_data'], true);
1121
1122 // Retrieve raw response from ai_usage table
1123 $brief['brief_data']['raw_response'] = $this->get_raw_response_for_brief($brief['id']);
1124
1125 // Update model with actual model used (if available in ai_usage table)
1126 $actual_model = $this->get_actual_model_for_brief($brief['id']);
1127 if ($actual_model && isset($brief['brief_data']['generation_meta'])) {
1128 $brief['brief_data']['generation_meta']['model'] = $actual_model;
1129 }
1130
1131 // Apply normalization to existing briefs to ensure React compatibility
1132 $brief['brief_data'] = $this->normalize_brief_data($brief['brief_data']);
1133
1134 return $brief;
1135 }
1136
1137 /**
1138 * Delete brief
1139 *
1140 * @param int $brief_id Brief ID to delete
1141 * @return bool Success status
1142 */
1143 public function delete_brief(int $brief_id): bool {
1144 global $wpdb;
1145
1146 $table_name = $wpdb->prefix . 'thinkrank_content_briefs';
1147 $user_id = get_current_user_id();
1148
1149 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief deletion requires direct database access
1150 $result = $wpdb->delete(
1151 $table_name,
1152 [
1153 'id' => $brief_id,
1154 'user_id' => $user_id
1155 ],
1156 ['%d', '%d']
1157 );
1158
1159 return $result !== false;
1160 }
1161
1162 /**
1163 * Calculate readability score using Flesch Reading Ease
1164 *
1165 * @param string $text Text to analyze
1166 * @return array Readability metrics
1167 */
1168 private function calculate_readability_score(string $text): array {
1169 if (empty($text)) {
1170 return ['score' => 0, 'level' => 'Unknown', 'grade' => 'N/A'];
1171 }
1172
1173 // Count sentences (approximate)
1174 $sentences = preg_split('/[.!?]+/', $text);
1175 $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== ''; }));
1176
1177 // Count words
1178 $word_count = str_word_count($text);
1179
1180 // Count syllables (approximate)
1181 $syllable_count = $this->count_syllables($text);
1182
1183 if ($sentence_count === 0 || $word_count === 0) {
1184 return ['score' => 0, 'level' => 'Unknown', 'grade' => 'N/A'];
1185 }
1186
1187 // Flesch Reading Ease formula
1188 $avg_sentence_length = $word_count / $sentence_count;
1189 $avg_syllables_per_word = $syllable_count / $word_count;
1190
1191 $flesch_score = 206.835 - (1.015 * $avg_sentence_length) - (84.6 * $avg_syllables_per_word);
1192 $flesch_score = max(0, min(100, $flesch_score)); // Clamp between 0-100
1193
1194 // Determine reading level
1195 if ($flesch_score >= 90) {
1196 $level = 'Very Easy';
1197 $grade = '5th grade';
1198 } elseif ($flesch_score >= 80) {
1199 $level = 'Easy';
1200 $grade = '6th grade';
1201 } elseif ($flesch_score >= 70) {
1202 $level = 'Fairly Easy';
1203 $grade = '7th grade';
1204 } elseif ($flesch_score >= 60) {
1205 $level = 'Standard';
1206 $grade = '8th-9th grade';
1207 } elseif ($flesch_score >= 50) {
1208 $level = 'Fairly Difficult';
1209 $grade = '10th-12th grade';
1210 } elseif ($flesch_score >= 30) {
1211 $level = 'Difficult';
1212 $grade = 'College level';
1213 } else {
1214 $level = 'Very Difficult';
1215 $grade = 'Graduate level';
1216 }
1217
1218 return [
1219 'score' => round($flesch_score, 1),
1220 'level' => $level,
1221 'grade' => $grade
1222 ];
1223 }
1224
1225 /**
1226 * Count syllables in text (approximate)
1227 *
1228 * @param string $text Text to analyze
1229 * @return int Syllable count
1230 */
1231 private function count_syllables(string $text): int {
1232 $words = str_word_count(strtolower($text), 1);
1233 $syllable_count = 0;
1234
1235 foreach ($words as $word) {
1236 $syllable_count += $this->count_word_syllables($word);
1237 }
1238
1239 return max(1, $syllable_count); // At least 1 syllable
1240 }
1241
1242 /**
1243 * Count syllables in a single word
1244 *
1245 * @param string $word Word to analyze
1246 * @return int Syllable count
1247 */
1248 private function count_word_syllables(string $word): int {
1249 $word = strtolower($word);
1250 $vowels = 'aeiouy';
1251 $syllable_count = 0;
1252 $previous_was_vowel = false;
1253
1254 for ($i = 0; $i < strlen($word); $i++) {
1255 $is_vowel = strpos($vowels, $word[$i]) !== false;
1256 if ($is_vowel && !$previous_was_vowel) {
1257 $syllable_count++;
1258 }
1259 $previous_was_vowel = $is_vowel;
1260 }
1261
1262 // Handle silent 'e'
1263 if (substr($word, -1) === 'e' && $syllable_count > 1) {
1264 $syllable_count--;
1265 }
1266
1267 return max(1, $syllable_count);
1268 }
1269
1270 /**
1271 * Analyze keyword density in content
1272 *
1273 * @param string $text Content text
1274 * @param string $title Page title
1275 * @return array Keyword analysis
1276 */
1277 private function analyze_keyword_density(string $text, string $title): array {
1278 $combined_text = strtolower($title . ' ' . $text);
1279 $words = str_word_count($combined_text, 1);
1280 $total_words = count($words);
1281
1282 if ($total_words === 0) {
1283 return ['top_keywords' => [], 'total_words' => 0];
1284 }
1285
1286 // Count word frequency
1287 $word_counts = array_count_values($words);
1288
1289 // Filter out common stop words
1290 $stop_words = ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can', 'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her', 'us', 'them'];
1291
1292 foreach ($stop_words as $stop_word) {
1293 unset($word_counts[$stop_word]);
1294 }
1295
1296 // Filter out single characters and numbers
1297 $word_counts = array_filter($word_counts, function($count, $word) {
1298 return strlen($word) > 2 && !is_numeric($word) && $count > 1;
1299 }, ARRAY_FILTER_USE_BOTH);
1300
1301 // Sort by frequency
1302 arsort($word_counts);
1303
1304 // Calculate density and format results
1305 $top_keywords = [];
1306 foreach (array_slice($word_counts, 0, 10, true) as $word => $count) {
1307 $density = round(($count / $total_words) * 100, 2);
1308 $top_keywords[] = [
1309 'keyword' => $word,
1310 'count' => $count,
1311 'density' => $density
1312 ];
1313 }
1314
1315 return [
1316 'top_keywords' => $top_keywords,
1317 'total_words' => $total_words
1318 ];
1319 }
1320
1321 /**
1322 * Detect content freshness indicators
1323 *
1324 * @param string $html Full HTML content
1325 * @param string $text Body text
1326 * @return array Freshness indicators
1327 */
1328 private function detect_freshness_indicators(string $html, string $text): array {
1329 $indicators = [];
1330
1331 // Check for date patterns in content
1332 if (preg_match('/\b(updated|revised|modified|published).*?(\d{4}|\d{1,2}\/\d{1,2}\/\d{2,4})/i', $text)) {
1333 $indicators[] = 'Contains recent update dates';
1334 }
1335
1336 // Check for current year references
1337 $current_year = gmdate('Y');
1338 if (strpos($text, $current_year) !== false) {
1339 $indicators[] = "References current year ({$current_year})";
1340 }
1341
1342 // Check for "latest", "new", "recent" keywords
1343 if (preg_match('/\b(latest|newest|recent|updated|current|modern|today)\b/i', $text)) {
1344 $indicators[] = 'Uses freshness keywords';
1345 }
1346
1347 // Check for structured data with dates
1348 if (preg_match('/"dateModified"|"datePublished"/i', $html)) {
1349 $indicators[] = 'Has structured date metadata';
1350 }
1351
1352 return $indicators;
1353 }
1354
1355 /**
1356 * Score title for SEO effectiveness
1357 *
1358 * @param string $title Page title
1359 * @return array Title scoring
1360 */
1361 private function score_title_seo(string $title): array {
1362 $score = 0;
1363 $max_score = 100;
1364 $feedback = [];
1365
1366 // Length check (optimal: 50-60 characters)
1367 $length = strlen($title);
1368 if ($length >= 50 && $length <= 60) {
1369 $score += 25;
1370 $feedback[] = 'Good length (50-60 chars)';
1371 } elseif ($length >= 40 && $length <= 70) {
1372 $score += 15;
1373 $feedback[] = 'Acceptable length';
1374 } else {
1375 $feedback[] = $length < 40 ? 'Too short (under 40 chars)' : 'Too long (over 70 chars)';
1376 }
1377
1378 // Word count (optimal: 5-9 words)
1379 $word_count = str_word_count($title);
1380 if ($word_count >= 5 && $word_count <= 9) {
1381 $score += 20;
1382 $feedback[] = 'Good word count';
1383 } elseif ($word_count >= 3 && $word_count <= 12) {
1384 $score += 10;
1385 $feedback[] = 'Acceptable word count';
1386 } else {
1387 $feedback[] = $word_count < 3 ? 'Too few words' : 'Too many words';
1388 }
1389
1390 // Check for power words
1391 $power_words = ['ultimate', 'complete', 'guide', 'best', 'top', 'essential', 'proven', 'expert', 'advanced', 'beginner'];
1392 $has_power_words = false;
1393 foreach ($power_words as $power_word) {
1394 if (stripos($title, $power_word) !== false) {
1395 $has_power_words = true;
1396 break;
1397 }
1398 }
1399 if ($has_power_words) {
1400 $score += 15;
1401 $feedback[] = 'Contains power words';
1402 }
1403
1404 // Check for numbers
1405 if (preg_match('/\d+/', $title)) {
1406 $score += 10;
1407 $feedback[] = 'Contains numbers';
1408 }
1409
1410 // Check for emotional triggers
1411 $emotional_words = ['amazing', 'incredible', 'shocking', 'secret', 'revealed', 'proven', 'guaranteed'];
1412 $has_emotional_words = false;
1413 foreach ($emotional_words as $emotional_word) {
1414 if (stripos($title, $emotional_word) !== false) {
1415 $has_emotional_words = true;
1416 break;
1417 }
1418 }
1419 if ($has_emotional_words) {
1420 $score += 10;
1421 $feedback[] = 'Contains emotional triggers';
1422 }
1423
1424 // Uniqueness check (avoid generic titles)
1425 $generic_patterns = ['untitled', 'new page', 'home', 'welcome'];
1426 $is_generic = false;
1427 foreach ($generic_patterns as $pattern) {
1428 if (stripos($title, $pattern) !== false) {
1429 $is_generic = true;
1430 break;
1431 }
1432 }
1433 if (!$is_generic) {
1434 $score += 20;
1435 $feedback[] = 'Appears unique';
1436 } else {
1437 $feedback[] = 'Appears generic';
1438 }
1439
1440 return [
1441 'score' => min($score, $max_score),
1442 'max_score' => $max_score,
1443 'grade' => $this->get_grade_from_score($score),
1444 'feedback' => $feedback
1445 ];
1446 }
1447
1448 /**
1449 * Score meta description for SEO effectiveness
1450 *
1451 * @param string $meta_desc Meta description
1452 * @return array Meta description scoring
1453 */
1454 private function score_meta_description(string $meta_desc): array {
1455 $score = 0;
1456 $max_score = 100;
1457 $feedback = [];
1458
1459 if (empty($meta_desc)) {
1460 return [
1461 'score' => 0,
1462 'max_score' => $max_score,
1463 'grade' => 'F',
1464 'feedback' => ['No meta description found']
1465 ];
1466 }
1467
1468 // Length check (optimal: 150-160 characters)
1469 $length = strlen($meta_desc);
1470 if ($length >= 150 && $length <= 160) {
1471 $score += 30;
1472 $feedback[] = 'Optimal length (150-160 chars)';
1473 } elseif ($length >= 120 && $length <= 170) {
1474 $score += 20;
1475 $feedback[] = 'Good length';
1476 } elseif ($length >= 100 && $length <= 180) {
1477 $score += 10;
1478 $feedback[] = 'Acceptable length';
1479 } else {
1480 $feedback[] = $length < 100 ? 'Too short (under 100 chars)' : 'Too long (over 180 chars)';
1481 }
1482
1483 // Check for call-to-action
1484 $cta_words = ['learn', 'discover', 'find out', 'get', 'download', 'try', 'start', 'join', 'sign up', 'contact', 'buy', 'shop'];
1485 $has_cta = false;
1486 foreach ($cta_words as $cta_word) {
1487 if (stripos($meta_desc, $cta_word) !== false) {
1488 $has_cta = true;
1489 break;
1490 }
1491 }
1492 if ($has_cta) {
1493 $score += 20;
1494 $feedback[] = 'Contains call-to-action';
1495 }
1496
1497 // Check for unique selling proposition
1498 $usp_words = ['best', 'top', 'leading', 'expert', 'professional', 'trusted', 'proven', 'award-winning'];
1499 $has_usp = false;
1500 foreach ($usp_words as $usp_word) {
1501 if (stripos($meta_desc, $usp_word) !== false) {
1502 $has_usp = true;
1503 break;
1504 }
1505 }
1506 if ($has_usp) {
1507 $score += 15;
1508 $feedback[] = 'Contains unique selling proposition';
1509 }
1510
1511 // Check for benefits/value proposition
1512 $benefit_words = ['save', 'improve', 'increase', 'boost', 'enhance', 'optimize', 'maximize', 'reduce', 'eliminate'];
1513 $has_benefits = false;
1514 foreach ($benefit_words as $benefit_word) {
1515 if (stripos($meta_desc, $benefit_word) !== false) {
1516 $has_benefits = true;
1517 break;
1518 }
1519 }
1520 if ($has_benefits) {
1521 $score += 15;
1522 $feedback[] = 'Highlights benefits';
1523 }
1524
1525 // Readability check
1526 $sentences = preg_split('/[.!?]+/', $meta_desc);
1527 $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== ''; }));
1528 if ($sentence_count >= 1 && $sentence_count <= 3) {
1529 $score += 20;
1530 $feedback[] = 'Good sentence structure';
1531 } else {
1532 $feedback[] = $sentence_count === 0 ? 'No clear sentences' : 'Too many sentences';
1533 }
1534
1535 return [
1536 'score' => min($score, $max_score),
1537 'max_score' => $max_score,
1538 'grade' => $this->get_grade_from_score($score),
1539 'feedback' => $feedback
1540 ];
1541 }
1542
1543 /**
1544 * Assess content depth based on structure and length
1545 *
1546 * @param array $headings Heading structure
1547 * @param int $word_count Word count
1548 * @return array Content depth assessment
1549 */
1550 private function assess_content_depth(array $headings, int $word_count): array {
1551 $depth_score = 0;
1552 $max_score = 100;
1553
1554 // Word count scoring (more words = more depth)
1555 if ($word_count >= 2000) {
1556 $depth_score += 40;
1557 } elseif ($word_count >= 1000) {
1558 $depth_score += 30;
1559 } elseif ($word_count >= 500) {
1560 $depth_score += 20;
1561 } elseif ($word_count >= 300) {
1562 $depth_score += 10;
1563 }
1564
1565 // Heading structure scoring
1566 $total_headings = 0;
1567 $heading_levels = 0;
1568 foreach ($headings as $level => $level_headings) {
1569 $total_headings += count($level_headings);
1570 $heading_levels++;
1571 }
1572
1573 if ($total_headings >= 10) {
1574 $depth_score += 25;
1575 } elseif ($total_headings >= 5) {
1576 $depth_score += 15;
1577 } elseif ($total_headings >= 3) {
1578 $depth_score += 10;
1579 }
1580
1581 // Heading hierarchy scoring
1582 if ($heading_levels >= 3) {
1583 $depth_score += 20;
1584 } elseif ($heading_levels >= 2) {
1585 $depth_score += 15;
1586 }
1587
1588 // Content structure bonus
1589 if (isset($headings['h1']) && isset($headings['h2'])) {
1590 $depth_score += 15;
1591 }
1592
1593 // Determine depth level
1594 if ($depth_score >= 80) {
1595 $level = 'Comprehensive';
1596 } elseif ($depth_score >= 60) {
1597 $level = 'Detailed';
1598 } elseif ($depth_score >= 40) {
1599 $level = 'Moderate';
1600 } elseif ($depth_score >= 20) {
1601 $level = 'Basic';
1602 } else {
1603 $level = 'Shallow';
1604 }
1605
1606 return [
1607 'score' => min($depth_score, $max_score),
1608 'level' => $level,
1609 'word_count' => $word_count,
1610 'total_headings' => $total_headings,
1611 'heading_levels' => $heading_levels
1612 ];
1613 }
1614
1615 /**
1616 * Convert numeric score to letter grade
1617 *
1618 * @param int $score Numeric score
1619 * @return string Letter grade
1620 */
1621 private function get_grade_from_score(int $score): string {
1622 if ($score >= 90) return 'A';
1623 if ($score >= 80) return 'B';
1624 if ($score >= 70) return 'C';
1625 if ($score >= 60) return 'D';
1626 return 'F';
1627 }
1628
1629 /**
1630 * Log AI usage for analytics
1631 *
1632 * @param int $user_id User ID
1633 * @param string $action Action performed
1634 * @param int $tokens_used Tokens consumed
1635 * @param int|null $post_id Related post/brief ID
1636 * @param string|null $raw_response Raw AI response for debugging
1637 * @param string|null $actual_model Actual model used (from response)
1638 * @return int Usage record ID
1639 */
1640 private function log_ai_usage(int $user_id, string $action, int $tokens_used, ?int $post_id = null, ?string $raw_response = null, ?string $actual_model = null): int {
1641 global $wpdb;
1642
1643 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1644
1645 $metadata = [];
1646 if ($raw_response) {
1647 $metadata['raw_response'] = $raw_response;
1648 }
1649 if ($actual_model) {
1650 $metadata['actual_model'] = $actual_model;
1651 }
1652
1653 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage logging requires direct database access
1654 $wpdb->insert(
1655 $table_name,
1656 [
1657 'user_id' => $user_id,
1658 'action' => $action,
1659 'tokens_used' => $tokens_used,
1660 'provider' => $this->settings->get('ai_provider', 'openai'),
1661 'post_id' => $post_id,
1662 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1663 'created_at' => current_time('mysql'),
1664 ],
1665 ['%d', '%s', '%d', '%s', '%d', '%s', '%s']
1666 );
1667
1668 return $wpdb->insert_id;
1669 }
1670
1671 /**
1672 * Get raw AI response for a brief from ai_usage table
1673 *
1674 * @param int $brief_id Brief ID
1675 * @return string Raw AI response or empty string if not found
1676 */
1677 private function get_raw_response_for_brief(int $brief_id): string {
1678 global $wpdb;
1679
1680 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1681
1682 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage retrieval requires direct database access
1683 $result = $wpdb->get_var(
1684 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1685 $wpdb->prepare(
1686 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1687 "SELECT metadata FROM `{$table_name}` WHERE post_id = %d AND action = 'content_brief' ORDER BY created_at DESC LIMIT 1",
1688 $brief_id
1689 )
1690 );
1691
1692 if ($result) {
1693 $metadata = json_decode($result, true);
1694 return $metadata['raw_response'] ?? '';
1695 }
1696
1697 return '';
1698 }
1699
1700 /**
1701 * Get actual model used for a brief from ai_usage table
1702 *
1703 * @param int $brief_id Brief ID
1704 * @return string|null Actual model used or null if not found
1705 */
1706 private function get_actual_model_for_brief(int $brief_id): ?string {
1707 global $wpdb;
1708
1709 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1710
1711 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage retrieval requires direct database access
1712 $result = $wpdb->get_var(
1713 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1714 $wpdb->prepare(
1715 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1716 "SELECT metadata FROM `{$table_name}` WHERE post_id = %d AND action = 'content_brief' ORDER BY created_at DESC LIMIT 1",
1717 $brief_id
1718 )
1719 );
1720
1721 if ($result) {
1722 $metadata = json_decode($result, true);
1723 return $metadata['actual_model'] ?? null;
1724 }
1725
1726 return null;
1727 }
1728
1729 /**
1730 * Get Prompt Builder instance
1731 *
1732 * @since 1.0.0
1733 *
1734 * @return \ThinkRank\AI\Prompt_Builder Prompt Builder instance
1735 */
1736 private function get_prompt_builder(): \ThinkRank\AI\Prompt_Builder {
1737 if (!class_exists('ThinkRank\\AI\\Prompt_Builder')) {
1738 require_once THINKRANK_PLUGIN_DIR . 'includes/ai/class-prompt-builder.php';
1739 }
1740 return new \ThinkRank\AI\Prompt_Builder();
1741 }
1742 }
1743