PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 57 releases
← All changes | includes/ai/class-content-brief-generator.php +535 -109 1.28.0 → 2.14.2 View file →
@@ -48,8 +48,34 @@
48 48 */
49 49 private const OPENAI_REQUEST_TIMEOUT = 300;
50 50
51 51 /**
52 + * Minimum output-token budget for a content brief.
53 + *
54 + * A shorter length tier must never starve the structured JSON — plus any
55 + * reasoning/thinking tokens, which are drawn from the same budget — to the
56 + * point of truncating mid-response (the failure #165 fixed on Gemini). This
57 + * floor is only a safety net for small-ceiling models; it never exceeds the
58 + * model-aware base budget. See scale_tokens_for_length().
59 + */
60 + private const MIN_BRIEF_TOKENS = 2000;
61 +
62 + /**
63 + * Content-length → budget multipliers, applied to the model-aware base.
64 + *
65 + * NOTE: provisional starting points (issue #287). They make Short/Medium/
66 + * Long request measurably different budgets, but the exact figures should
67 + * be validated against recorded completion-token usage for a real brief on
68 + * each provider before being treated as final. Unknown lengths fall back to
69 + * the 'medium' tier (see scale_tokens_for_length()).
70 + */
71 + private const LENGTH_TOKEN_MULTIPLIERS = [
72 + 'short' => 0.6,
73 + 'medium' => 0.8,
74 + 'long' => 1.0,
75 + ];
76 +
77 + /**
52 78 * Settings instance
53 79 *
54 80 * @var Settings
55 81 */
@@ -57,9 +83,11 @@
57 83
58 84 /**
59 85 * AI client instance
60 86 *
61 - * @var OpenAI_Client|Claude_Client
87 + * Null when the generator was built for storage-only work.
88 + *
89 + * @var OpenAI_Client|Claude_Client|null
62 90 */
63 91 private $ai_client;
64 92
65 93 /**
@@ -66,18 +94,31 @@
66 94 * Constructor
67 95 *
68 96 * @param Settings|null $settings Settings instance
69 97 * @param OpenAI_Client|Claude_Client|null $ai_client AI client instance
98 + * @param bool $require_ai_client Whether a provider client is required. Pass
99 + * false for storage-only use (list/export/
100 + * delete), which never calls a provider.
70 101 */
71 - public function __construct(?Settings $settings = null, $ai_client = null) {
102 + public function __construct(?Settings $settings = null, $ai_client = null, bool $require_ai_client = true) {
72 103 $this->settings = $settings ?? Settings::instance();
73 104
74 105 if ($ai_client) {
75 106 $this->ai_client = $ai_client;
76 - } else {
77 - // Fallback to creating own client for backward compatibility
78 - $this->init_ai_client();
107 +
108 + return;
79 109 }
110 +
111 + // Read-only callers (listing, exporting and deleting saved briefs) only
112 + // touch the database and never reach a provider. Constructing a client
113 + // for them turns "no API key configured" — the default state of a fresh
114 + // install — into a hard failure, so let them opt out.
115 + if (!$require_ai_client) {
116 + return;
117 + }
118 +
119 + // Fallback to creating own client for backward compatibility
120 + $this->init_ai_client();
80 121 }
81 122
82 123 /**
83 124 * Initialize AI client based on available API keys
@@ -82,11 +123,13 @@
82 123 /**
83 124 * Initialize AI client based on available API keys
84 125 *
85 126 * @return void
127 + *
128 + * @throws \Exception On failure.
86 129 */
87 130 private function init_ai_client(): void {
88 - $provider = $this->settings->get('ai_provider', 'openai');
131 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
89 132
90 133 if ($provider === 'openai') {
91 134 $api_key = $this->settings->get('openai_api_key');
92 135 if ($api_key) {
@@ -110,11 +153,31 @@
110 153 if ($api_key) {
111 154 $model = $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
112 155 $this->ai_client = new OpenRouter_Client($api_key, $model, self::AI_REQUEST_TIMEOUT);
113 156 }
157 + } elseif ($provider === 'openai_compatible') {
158 + // Same client as OpenAI, different host — and the key is optional,
159 + // so the URL and model id are what gate it (#721). The user's own
160 + // timeout applies: a local model writing a brief on CPU is slow,
161 + // and the setting exists for exactly that.
162 + $base_url = (string) $this->settings->get('openai_compatible_base_url', '');
163 + $model = trim((string) $this->settings->get('openai_compatible_model', ''));
164 + if ('' !== $base_url && '' !== $model) {
165 + $this->ai_client = new OpenAI_Client(
166 + (string) $this->settings->get('openai_compatible_api_key', ''),
167 + $model,
168 + (int) $this->settings->get('openai_compatible_timeout', Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT),
169 + $base_url
170 + );
171 + $this->ai_client->set_json_mode((bool) $this->settings->get('openai_compatible_json_mode', false));
172 + }
114 173 }
115 174
116 175 if (!$this->ai_client) {
176 + if ('openai_compatible' === $provider) {
177 + throw new \Exception('Please set the base URL and model id for your OpenAI-compatible endpoint in ThinkRank settings.');
178 + }
179 +
117 180 throw new \Exception('Please configure your AI provider API key in ThinkRank settings.');
118 181 }
119 182 }
120 183
@@ -129,9 +192,15 @@
129 192 return $this->ai_client->get_model();
130 193 }
131 194
132 195 // Fallback to settings
133 - $provider = $this->settings->get('ai_provider', 'openai');
196 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
197 + if (Settings::AI_PROVIDER_NONE === $provider) {
198 + // No provider chosen, so there is no model to name. Reporting the
199 + // OpenAI default here would attribute output to a provider the site
200 + // never selected (#572).
201 + return '';
202 + }
134 203 if ($provider === 'claude') {
135 204 return $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL);
136 205 } elseif ($provider === 'gemini') {
137 206 return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
@@ -136,8 +205,10 @@
136 205 } elseif ($provider === 'gemini') {
137 206 return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
138 207 } elseif ($provider === 'openrouter') {
139 208 return $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
209 + } elseif ($provider === 'openai_compatible') {
210 + return (string) $this->settings->get('openai_compatible_model', '');
140 211 } else {
141 212 return $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
142 213 }
143 214 }
@@ -142,14 +213,51 @@
142 213 }
143 214 }
144 215
145 216 /**
217 + * Resolve the reasoning-effort level for a content-brief request.
218 + *
219 + * Without an explicit level, GPT-5 models run at their default (maximum)
220 + * reasoning effort against a ~95% completion budget — the slowest and
221 + * costliest configuration, where billed reasoning tokens (drawn from the
222 + * same budget) are spent before any visible output (issue #286).
223 + *
224 + * A brief is a structured planning task, so 'low' is a provisional middle
225 + * ground between 'minimal' and the model's default.
226 + * The level is filterable so a site can trade latency for more reasoning;
227 + * returning '' opts out entirely and lets the model use its default effort.
228 + * Only the GPT-5 family consumes this — o1/o3, gpt-4o and the non-OpenAI
229 + * clients ignore an unrecognised option key.
230 + *
231 + * @param string $model The resolved model ID (passed to the filter).
232 + * @param array $params The brief generation parameters (passed to the filter).
233 + * @return string One of 'minimal' | 'low' | 'medium' | 'high', or '' to opt out.
234 + */
235 + private function resolve_reasoning_effort(string $model, array $params): string {
236 + /**
237 + * Filter the reasoning-effort level used for content-brief generation.
238 + *
239 + * @param string $effort The default level ('low'). Return '' to opt out.
240 + * @param string $model The resolved model ID for this request.
241 + * @param array $params The brief generation parameters.
242 + */
243 + $effort = (string) apply_filters('thinkrank_content_brief_reasoning_effort', 'low', $model, $params);
244 +
245 + // Only values OpenAI accepts may reach the request body ('' opts out).
246 + // An unrecognised filter return (e.g. 'turbo') would otherwise be sent
247 + // verbatim and fail the whole brief with a 400, so degrade to the
248 + // documented default instead.
249 + $allowed = ['', 'minimal', 'low', 'medium', 'high'];
250 + return in_array($effort, $allowed, true) ? $effort : 'low';
251 + }
252 +
253 + /**
146 254 * Get current AI provider
147 255 *
148 256 * @return string Current provider name
149 257 */
150 258 private function get_current_provider(): string {
151 - return $this->settings->get('ai_provider', 'openai');
259 + return $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
152 260 }
153 261
154 262 /**
155 263 * Extract token usage from AI response
@@ -159,9 +267,9 @@
159 267 */
160 268 private function extract_token_usage(array $ai_response): int {
161 269 $provider = $this->get_current_provider();
162 270
163 - if ($provider === 'openai' || $provider === 'openrouter') {
271 + if ($provider === 'openai' || $provider === 'openrouter' || $provider === 'openai_compatible') {
164 272 // OpenAI-compatible format: response['usage']['total_tokens']
165 273 return (int) ($ai_response['usage']['total_tokens'] ?? 0);
166 274 } elseif ($provider === 'claude') {
167 275 // Claude format: response['usage']['input_tokens'] + response['usage']['output_tokens']
@@ -241,29 +349,55 @@
241 349 $language
242 350 );
243 351
244 352 try {
245 - // Get recommended token limit for content briefs (model-specific)
246 - $max_tokens = method_exists($this->ai_client, 'get_recommended_tokens')
247 - ? $this->ai_client->get_recommended_tokens('content_brief')
248 - : 4000; // Fallback for non-OpenAI clients
353 + // Get the model-aware budget for a comprehensive brief, then scale
354 + // it to the requested content length so Short/Medium/Long actually
355 + // request different budgets (issue #287). Every client (OpenAI,
356 + // Claude, Gemini, OpenRouter) implements get_recommended_tokens(),
357 + // so there is no model-blind fallback.
358 + $base_tokens = (int) $this->ai_client->get_recommended_tokens('content_brief');
359 + $max_tokens = $this->scale_tokens_for_length($base_tokens, $content_length);
249 360
361 + // Bound hidden reasoning on the GPT-5 family (issue #286). See
362 + // resolve_reasoning_effort(). Only the GPT-5 family reads this;
363 + // o1/o3, gpt-4o and the non-OpenAI clients ignore the option, and
364 + // an empty string opts out (model default effort).
365 + $reasoning_effort = $this->resolve_reasoning_effort($this->get_current_model(), $params);
366 +
367 + $completion_options = [
368 + // For GPT‑5 family the client translates max_tokens to
369 + // max_completion_tokens internally. Temperature is intentionally
370 + // omitted: every client defaults it to 0.7, and reasoning models
371 + // reject it outright, so passing it here was misleading no-op.
372 + 'max_tokens' => $max_tokens,
373 + // The brief is one JSON object. Only a compatible endpoint
374 + // with JSON mode on reads this; every other client ignores it.
375 + 'json_object' => true,
376 + ];
377 + if ('' !== $reasoning_effort) {
378 + $completion_options['reasoning_effort'] = $reasoning_effort;
379 + }
380 +
250 381 // Generate brief using AI
251 - $ai_response = $this->ai_client->generate_completion($prompt, [
252 - // For GPT‑5 family the client will translate to max_completion_tokens internally
253 - 'max_tokens' => $max_tokens,
254 - 'temperature' => 0.7,
255 - ]);
382 + $ai_response = $this->ai_client->generate_completion($prompt, $completion_options);
256 383
384 + // Detect a provider-side non-answer (refusal, policy block, or
385 + // truncation) BEFORE attempting text extraction. Otherwise a
386 + // refusal — which OpenAI returns as HTTP 200 with content=null —
387 + // slips past every isset() branch and gets serialized into the
388 + // brief body instead of being reported to the user.
389 + $this->guard_against_non_answer($ai_response, $max_tokens);
390 +
257 391 // Extract text content from AI response
258 392 $ai_text = '';
259 393
260 394 // Handle OpenAI response format
261 395 if (isset($ai_response['choices'][0]['message']['content'])) {
262 - $contentField = $ai_response['choices'][0]['message']['content'];
263 - if (is_string($contentField)) {
264 - $ai_text = $contentField;
265 - } elseif (is_array($contentField)) {
396 + $content_field = $ai_response['choices'][0]['message']['content'];
397 + if (is_string($content_field)) {
398 + $ai_text = $content_field;
399 + } elseif (is_array($content_field)) {
266 400 // Concatenate text parts from array-based content (Chat Completions multimodal)
267 401 $parts = array_map(function($part) {
268 402 if (is_array($part)) {
269 403 return $part['text'] ?? '';
@@ -268,9 +402,9 @@
268 402 if (is_array($part)) {
269 403 return $part['text'] ?? '';
270 404 }
271 405 return is_string($part) ? $part : '';
272 - }, $contentField);
406 + }, $content_field);
273 407 $ai_text = trim(implode("\n", array_filter($parts)));
274 408 }
275 409 }
276 410 // Handle Claude response format
@@ -279,15 +413,8 @@
279 413 }
280 414 // Handle Gemini response format
281 415 elseif (isset($ai_response['candidates'][0]['content']['parts'][0]['text'])) {
282 416 $ai_text = $ai_response['candidates'][0]['content']['parts'][0]['text'];
283 -
284 - // A reply cut off at the token limit is incomplete JSON, so it
285 - // can never be parsed. Fail with the real reason instead of
286 - // saving a brief titled "Unable to parse AI response".
287 - if (($ai_response['candidates'][0]['finishReason'] ?? '') === 'MAX_TOKENS') {
288 - throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.');
289 - }
290 417 }
291 418 // Handle direct content field
292 419 elseif (isset($ai_response['content']) && is_string($ai_response['content'])) {
293 420 $ai_text = $ai_response['content'];
@@ -295,12 +422,19 @@
295 422 // Handle direct string response
296 423 elseif (is_string($ai_response)) {
297 424 $ai_text = $ai_response;
298 425 }
299 - // If we still don't have text, log the response structure for debugging
426 + // No known provider shape matched and guard_against_non_answer()
427 + // found nothing it recognised. Never serialize the raw envelope
428 + // into the brief body — that turns a clear failure into a saved,
429 + // meaningless brief. Log the shape for diagnostics and fail.
300 430 else {
301 - // As a last resort, stringify the response for visibility (prevents empty content error)
302 - $ai_text = is_array($ai_response) ? wp_json_encode($ai_response) : (string) $ai_response;
431 + if (defined('WP_DEBUG') && WP_DEBUG) {
432 + $shape = is_array($ai_response) ? implode(', ', array_keys($ai_response)) : gettype($ai_response);
433 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
434 + error_log('[ThinkRank] Content brief: unrecognised AI response shape. Top-level keys: ' . $shape);
435 + }
436 + throw new \Exception('The AI returned a response in an unexpected format. Please try again.');
303 437 }
304 438
305 439 // Ensure we have actual text content
306 440 if (empty(trim($ai_text))) {
@@ -341,8 +475,18 @@
341 475
342 476 } catch (\Exception $e) {
343 477 // Provide more specific error messages
344 478 $error_message = $e->getMessage();
479 +
480 + // Messages we authored for the user (refusals, policy blocks,
481 + // token-limit truncation, unexpected shape) all start with "The AI "
482 + // and are already actionable. Pass them through verbatim instead of
483 + // flattening them via the substring matching below — e.g. so a
484 + // refusal is not rewritten into generic "empty content" advice.
485 + if (strpos($error_message, 'The AI ') === 0) {
486 + throw new \Exception(esc_html($error_message));
487 + }
488 +
345 489 if (strpos($error_message, 'API key') !== false) {
346 490 throw new \Exception('API key configuration error. Please check your AI provider settings.');
347 491 } elseif (strpos($error_message, 'Invalid AI response format') !== false) {
348 492 throw new \Exception('AI service returned an unexpected response format. Please try again.');
@@ -354,8 +498,120 @@
354 498 }
355 499 }
356 500
357 501 /**
502 + * Scale the model-aware brief budget to the requested content length.
503 + *
504 + * get_recommended_tokens('content_brief') returns the budget for a full,
505 + * comprehensive (Long) brief, already capped at the model's completion
506 + * ceiling. Shorter tiers request proportionally less so that choosing Short
507 + * is genuinely faster and cheaper (issue #287), while every tier stays at or
508 + * below the base and at or above MIN_BRIEF_TOKENS so it cannot truncate.
509 + *
510 + * @param int $base_tokens Model-aware budget for a comprehensive brief.
511 + * @param string $content_length One of 'short' | 'medium' | 'long'.
512 + * @return int Scaled max_tokens, clamped to [floor, base_tokens].
513 + */
514 + private function scale_tokens_for_length(int $base_tokens, string $content_length): int {
515 + // Unknown/missing length falls back to the medium tier — never to 0 or
516 + // to the raw ceiling.
517 + $multiplier = self::LENGTH_TOKEN_MULTIPLIERS[$content_length]
518 + ?? self::LENGTH_TOKEN_MULTIPLIERS['medium'];
519 +
520 + $scaled = (int) round($base_tokens * $multiplier);
521 +
522 + // The floor can never exceed the base itself, so a model with a tiny
523 + // ceiling still yields a sane, in-range value.
524 + $floor = (int) min($base_tokens, self::MIN_BRIEF_TOKENS);
525 +
526 + return max($floor, min($scaled, $base_tokens));
527 + }
528 +
529 + /**
530 + * Detect a provider-side non-answer and fail with the real reason.
531 + *
532 + * A refusal, content-policy block, or token-limit truncation is not a
533 + * usable brief. Each provider signals these differently, and none of the
534 + * signals set the content field the extraction chain looks for — so if we
535 + * don't catch them here they fall through to the "unexpected format" path
536 + * (or, historically, were serialized into the brief body). All messages
537 + * start with "The AI " so the outer catch passes them through unchanged.
538 + *
539 + * @param mixed $ai_response Raw response from the AI client.
540 + * @param int $requested_tokens The max_tokens this request asked for; 0 when unknown.
541 + * @throws \Exception If the response is a refusal, policy block, or truncation.
542 + */
543 + private function guard_against_non_answer($ai_response, int $requested_tokens = 0): void {
544 + if (!is_array($ai_response)) {
545 + return;
546 + }
547 +
548 + // --- OpenAI (Chat Completions) ---
549 + // A structured refusal is HTTP 200 with message.content=null and the
550 + // stated reason carried in message.refusal. finish_reason distinguishes
551 + // a policy block from a truncated completion.
552 + if (isset($ai_response['choices'][0]['message'])) {
553 + $message = $ai_response['choices'][0]['message'];
554 + $finish = (string) ($ai_response['choices'][0]['finish_reason'] ?? '');
555 +
556 + if (!empty($message['refusal'])) {
557 + throw new \Exception(esc_html(sprintf(
558 + 'The AI declined to generate this brief: %s',
559 + (string) $message['refusal']
560 + )));
561 + }
562 + if ('content_filter' === $finish) {
563 + throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
564 + }
565 + // A self-hosted server can stop short of max_tokens because the
566 + // prompt and the answer together filled its context window
567 + // (Ollama loads models at 4096 by default). A bigger output budget
568 + // cannot fix that, so say what can.
569 + $completion_tokens = (int) ($ai_response['usage']['completion_tokens'] ?? 0);
570 + if ('length' === $finish && $requested_tokens > 0 && $completion_tokens > 0 && $completion_tokens < $requested_tokens) {
571 + throw new \Exception(esc_html(sprintf(
572 + 'The AI stopped after %1$d tokens, short of the %2$d allowed, because the server ran out of context window before finishing the brief. Raise the context length on your AI server (for Ollama, set OLLAMA_CONTEXT_LENGTH to 16384 or more) and try again.',
573 + $completion_tokens,
574 + $requested_tokens
575 + )));
576 + }
577 + if ('length' === $finish) {
578 + throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try fewer competitor URLs, or a model with a larger output limit. A shorter content length will not help: it asks for a smaller budget, not a smaller answer.');
579 + }
580 + }
581 +
582 + // --- Claude (Messages) ---
583 + if (isset($ai_response['stop_reason'])) {
584 + $stop_reason = (string) $ai_response['stop_reason'];
585 + if ('refusal' === $stop_reason) {
586 + throw new \Exception('The AI declined to generate this brief for this topic. Try a different topic or less sensitive keywords.');
587 + }
588 + if ('max_tokens' === $stop_reason) {
589 + throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try fewer competitor URLs, or a model with a larger output limit. A shorter content length will not help: it asks for a smaller budget, not a smaller answer.');
590 + }
591 + }
592 +
593 + // --- Gemini ---
594 + // A prompt rejected outright returns no candidate at all, only
595 + // promptFeedback.blockReason; a candidate can also finish on SAFETY or
596 + // PROHIBITED_CONTENT, or be truncated at MAX_TOKENS.
597 + $block_reason = (string) ($ai_response['promptFeedback']['blockReason'] ?? '');
598 + if ('' !== $block_reason) {
599 + throw new \Exception(esc_html(sprintf(
600 + 'The AI blocked this request under its content policy (%s). Try a different topic or less sensitive keywords.',
601 + $block_reason
602 + )));
603 + }
604 + $gemini_finish = (string) ($ai_response['candidates'][0]['finishReason'] ?? '');
605 + if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) {
606 + throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
607 + }
608 + if ('MAX_TOKENS' === $gemini_finish) {
609 + throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try fewer competitor URLs, or a model with a larger output limit. A shorter content length will not help: it asks for a smaller budget, not a smaller answer.');
610 + }
611 + }
612 +
613 + /**
358 614 * Validate brief generation parameters
359 615 *
360 616 * @param array $params Parameters to validate
361 617 * @throws \Exception If validation fails
@@ -365,19 +621,19 @@
365 621 throw new \Exception('Target keywords are required and must be an array.');
366 622 }
367 623
368 624 $valid_content_types = ['blog_post', 'product_page', 'landing_page', 'tutorial'];
369 - if (!empty($params['content_type']) && !in_array($params['content_type'], $valid_content_types)) {
625 + if (!empty($params['content_type']) && !in_array($params['content_type'], $valid_content_types, true)) {
370 626 throw new \Exception('Invalid content type specified.');
371 627 }
372 628
373 629 $valid_lengths = ['short', 'medium', 'long'];
374 - if (!empty($params['content_length']) && !in_array($params['content_length'], $valid_lengths)) {
630 + if (!empty($params['content_length']) && !in_array($params['content_length'], $valid_lengths, true)) {
375 631 throw new \Exception('Invalid content length specified.');
376 632 }
377 633
378 634 $valid_tones = ['professional', 'casual', 'technical', 'friendly'];
379 - if (!empty($params['tone']) && !in_array($params['tone'], $valid_tones)) {
635 + if (!empty($params['tone']) && !in_array($params['tone'], $valid_tones, true)) {
380 636 throw new \Exception('Invalid tone specified.');
381 637 }
382 638 }
383 639
@@ -445,9 +701,9 @@
445 701 'title' => $json_data['title_suggestions'] ?? [],
446 702 'meta_description' => $json_data['meta_descriptions'][0] ?? '',
447 703 'meta_descriptions' => $json_data['meta_descriptions'] ?? [],
448 704 'url_slugs' => $json_data['url_slugs'] ?? [],
449 - 'outline' => $json_data['outline'] ?? [],
705 + 'outline' => self::strip_outline_level_labels($json_data['outline'] ?? []),
450 706 'seo_recommendations' => [
451 707 'title_suggestions' => $json_data['title_suggestions'] ?? [],
452 708 'meta_description' => $json_data['meta_descriptions'][0] ?? '',
453 709 'meta_descriptions' => $json_data['meta_descriptions'] ?? [],
@@ -473,9 +729,9 @@
473 729 ],
474 730 'competitor_gaps' => $json_data['competitor_analysis']['content_gaps'] ?? [],
475 731 'call_to_actions' => $json_data['call_to_actions'] ?? [],
476 732 'writing_guidelines' => $json_data['writing_guidelines'] ?? [],
477 - 'content_body' => $json_data['content_body'] ?? '',
733 + 'content_body' => self::strip_heading_level_labels((string) ($json_data['content_body'] ?? '')),
478 734 'estimated_word_count' => $this->get_word_count_estimate($original_params['content_length'] ?? 'medium'),
479 735 'raw_response' => '', // Will be retrieved from ai_usage table
480 736 'generation_params' => $original_params,
481 737 'parsing_status' => 'success',
@@ -483,8 +739,118 @@
483 739 ];
484 740 }
485 741
486 742 /**
743 + * Remove a leading level label from a heading string.
744 + *
745 + * The prompt's own JSON example labelled outline headings with their level
746 + * (`"heading": "H1: Main Title"` next to a separate `"level": 1`), so the
747 + * model often carried the convention into the drafted article and Pro's
748 + * "Insert into post" wrote `<h2>H2: Real Heading</h2>` into published
749 + * content. The prompt no longer does that, but a prompt change never fully
750 + * binds a model — so the label is stripped here too (#410).
751 + *
752 + * Covers the label forms a model actually emits: `H2:`, `h3:`, `H2 -`,
753 + * `H4.`, `H2)` and the en/em dash variants, optionally wrapped in markdown
754 + * emphasis (`**H2:**`). The delimiter is anchored directly after the digit
755 + * so `H10:` — a plausible heading in a numbered list — is left alone, and
756 + * only a leading label is matched so body copy that mentions a level
757 + * survives. Trailing emphasis is consumed only when the same marker opened
758 + * the label, so `H2: *emphasised start*` keeps its asterisks.
759 + *
760 + * @since 2.0.1
761 + *
762 + * @param string $heading Heading text.
763 + * @return string Heading without its level prefix.
764 + */
765 + public static function strip_level_label(string $heading): string {
766 + // En dash and em dash as raw UTF-8 bytes, so the pattern needs no /u
767 + // modifier and cannot blank a heading that is not valid UTF-8.
768 + $delimiter = '(?:[:.)\-]|\xe2\x80\x93|\xe2\x80\x94)';
769 + $emphasis = '(\*{1,3}|_{1,3})';
770 +
771 + $pattern = '/^\s*(?:'
772 + . $emphasis . '\s*[Hh][1-6]\s*' . $delimiter . '\s*\1'
773 + . '|[Hh][1-6]\s*' . $delimiter
774 + . ')\s*/';
775 +
776 + return (string) preg_replace($pattern, '', $heading);
777 + }
778 +
779 + /**
780 + * Strip a level label from a heading's inner HTML.
781 + *
782 + * A model drafting publish-ready HTML often wraps the heading text in an
783 + * inline tag (`<h2><strong>H2: Real Heading</strong></h2>`). That pushes a
784 + * `<` in front of the label, so the leading run of inline opening tags is
785 + * set aside and re-attached around the cleaned text.
786 + *
787 + * @since 2.0.1
788 + *
789 + * @param string $inner Heading inner HTML.
790 + * @return string Inner HTML without the level prefix.
791 + */
792 + private static function strip_inner_level_label(string $inner): string {
793 + $prefix = '';
794 +
795 + if (preg_match('/^(\s*(?:<(?:strong|em|b|i|span|mark|code|u)\b[^>]*>\s*)+)(.*)$/is', $inner, $parts)) {
796 + $prefix = $parts[1];
797 + $inner = $parts[2];
798 + }
799 +
800 + return $prefix . self::strip_level_label($inner);
801 + }
802 +
803 + /**
804 + * Strip level labels from every heading in an outline.
805 + *
806 + * @since 2.0.1
807 + *
808 + * @param mixed $outline Outline as returned by the model.
809 + * @return array Outline with clean headings.
810 + */
811 + public static function strip_outline_level_labels($outline): array {
812 + if (!is_array($outline)) {
813 + return [];
814 + }
815 +
816 + foreach ($outline as $index => $section) {
817 + if (is_array($section) && isset($section['heading']) && is_string($section['heading'])) {
818 + $outline[$index]['heading'] = self::strip_level_label($section['heading']);
819 + } elseif (is_string($section)) {
820 + $outline[$index] = self::strip_level_label($section);
821 + }
822 + }
823 +
824 + return $outline;
825 + }
826 +
827 + /**
828 + * Strip level labels from the heading text inside drafted HTML.
829 + *
830 + * This is the path that reaches published post content, so it is the one
831 + * that matters most. Only the text directly inside an <h1>-<h6> is touched.
832 + *
833 + * @since 2.0.1
834 + *
835 + * @param string $html Drafted article body.
836 + * @return string Body with clean headings.
837 + */
838 + public static function strip_heading_level_labels(string $html): string {
839 + if ('' === $html || false === stripos($html, '<h')) {
840 + return $html;
841 + }
842 +
843 + return (string) preg_replace_callback(
844 + '/(<h([1-6])\b[^>]*>)(.*?)(<\/h\2>)/is',
845 + static function (array $parts): string {
846 + return $parts[1] . self::strip_inner_level_label($parts[3]) . $parts[4];
847 + },
848 + $html
849 + );
850 + }
851 +
852 + /**
487 853 * Create error response when JSON parsing fails
488 854 *
489 855 * @param string $ai_response Raw AI response
490 856 * @param array $original_params Original generation parameters
@@ -593,8 +959,32 @@
593 959 }
594 960
595 961 return $normalized;
596 962 }
963 + /**
964 + * The URL to check and fetch for a competitor URL the user entered, or
965 + * null when it is not a valid URL.
966 + *
967 + * A competitor page on an internationalised domain, or with a Bengali or
968 + * Arabic slug, is a valid URL, so the syntax check is Url_Validator's.
969 + * What comes back is the ASCII form (punycode host, percent-encoded path),
970 + * and both the SSRF guard and the fetch use it: the guard cannot resolve
971 + * a Unicode host name, and it must check exactly the URL that is then
972 + * requested.
973 + *
974 + * @since 2.14.2
975 + *
976 + * @param string $url Trimmed URL as entered.
977 + * @return string|null ASCII URL, or null when invalid.
978 + */
979 + private function competitor_fetch_url(string $url): ?string {
980 + if ('' === $url || !\ThinkRank\Core\Url_Validator::is_valid($url)) {
981 + return null;
982 + }
983 +
984 + return \ThinkRank\Core\Url_Validator::to_ascii($url);
985 + }
986 +
597 987 private function analyze_competitor_urls(array $urls): string {
598 988 $analysis_results = [];
599 989 $failed_urls = [];
600 990
@@ -602,9 +992,10 @@
602 992 $urls = array_slice($urls, 0, 3);
603 993
604 994 foreach ($urls as $url) {
605 995 $url = trim($url);
606 - if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
996 + $fetch_url = $this->competitor_fetch_url($url);
997 + if (null === $fetch_url) {
607 998 $failed_urls[] = $url . " (invalid URL)";
608 999 continue;
609 1000 }
610 1001
@@ -610,14 +1001,14 @@
610 1001
611 1002 // SSRF guard: only fetch public http/https hosts. Blocks loopback,
612 1003 // link-local (cloud metadata), private and reserved ranges before any
613 1004 // request is made.
614 - if (!$this->is_safe_public_url($url)) {
1005 + if (!$this->is_safe_public_url($fetch_url)) {
615 1006 $failed_urls[] = $url . " (blocked: non-public host)";
616 1007 continue;
617 1008 }
618 1009
619 - $content_data = $this->scrape_competitor_content($url);
1010 + $content_data = $this->scrape_competitor_content($fetch_url);
620 1011 if ($content_data) {
621 1012 $analysis_results[] = $this->format_competitor_analysis($url, $content_data);
622 1013 } else {
623 1014 $failed_urls[] = $url . " (failed to scrape)";
@@ -647,63 +1038,17 @@
647 1038 * resolves only to public IP addresses.
648 1039 *
649 1040 * Prevents SSRF — a low-privilege user could otherwise point competitor
650 1041 * scraping at loopback, link-local (e.g. 169.254.169.254 cloud metadata),
651 - * private or reserved addresses to probe internal services.
1042 + * CGNAT, private or reserved addresses to probe internal services. The
1043 + * block list lives in {@see \ThinkRank\Core\Url_Safety} so this and the
1044 + * schema importer can never drift apart.
652 1045 *
653 1046 * @param string $url URL to validate.
654 1047 * @return bool True when safe to fetch.
655 1048 */
656 1049 private function is_safe_public_url(string $url): bool {
657 - $parts = wp_parse_url($url);
658 - if (empty($parts['scheme']) || empty($parts['host'])) {
659 - return false;
660 - }
661 -
662 - // Only ever fetch over http/https.
663 - if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
664 - return false;
665 - }
666 -
667 - $host = $parts['host'];
668 -
669 - // Resolve the host to the IP(s) it points at (literal IPs pass through).
670 - $ips = [];
671 - if (filter_var($host, FILTER_VALIDATE_IP)) {
672 - $ips[] = $host;
673 - } else {
674 - $records = @dns_get_record($host, DNS_A + DNS_AAAA);
675 - if (is_array($records)) {
676 - foreach ($records as $record) {
677 - if (!empty($record['ip'])) {
678 - $ips[] = $record['ip'];
679 - } elseif (!empty($record['ipv6'])) {
680 - $ips[] = $record['ipv6'];
681 - }
682 - }
683 - }
684 - // Fallback for hosts dns_get_record can't resolve.
685 - if (empty($ips)) {
686 - $resolved = gethostbyname($host);
687 - if ($resolved !== $host) {
688 - $ips[] = $resolved;
689 - }
690 - }
691 - }
692 -
693 - // Unresolvable host → don't fetch.
694 - if (empty($ips)) {
695 - return false;
696 - }
697 -
698 - // Reject if any resolved address is private, reserved, loopback or link-local.
699 - foreach ($ips as $ip) {
700 - if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
701 - return false;
702 - }
703 - }
704 -
705 - return true;
1050 + return \ThinkRank\Core\Url_Safety::is_safe_public_url($url);
706 1051 }
707 1052
708 1053 /**
709 1054 * Scrape content from a competitor URL
@@ -711,12 +1056,13 @@
711 1056 * @param string $url The URL to scrape
712 1057 * @return array|null Content data or null if failed
713 1058 */
714 1059 private function scrape_competitor_content(string $url): ?array {
715 - // wp_safe_remote_get() (reject_unsafe_urls) re-validates the URL and, unlike
716 - // wp_remote_get(), rejects redirects to internal/private hosts — closing the
717 - // redirect-based SSRF bypass on top of the pre-flight is_safe_public_url() check.
718 - $response = wp_safe_remote_get($url, [
1060 + // Url_Safety::safe_remote_get() follows redirects manually and re-checks
1061 + // the resolved host on every hop, so a target that redirects to (or
1062 + // rebinds onto) an internal address after the pre-flight check is
1063 + // refused rather than fetched.
1064 + $response = \ThinkRank\Core\Url_Safety::safe_remote_get($url, [
719 1065 'timeout' => 8, // Reduced from 15 to 8 seconds
720 1066 'user-agent' => 'Mozilla/5.0 (compatible; ThinkRank SEO Bot)',
721 1067 'headers' => [
722 1068 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
@@ -908,9 +1254,9 @@
908 1254 // Add heading structure
909 1255 if (!empty($content_data['headings'])) {
910 1256 $analysis .= "\nCONTENT STRUCTURE:\n";
911 1257 foreach ($content_data['headings'] as $level => $headings) {
912 - $analysis .= "- " . strtoupper($level) . " ({count}): " . implode(', ', array_slice($headings, 0, 3));
1258 + $analysis .= "- " . strtoupper($level) . " (" . count($headings) . "): " . implode(', ', array_slice($headings, 0, 3));
913 1259 if (count($headings) > 3) {
914 1260 $analysis .= "... (+" . (count($headings) - 3) . " more)";
915 1261 }
916 1262 $analysis .= "\n";
@@ -992,8 +1338,20 @@
992 1338 if (false === $result) {
993 1339 throw new \Exception('Failed to save content brief to database.');
994 1340 }
995 1341
1342 + /**
1343 + * Fires after a content brief is persisted.
1344 + *
1345 + * Analytics listens to drop its cached overview so the brief counts
1346 + * on the Usages page are not stale for a TTL.
1347 + *
1348 + * @since 2.2.1
1349 + *
1350 + * @param int $brief_id Row id of the stored brief.
1351 + */
1352 + do_action('thinkrank_content_brief_created', (int) $wpdb->insert_id);
1353 +
996 1354 return $wpdb->insert_id;
997 1355 }
998 1356
999 1357 /**
@@ -1023,11 +1381,14 @@
1023 1381 if (isset($brief_data['visual_content']['image_recommendations']) && is_array($brief_data['visual_content']['image_recommendations'])) {
1024 1382 $brief_data['visual_content']['image_recommendations'] = array_map(function($rec) {
1025 1383 if (is_array($rec)) {
1026 1384 $text = '';
1027 - if (isset($rec['type'])) $text .= $rec['type'] . ': ';
1028 - if (isset($rec['description'])) $text .= $rec['description'];
1029 - if (isset($rec['alt_text'])) $text .= ' (Alt: ' . $rec['alt_text'] . ')';
1385 + if (isset($rec['type'])) { $text .= $rec['type'] . ': ';
1386 + }
1387 + if (isset($rec['description'])) { $text .= $rec['description'];
1388 + }
1389 + if (isset($rec['alt_text'])) { $text .= ' (Alt: ' . $rec['alt_text'] . ')';
1390 + }
1030 1391 return $text ?: 'Image recommendation';
1031 1392 }
1032 1393 return is_string($rec) ? $rec : 'Image recommendation';
1033 1394 }, $brief_data['visual_content']['image_recommendations']);
@@ -1032,8 +1393,54 @@
1032 1393 return is_string($rec) ? $rec : 'Image recommendation';
1033 1394 }, $brief_data['visual_content']['image_recommendations']);
1034 1395 }
1035 1396
1397 + return $this->sanitize_brief_output($brief_data);
1398 + }
1399 +
1400 + /**
1401 + * Strip untrusted markup out of brief fields before they leave the server.
1402 + *
1403 + * Brief content crosses a trust boundary: it is assembled by an external AI
1404 + * provider from prompts that can include text fetched from competitor URLs.
1405 + * It was previously copied out of the decoded JSON verbatim and rendered in
1406 + * the admin SPA through dangerouslySetInnerHTML, so a malicious or
1407 + * prompt-injected response could execute script in the admin origin (#365).
1408 + *
1409 + * Runs on the read path as well as generation, so briefs stored before this
1410 + * fix are sanitized when they are loaded.
1411 + *
1412 + * @since 1.32.0
1413 + *
1414 + * @param array $brief_data Brief data to sanitize.
1415 + * @return array Sanitized brief data.
1416 + */
1417 + private function sanitize_brief_output(array $brief_data): array {
1418 + foreach ($brief_data as $key => $value) {
1419 + // The raw provider response is debug output shown as plain text, and
1420 + // the generation params are our own values — leave both intact.
1421 + if ('raw_response' === $key || 'generation_params' === $key) {
1422 + continue;
1423 + }
1424 +
1425 + if ('content_body' === $key && is_string($value)) {
1426 + // Deliberately HTML: it is the drafted article and is rendered as
1427 + // markup. wp_kses_post() keeps normal post formatting while
1428 + // dropping script/style/iframe, event-handler attributes and
1429 + // javascript: URLs.
1430 + $brief_data[$key] = wp_kses_post($value);
1431 + continue;
1432 + }
1433 +
1434 + if (is_array($value)) {
1435 + $brief_data[$key] = $this->sanitize_brief_output($value);
1436 + } elseif (is_string($value)) {
1437 + // Every other field is plain text (headings, keywords, guidance).
1438 + // Markdown emphasis markers are preserved; HTML tags are not.
1439 + $brief_data[$key] = wp_strip_all_tags($value);
1440 + }
1441 + }
1442 +
1036 1443 return $brief_data;
1037 1444 }
1038 1445
1039 1446 /**
@@ -1118,13 +1525,17 @@
1118 1525 private function hydrate_brief_row(array $brief): array {
1119 1526 $brief['target_keywords'] = json_decode($brief['target_keywords'], true);
1120 1527 $brief['brief_data'] = json_decode($brief['brief_data'], true);
1121 1528
1529 + // Cast: the row comes from $wpdb, which returns every column as a
1530 + // string, and both helpers declare an int parameter.
1531 + $brief_id = (int) $brief['id'];
1532 +
1122 1533 // Retrieve raw response from ai_usage table
1123 - $brief['brief_data']['raw_response'] = $this->get_raw_response_for_brief($brief['id']);
1534 + $brief['brief_data']['raw_response'] = $this->get_raw_response_for_brief($brief_id);
1124 1535
1125 1536 // Update model with actual model used (if available in ai_usage table)
1126 - $actual_model = $this->get_actual_model_for_brief($brief['id']);
1537 + $actual_model = $this->get_actual_model_for_brief($brief_id);
1127 1538 if ($actual_model && isset($brief['brief_data']['generation_meta'])) {
1128 1539 $brief['brief_data']['generation_meta']['model'] = $actual_model;
1129 1540 }
1130 1541
@@ -1171,9 +1582,10 @@
1171 1582 }
1172 1583
1173 1584 // Count sentences (approximate)
1174 1585 $sentences = preg_split('/[.!?]+/', $text);
1175 - $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== ''; }));
1586 + $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== '';
1587 +}));
1176 1588
1177 1589 // Count words
1178 1590 $word_count = str_word_count($text);
1179 1591
@@ -1250,9 +1662,9 @@
1250 1662 $vowels = 'aeiouy';
1251 1663 $syllable_count = 0;
1252 1664 $previous_was_vowel = false;
1253 1665
1254 - for ($i = 0; $i < strlen($word); $i++) {
1666 + for ($i = 0, $len = strlen($word); $i < $len; $i++) {
1255 1667 $is_vowel = strpos($vowels, $word[$i]) !== false;
1256 1668 if ($is_vowel && !$previous_was_vowel) {
1257 1669 $syllable_count++;
1258 1670 }
@@ -1523,9 +1935,10 @@
1523 1935 }
1524 1936
1525 1937 // Readability check
1526 1938 $sentences = preg_split('/[.!?]+/', $meta_desc);
1527 - $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== ''; }));
1939 + $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== '';
1940 +}));
1528 1941 if ($sentence_count >= 1 && $sentence_count <= 3) {
1529 1942 $score += 20;
1530 1943 $feedback[] = 'Good sentence structure';
1531 1944 } else {
@@ -1618,12 +2031,16 @@
1618 2031 * @param int $score Numeric score
1619 2032 * @return string Letter grade
1620 2033 */
1621 2034 private function get_grade_from_score(int $score): string {
1622 - if ($score >= 90) return 'A';
1623 - if ($score >= 80) return 'B';
1624 - if ($score >= 70) return 'C';
1625 - if ($score >= 60) return 'D';
2035 + if ($score >= 90) { return 'A';
2036 + }
2037 + if ($score >= 80) { return 'B';
2038 + }
2039 + if ($score >= 70) { return 'C';
2040 + }
2041 + if ($score >= 60) { return 'D';
2042 + }
1626 2043 return 'F';
1627 2044 }
1628 2045
1629 2046 /**
@@ -1656,9 +2073,9 @@
1656 2073 [
1657 2074 'user_id' => $user_id,
1658 2075 'action' => $action,
1659 2076 'tokens_used' => $tokens_used,
1660 - 'provider' => $this->settings->get('ai_provider', 'openai'),
2077 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1661 2078 'post_id' => $post_id,
1662 2079 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1663 2080 'created_at' => current_time('mysql'),
1664 2081 ],
@@ -1663,8 +2080,17 @@
1663 2080 'created_at' => current_time('mysql'),
1664 2081 ],
1665 2082 ['%d', '%s', '%d', '%s', '%d', '%s', '%s']
1666 2083 );
2084 +
2085 + /**
2086 + * Fires after an AI usage row is recorded.
2087 + *
2088 + * @since 2.2.1
2089 + *
2090 + * @param int $user_id User the usage was recorded against.
2091 + */
2092 + do_action('thinkrank_ai_usage_logged', $user_id);
1667 2093
1668 2094 return $wpdb->insert_id;
1669 2095 }
1670 2096