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

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