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-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.26.0, at includes/ai/class-content-brief-generator.php

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