PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
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-focus-keyword-usage-endpoint.php

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

201 lines 6.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Focus Keyword Usage API Endpoint
5 *
6 * Reports whether a focus keyword is already used on other posts, powering the
7 * "You have already used this Focus Keyword" analysis status (Rank Math parity).
8 *
9 * @package ThinkRank
10 * @subpackage API
11 * @since 1.15.x
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\API;
17
18 use WP_REST_Controller;
19 use WP_REST_Request;
20 use WP_REST_Response;
21 use WP_Query;
22 use WP_Error;
23
24 // Prevent direct access
25 if (!defined('ABSPATH')) {
26 exit;
27 }
28
29 /**
30 * Focus Keyword Usage API Endpoint.
31 *
32 * @since 1.15.x
33 */
34 class Focus_Keyword_Usage_Endpoint extends WP_REST_Controller {
35
36 /**
37 * API namespace.
38 *
39 * @var string
40 */
41 protected $namespace = 'thinkrank/v1';
42
43 /**
44 * API resource base.
45 *
46 * @var string
47 */
48 protected $rest_base = 'focus-keyword-usage';
49
50 /**
51 * Register API routes.
52 *
53 * @return void
54 */
55 public function register_routes(): void {
56 register_rest_route(
57 $this->namespace,
58 '/' . $this->rest_base,
59 [
60 [
61 'methods' => 'GET',
62 'callback' => [$this, 'get_usage'],
63 'permission_callback' => [$this, 'check_permissions'],
64 'args' => [
65 'post_id' => [
66 'required' => true,
67 'type' => 'integer',
68 'sanitize_callback' => 'absint',
69 'validate_callback' => static function ($param) {
70 return is_numeric($param);
71 },
72 ],
73 'keywords' => [
74 'required' => true,
75 'type' => 'string',
76 ],
77 ],
78 ],
79 ]
80 );
81 }
82
83 /**
84 * Return per-keyword usage counts across other posts.
85 *
86 * @param WP_REST_Request $request Request object.
87 * @return WP_REST_Response|WP_Error
88 */
89 public function get_usage(WP_REST_Request $request) {
90 $post_id = (int) $request->get_param('post_id');
91
92 if (!current_user_can('edit_post', $post_id)) {
93 return new WP_Error(
94 'thinkrank_forbidden',
95 __('You are not allowed to edit this post.', 'thinkrank'),
96 ['status' => 403]
97 );
98 }
99
100 // The `keywords` param is a JSON array (falling back to comma-separated).
101 $raw = (string) $request->get_param('keywords');
102 $keywords = json_decode($raw, true);
103 if (!is_array($keywords)) {
104 $keywords = explode(',', $raw);
105 }
106
107 $post_type = get_post_type($post_id) ?: 'post';
108 $seen = [];
109 $results = [];
110
111 foreach ($keywords as $keyword) {
112 $keyword = is_string($keyword) ? trim($keyword) : '';
113 $key = function_exists('mb_strtolower') ? mb_strtolower($keyword) : strtolower($keyword);
114 if ($keyword === '' || isset($seen[$key])) {
115 continue;
116 }
117 $seen[$key] = true;
118
119 $usage = $this->count_keyword_usage($keyword, $post_id, $post_type);
120 $results[] = [
121 'keyword' => $keyword,
122 'count' => $usage['count'],
123 'posts' => $usage['posts'],
124 ];
125 }
126
127 return new WP_REST_Response([
128 'success' => true,
129 'keywords' => $results,
130 ], 200);
131 }
132
133 /**
134 * Count other posts (of the same type) that use a keyword as a focus keyword.
135 *
136 * Matches both the legacy scalar primary keyword and the keyword appearing
137 * anywhere in the serialized focus-keyword array.
138 *
139 * @param string $keyword Keyword to look up.
140 * @param int $exclude_id Current post to exclude.
141 * @param string $post_type Post type to scope the search to.
142 * @return array{count:int,posts:array<int,array{id:int,title:string,edit:string}>}
143 */
144 private function count_keyword_usage(string $keyword, int $exclude_id, string $post_type): array {
145 $query = new WP_Query([
146 'post_type' => $post_type,
147 'post_status' => ['publish', 'future', 'draft', 'pending', 'private'],
148 'posts_per_page' => 6,
149 'post__not_in' => [$exclude_id],
150 'fields' => 'ids',
151 'ignore_sticky_posts' => true,
152 'no_found_rows' => false,
153 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- intentional, small admin-only lookup.
154 'meta_query' => [
155 'relation' => 'OR',
156 [
157 // Legacy scalar primary keyword — exact match.
158 'key' => '_thinkrank_focus_keyword',
159 'value' => $keyword,
160 'compare' => '=',
161 ],
162 [
163 // Serialized array entry: s:LEN:"keyword"; → contains :"keyword";
164 'key' => '_thinkrank_focus_keywords',
165 'value' => ':"' . $keyword . '";',
166 'compare' => 'LIKE',
167 ],
168 ],
169 ]);
170
171 $posts = [];
172 foreach (array_slice($query->posts, 0, 5) as $id) {
173 $id = (int) $id;
174 $posts[] = [
175 'id' => $id,
176 'title' => html_entity_decode(get_the_title($id), ENT_QUOTES),
177 'edit' => (string) get_edit_post_link($id, 'raw'),
178 ];
179 }
180
181 return [
182 'count' => (int) $query->found_posts,
183 'posts' => $posts,
184 ];
185 }
186
187 /**
188 * Permission check — must be able to edit the target post.
189 *
190 * @param WP_REST_Request $request Request object.
191 * @return bool
192 */
193 public function check_permissions(WP_REST_Request $request): bool {
194 $post_id = (int) $request->get_param('post_id');
195 if ($post_id > 0) {
196 return current_user_can('edit_post', $post_id);
197 }
198 return current_user_can('edit_posts');
199 }
200 }
201