PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / api / class-content-brief-endpoint.php

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

472 lines 15.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Content Brief API Endpoint
4 *
5 * Handles REST API endpoints for content brief generation
6 *
7 * @package ThinkRank
8 * @subpackage API
9 * @since 1.0.0
10 */
11
12 namespace ThinkRank\API;
13
14 use ThinkRank\AI\Content_Brief_Generator;
15 use ThinkRank\AI\Manager as AI_Manager;
16 use WP_REST_Request;
17 use WP_REST_Response;
18 use WP_Error;
19
20 // Prevent direct access
21 if (!defined('ABSPATH')) {
22 exit;
23 }
24
25 /**
26 * Content Brief Endpoint class
27 */
28 class Content_Brief_Endpoint {
29
30 /**
31 * API namespace
32 */
33 const NAMESPACE = 'thinkrank/v1';
34
35 /**
36 * Content brief generator instance
37 *
38 * @var Content_Brief_Generator|null
39 */
40 private ?Content_Brief_Generator $generator = null;
41
42 /**
43 * Generator instance for read-only work (no AI client attached)
44 *
45 * @var Content_Brief_Generator|null
46 */
47 private ?Content_Brief_Generator $storage_generator = null;
48
49 /**
50 * Constructor
51 */
52 public function __construct() {
53 // Don't instantiate generator here - do it lazily when needed
54 }
55
56 /**
57 * Get generator instance (lazy loading)
58 *
59 * Only for routes that actually call the AI provider. Storage-only routes
60 * must use get_storage_generator() instead.
61 *
62 * @return Content_Brief_Generator
63 * @throws \Exception If generator cannot be created
64 */
65 private function get_generator(): Content_Brief_Generator {
66 if ($this->generator === null) {
67 // Get AI client from AI Manager with proper timeout configuration
68 $ai_manager = new AI_Manager();
69 $ai_client = $ai_manager->get_client();
70
71 $this->generator = new Content_Brief_Generator(null, $ai_client);
72 }
73 return $this->generator;
74 }
75
76 /**
77 * Get a generator for storage-only work (list / export / delete).
78 *
79 * These routes only read and write the briefs table, so they must not
80 * require an AI client: on a site with no API key configured — the default
81 * for a fresh install — building one throws and turns a plain database read
82 * into a 500.
83 *
84 * @return Content_Brief_Generator
85 */
86 private function get_storage_generator(): Content_Brief_Generator {
87 if ($this->storage_generator === null) {
88 $this->storage_generator = new Content_Brief_Generator(null, null, false);
89 }
90 return $this->storage_generator;
91 }
92
93 /**
94 * Register REST API routes
95 *
96 * @return void
97 */
98 public function register_routes(): void {
99 // Generate content brief
100 register_rest_route(self::NAMESPACE, '/content-brief/generate', [
101 'methods' => 'POST',
102 'callback' => [$this, 'generate_brief'],
103 'permission_callback' => [$this, 'check_permissions'],
104 'args' => [
105 'target_keywords' => [
106 'required' => true,
107 'type' => 'array',
108 'items' => [
109 'type' => 'string',
110 'minLength' => 1
111 ],
112 'minItems' => 1,
113 'validate_callback' => [$this, 'validate_keywords']
114 ],
115 'content_type' => [
116 'type' => 'string',
117 'default' => 'blog_post',
118 'enum' => ['blog_post', 'product_page', 'landing_page', 'tutorial']
119 ],
120 'target_audience' => [
121 'type' => 'string',
122 'default' => 'general',
123 'enum' => ['beginners', 'professionals', 'general', 'experts']
124 ],
125 'content_length' => [
126 'type' => 'string',
127 'default' => 'medium',
128 'enum' => ['short', 'medium', 'long']
129 ],
130 'tone' => [
131 'type' => 'string',
132 'default' => 'professional',
133 'enum' => ['professional', 'casual', 'technical', 'friendly']
134 ],
135 'competitor_urls' => [
136 'type' => 'array',
137 'items' => [
138 'type' => 'string',
139 'format' => 'uri'
140 ],
141 'default' => []
142 ],
143 'additional_context' => [
144 'type' => 'string',
145 'default' => ''
146 ]
147 ]
148 ]);
149
150 // Get user's content briefs
151 register_rest_route(self::NAMESPACE, '/content-brief/list', [
152 'methods' => 'GET',
153 'callback' => [$this, 'get_briefs'],
154 'permission_callback' => [$this, 'check_permissions'],
155 'args' => [
156 'limit' => [
157 'type' => 'integer',
158 'default' => 10,
159 'minimum' => 1,
160 'maximum' => 50
161 ],
162 'offset' => [
163 'type' => 'integer',
164 'default' => 0,
165 'minimum' => 0
166 ]
167 ]
168 ]);
169
170 // Delete content brief
171 register_rest_route(self::NAMESPACE, '/content-brief/(?P<id>\d+)', [
172 'methods' => 'DELETE',
173 'callback' => [$this, 'delete_brief'],
174 'permission_callback' => [$this, 'check_permissions'],
175 'args' => [
176 'id' => [
177 'required' => true,
178 'type' => 'integer',
179 'minimum' => 1
180 ]
181 ]
182 ]);
183
184 // Export content brief
185 register_rest_route(self::NAMESPACE, '/content-brief/(?P<id>\d+)/export', [
186 'methods' => 'GET',
187 'callback' => [$this, 'export_brief'],
188 'permission_callback' => [$this, 'check_permissions'],
189 'args' => [
190 'id' => [
191 'required' => true,
192 'type' => 'integer',
193 'minimum' => 1
194 ],
195 // Only plain-text export is implemented; keep the enum honest
196 // rather than advertising pdf/docx that fall back to text.
197 'format' => [
198 'type' => 'string',
199 'default' => 'txt',
200 'enum' => ['txt']
201 ]
202 ]
203 ]);
204 }
205
206 /**
207 * Generate content brief
208 *
209 * @param WP_REST_Request $request Request object
210 * @return WP_REST_Response|WP_Error Response object
211 */
212 public function generate_brief(WP_REST_Request $request) {
213 try {
214 // Persistent per-user throttle on this paid AI-backed route (the other
215 // AI endpoints do the same) to prevent an edit_posts user looping it.
216 if (!$this->check_ai_rate_limit()) {
217 return new WP_Error(
218 'rate_limit_exceeded',
219 'Rate limit exceeded. Please wait a few minutes before generating another content brief.',
220 ['status' => 429]
221 );
222 }
223
224 $params = [
225 'target_keywords' => $request->get_param('target_keywords'),
226 'content_type' => $request->get_param('content_type'),
227 'target_audience' => $request->get_param('target_audience'),
228 'content_length' => $request->get_param('content_length'),
229 'tone' => $request->get_param('tone'),
230 'competitor_urls' => $request->get_param('competitor_urls'),
231 'additional_context' => $request->get_param('additional_context')
232 ];
233
234 $brief_data = $this->get_generator()->generate_brief($params);
235
236 return new WP_REST_Response([
237 'success' => true,
238 'data' => $brief_data,
239 'message' => 'Content brief generated successfully'
240 ], 200);
241
242 } catch (\Exception $e) {
243 return new WP_Error(
244 'brief_generation_failed',
245 $e->getMessage(),
246 ['status' => 500]
247 );
248 }
249 }
250
251 /**
252 * Get user's content briefs
253 *
254 * @param WP_REST_Request $request Request object
255 * @return WP_REST_Response|WP_Error Response object
256 */
257 public function get_briefs(WP_REST_Request $request) {
258 try {
259 $limit = $request->get_param('limit');
260 $offset = $request->get_param('offset');
261
262 $briefs = $this->get_storage_generator()->get_user_briefs($limit, $offset);
263
264 return new WP_REST_Response([
265 'success' => true,
266 'data' => $briefs,
267 'total' => count($briefs)
268 ], 200);
269
270 } catch (\Exception $e) {
271 return new WP_Error(
272 'briefs_fetch_failed',
273 $e->getMessage(),
274 ['status' => 500]
275 );
276 }
277 }
278
279 /**
280 * Delete content brief
281 *
282 * @param WP_REST_Request $request Request object
283 * @return WP_REST_Response|WP_Error Response object
284 */
285 public function delete_brief(WP_REST_Request $request) {
286 try {
287 $brief_id = $request->get_param('id');
288 $success = $this->get_storage_generator()->delete_brief($brief_id);
289
290 if ($success) {
291 return new WP_REST_Response([
292 'success' => true,
293 'message' => 'Content brief deleted successfully'
294 ], 200);
295 } else {
296 return new WP_Error(
297 'brief_delete_failed',
298 'Failed to delete content brief',
299 ['status' => 500]
300 );
301 }
302
303 } catch (\Exception $e) {
304 return new WP_Error(
305 'brief_delete_failed',
306 $e->getMessage(),
307 ['status' => 500]
308 );
309 }
310 }
311
312 /**
313 * Export content brief
314 *
315 * @param WP_REST_Request $request Request object
316 * @return WP_REST_Response|WP_Error Response object
317 */
318 public function export_brief(WP_REST_Request $request) {
319 try {
320 $brief_id = (int) $request->get_param('id');
321 $format = $request->get_param('format');
322
323 // Fetch the requested brief, scoped to the current user. Returns null
324 // (→ 404) when the id doesn't exist or belongs to another user.
325 $brief = $this->get_storage_generator()->get_brief($brief_id);
326
327 if (!$brief) {
328 return new WP_Error(
329 'brief_not_found',
330 'Content brief not found',
331 ['status' => 404]
332 );
333 }
334
335 $export_data = $this->format_brief_for_export($brief, $format);
336
337 return new WP_REST_Response([
338 'success' => true,
339 'data' => $export_data,
340 'format' => $format
341 ], 200);
342
343 } catch (\Exception $e) {
344 return new WP_Error(
345 'brief_export_failed',
346 $e->getMessage(),
347 ['status' => 500]
348 );
349 }
350 }
351
352 /**
353 * Format brief for export
354 *
355 * @param array $brief Brief data
356 * @param string $format Export format
357 * @return string Formatted content
358 */
359 private function format_brief_for_export(array $brief, string $format): string {
360 // Every read here is a field of json_decode() output, so nothing about
361 // its shape is guaranteed. implode() on null and str_repeat() on a
362 // negative count are a TypeError and a ValueError respectively, and
363 // neither is an \Exception — so the catch around this call never
364 // matched and an export of a malformed brief was a fatal (#394).
365 $brief_data = is_array($brief['brief_data'] ?? null) ? $brief['brief_data'] : [];
366 $keywords = is_array($brief['target_keywords'] ?? null) ? $brief['target_keywords'] : [];
367
368 $content = "Content Brief: " . (string) ($brief['title'] ?? '') . "\n\n";
369 $content .= "Target Keywords: " . implode(', ', array_map('strval', $keywords)) . "\n";
370 $content .= "Content Type: " . (string) ($brief['content_type'] ?? '') . "\n\n";
371
372 if (!empty($brief_data['outline']) && is_array($brief_data['outline'])) {
373 $content .= "Content Outline:\n";
374
375 foreach ($brief_data['outline'] as $item) {
376 if (!is_array($item)) {
377 continue;
378 }
379
380 // Clamped: a level of 0 or a missing one made the repeat count
381 // negative.
382 $level = max(1, min(6, (int) ($item['level'] ?? 1)));
383 $word_count = (int) ($item['word_count'] ?? 0);
384 $indent = str_repeat(' ', $level - 1);
385
386 $content .= $indent . "H{$level}: " . (string) ($item['heading'] ?? '');
387
388 if ($word_count > 0) {
389 $content .= " ({$word_count} words)";
390 }
391
392 $content .= "\n";
393 }
394 }
395
396 $content .= "\nGenerated on: " . (string) ($brief['created_at'] ?? '');
397
398 return $content;
399 }
400
401 /**
402 * Validate keywords parameter
403 *
404 * @param array $keywords Keywords to validate
405 * @return bool|WP_Error Validation result
406 */
407 public function validate_keywords($keywords) {
408 // A custom validate_callback replaces WP's array type-coercion, so the
409 // raw param arrives here as-is; reject non-arrays instead of letting a
410 // strict array type hint throw an uncaught TypeError during dispatch.
411 if (!is_array($keywords)) {
412 return new WP_Error(
413 'invalid_keywords',
414 'Keywords must be provided as an array',
415 ['status' => 400]
416 );
417 }
418
419 if (empty($keywords)) {
420 return new WP_Error(
421 'invalid_keywords',
422 'At least one keyword is required',
423 ['status' => 400]
424 );
425 }
426
427 foreach ($keywords as $keyword) {
428 if (!is_string($keyword) || empty(trim($keyword))) {
429 return new WP_Error(
430 'invalid_keyword',
431 'All keywords must be non-empty strings',
432 ['status' => 400]
433 );
434 }
435 }
436
437 return true;
438 }
439
440 /**
441 * Check permissions for API access
442 *
443 * @return bool Permission status
444 */
445 public function check_permissions(): bool {
446 return current_user_can('edit_posts');
447 }
448
449 /**
450 * Persistent per-user rate limit for the AI-backed generate route.
451 *
452 * Transient-backed (survives across requests) and keyed per user, mirroring
453 * the llms-txt endpoint's AI throttle but with its own bucket so the two
454 * features don't share a budget.
455 *
456 * @return bool True if the request is within the limit.
457 */
458 private function check_ai_rate_limit(): bool {
459 $user_id = get_current_user_id();
460 $rate_key = "thinkrank_ai_rate_content_brief_{$user_id}";
461
462 $requests = (int) get_transient($rate_key);
463
464 if ($requests >= 5) { // Max 5 content briefs per 10 minutes.
465 return false;
466 }
467
468 set_transient($rate_key, $requests + 1, 10 * MINUTE_IN_SECONDS);
469 return true;
470 }
471 }
472