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

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