PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 / abilities / analysis / class-bulk-analyze-and-save.php

class-bulk-analyze-and-save.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/abilities/analysis/class-bulk-analyze-and-save.php

215 lines 5.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Bulk analyze-and-save ability.
4 *
5 * @package ThinkRank\Abilities\Analysis
6 */
7
8 declare(strict_types=1);
9
10 namespace ThinkRank\Abilities\Analysis;
11
12 use ThinkRank\Abilities\Ability_Base;
13 use ThinkRank\Admin\Importers\Snapshot_Migrator;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit; // Exit if accessed directly.
17 }
18
19 /**
20 * Analyze a set of posts and persist their SEO scores in one call (see #190).
21 *
22 * Before this, an agent that wanted scores for many posts had to call
23 * `get-seo-score` (with `save_score`) once per post. This ability reuses the
24 * import pipeline's bulk scoring loop (`Snapshot_Migrator::score_posts()`) to
25 * score and save in a single request, returning per-post results plus totals.
26 *
27 * Either pass explicit `post_ids`, or omit them to score the most recent posts
28 * of `post_type`. Already-scored posts are skipped unless `rescore` is true.
29 */
30 class Bulk_Analyze_And_Save extends Ability_Base {
31 /**
32 * Hard cap on posts processed per call, to bound execution time.
33 */
34 private const MAX_POSTS = 100;
35
36 /**
37 * Constructor.
38 */
39 public function __construct() {
40 $this->id = 'thinkrank/bulk-analyze-and-save';
41 $this->label = __( 'Bulk Analyze and Save SEO Scores', 'thinkrank' );
42 $this->description = __( 'Analyze multiple posts and persist their SEO scores in one call. Pass explicit post_ids, or omit them to score the most recent posts of a post type. Already-scored posts are skipped unless rescore is true. Returns per-post results (score, saved score_id, status) and totals. Capped at 100 posts per call. Use list-content-items to find the post IDs.', 'thinkrank' );
43 }
44
45 /**
46 * {@inheritDoc}
47 *
48 * @return array<string, bool|float|string>
49 */
50 public function get_annotations() {
51 return [
52 'readonly' => false,
53 'destructive' => false,
54 'idempotent' => false,
55 'priority' => 1.5,
56 'openWorldHint' => false,
57 ];
58 }
59
60 /**
61 * {@inheritDoc}
62 *
63 * @return array<string, mixed>
64 */
65 public function get_input_schema() {
66 return [
67 'type' => 'object',
68 'additionalProperties' => false,
69 'properties' => [
70 'post_ids' => [
71 'type' => 'array',
72 'items' => [ 'type' => 'integer' ],
73 'description' => __( 'Explicit post IDs to score. When omitted, the most recent posts of post_type are scored.', 'thinkrank' ),
74 ],
75 'post_type' => [
76 'type' => 'string',
77 'default' => 'post',
78 'description' => __( 'Post type to score when post_ids is omitted.', 'thinkrank' ),
79 ],
80 'limit' => [
81 'type' => 'integer',
82 'default' => 20,
83 'minimum' => 1,
84 'maximum' => self::MAX_POSTS,
85 'description' => __( 'How many posts to score when post_ids is omitted (max 100).', 'thinkrank' ),
86 ],
87 'rescore' => [
88 'type' => 'boolean',
89 'default' => false,
90 'description' => __( 'Re-score and overwrite even posts that already have a stored score.', 'thinkrank' ),
91 ],
92 ],
93 ];
94 }
95
96 /**
97 * {@inheritDoc}
98 *
99 * @return array<string, mixed>
100 */
101 public function get_output_schema() {
102 return [
103 'type' => 'object',
104 'properties' => [
105 'scored' => [ 'type' => 'integer' ],
106 'skipped' => [ 'type' => 'integer' ],
107 'failed' => [ 'type' => 'integer' ],
108 'total' => [ 'type' => 'integer' ],
109 'results' => [
110 'type' => 'array',
111 'items' => [
112 'type' => 'object',
113 'additionalProperties' => true,
114 ],
115 ],
116 ],
117 ];
118 }
119
120 /**
121 * Execute ability.
122 *
123 * @param array<string, mixed> $input Ability input payload.
124 * @return array<string, mixed>|\WP_Error
125 */
126 public function execute( $input ) {
127 $rescore = ! empty( $input['rescore'] );
128
129 $post_ids = $this->resolve_post_ids( $input );
130 if ( is_wp_error( $post_ids ) ) {
131 return $post_ids;
132 }
133
134 if ( empty( $post_ids ) ) {
135 return [
136 'scored' => 0,
137 'skipped' => 0,
138 'failed' => 0,
139 'total' => 0,
140 'results' => [],
141 ];
142 }
143
144 $summary = ( new Snapshot_Migrator() )->score_posts( $post_ids, $rescore );
145
146 return [
147 'scored' => $summary['scored'],
148 'skipped' => $summary['skipped'],
149 'failed' => $summary['failed'],
150 'total' => $summary['total'],
151 'results' => $summary['results'],
152 ];
153 }
154
155 /**
156 * Resolve the list of post IDs to score, from explicit input or a query.
157 *
158 * @param array<string, mixed> $input Ability input payload.
159 * @return int[]|\WP_Error
160 */
161 private function resolve_post_ids( array $input ) {
162 if ( ! empty( $input['post_ids'] ) && is_array( $input['post_ids'] ) ) {
163 $ids = array_values(
164 array_unique(
165 array_filter(
166 array_map( 'intval', $input['post_ids'] )
167 )
168 )
169 );
170
171 if ( count( $ids ) > self::MAX_POSTS ) {
172 return new \WP_Error(
173 'thinkrank_bulk_analyze_too_many',
174 sprintf(
175 /* translators: %d: maximum number of posts. */
176 __( 'Too many posts: pass at most %d post_ids per call.', 'thinkrank' ),
177 self::MAX_POSTS
178 ),
179 [ 'status' => 400 ]
180 );
181 }
182
183 return $ids;
184 }
185
186 $post_type = isset( $input['post_type'] ) ? sanitize_key( (string) $input['post_type'] ) : 'post';
187 if ( ! post_type_exists( $post_type ) ) {
188 return new \WP_Error(
189 'thinkrank_bulk_analyze_bad_type',
190 sprintf(
191 /* translators: %s: post type slug. */
192 __( 'Unknown post type: %s.', 'thinkrank' ),
193 $post_type
194 ),
195 [ 'status' => 400 ]
196 );
197 }
198
199 $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 20;
200 $limit = max( 1, min( self::MAX_POSTS, $limit ) );
201
202 return get_posts(
203 [
204 'post_type' => $post_type,
205 'post_status' => 'publish',
206 'posts_per_page' => $limit,
207 'fields' => 'ids',
208 'orderby' => 'date',
209 'order' => 'DESC',
210 'suppress_filters' => false,
211 ]
212 );
213 }
214 }
215