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
thinkrank / includes / abilities / content / class-get-post-content.php

class-get-post-content.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.2, at includes/abilities/content/class-get-post-content.php

244 lines 8.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Get post content ability.
4 *
5 * @package ThinkRank\Abilities\Content
6 */
7
8 declare(strict_types=1);
9
10 namespace ThinkRank\Abilities\Content;
11
12 use ThinkRank\Abilities\Ability_Base;
13 use ThinkRank\SEO\Builder_Content;
14 use ThinkRank\SEO\Word_Count_Index;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit; // Exit if accessed directly.
18 }
19
20 /**
21 * Reads a post's body content.
22 *
23 * update-post-seo declares `additionalProperties: false` over an SEO-only field
24 * list, and nothing else read the body at all — so an agent was optimising a
25 * title and description for content it could not see (#676).
26 *
27 * Read-only, deliberately. There is no counterpart that writes the body, and
28 * that is the point rather than an omission: page-builder content does not live
29 * in `post_content` — Bricks, Oxygen, Breakdance and Elementor keep their trees
30 * in postmeta — so a write ability that set `post_content` would appear to
31 * succeed and change nothing on a builder page, or worse, resurrect stale
32 * blocks the builder had superseded. That is the failure mode #335 already
33 * documents. A write surface needs to be builder-aware before it is safe, which
34 * is a larger piece of work than this.
35 *
36 * `content` is therefore what the page actually renders, resolved through the
37 * same pipeline the SEO score and meta description use, and `post_content` is
38 * the raw stored column beside it so the difference is visible rather than
39 * guessed at.
40 */
41 class Get_Post_Content extends Ability_Base {
42
43 /**
44 * Constructor.
45 */
46 public function __construct() {
47 $this->id = 'thinkrank/get-post-content';
48 $this->label = __( 'Get ThinkRank Post Content', 'thinkrank' );
49 $this->description = __( 'Read a post or page\'s body content so you can judge what it is about before changing its SEO fields. Returns the text the page actually renders, resolved from the page builder when one owns the page, plus the raw stored post_content and which builder is in use. Read-only: use update-post-seo to change SEO fields. Use list-content-items to find ids.', 'thinkrank' );
50 }
51
52 /**
53 * {@inheritDoc}
54 *
55 * @return array<string, bool|float|string>
56 */
57 public function get_annotations() {
58 return [
59 'readonly' => true,
60 'destructive' => false,
61 'idempotent' => true,
62 'priority' => 1.0,
63 'openWorldHint' => false,
64 ];
65 }
66
67 /**
68 * {@inheritDoc}
69 *
70 * @return array<string, mixed>
71 */
72 public function get_input_schema() {
73 return [
74 'type' => 'object',
75 'additionalProperties' => false,
76 'required' => [ 'post_id' ],
77 'properties' => [
78 'post_id' => [
79 'type' => 'integer',
80 'description' => __( 'Post or page ID from list-content-items.', 'thinkrank' ),
81 ],
82 'max_chars' => [
83 'type' => 'integer',
84 'description' => __( 'Truncate the returned body to this many characters. Omit for the whole thing.', 'thinkrank' ),
85 ],
86 ],
87 ];
88 }
89
90 /**
91 * {@inheritDoc}
92 *
93 * @return array<string, mixed>
94 */
95 public function get_output_schema() {
96 return [
97 'type' => 'object',
98 'properties' => [
99 'id' => [ 'type' => 'integer' ],
100 'title' => [ 'type' => 'string' ],
101 'post_type' => [ 'type' => 'string' ],
102 'status' => [ 'type' => 'string' ],
103 'url' => [ 'type' => 'string' ],
104 'edit_url' => [ 'type' => 'string' ],
105 'excerpt' => [ 'type' => 'string' ],
106 'content' => [
107 'type' => 'string',
108 'description' => __( 'Plain-text body as the page renders it, resolved from the page builder where one owns the page.', 'thinkrank' ),
109 ],
110 'post_content' => [
111 'type' => 'string',
112 'description' => __( 'The raw stored post_content column. Empty or stale on builder pages, which is why it is reported separately.', 'thinkrank' ),
113 ],
114 'content_source' => [
115 'type' => 'string',
116 'enum' => [ 'post_content', 'builder' ],
117 'description' => __( 'Where the rendered body came from.', 'thinkrank' ),
118 ],
119 'builder' => [
120 'type' => [ 'string', 'null' ],
121 'description' => __( 'The page builder that owns this post, or null for the block/classic editor.', 'thinkrank' ),
122 ],
123 'word_count' => [ 'type' => 'integer' ],
124 'truncated' => [ 'type' => 'boolean' ],
125 ],
126 ];
127 }
128
129 /**
130 * Execute ability.
131 *
132 * @param array<string, mixed> $input Ability input payload.
133 * @return array<string, mixed>|\WP_Error
134 */
135 public function execute( $input ) {
136 $input = (array) $input;
137 $post_id = (int) ( $input['post_id'] ?? 0 );
138 $post = $post_id > 0 ? get_post( $post_id ) : null;
139
140 if ( ! $post instanceof \WP_Post ) {
141 return new \WP_Error(
142 'thinkrank_post_not_found',
143 sprintf(
144 /* translators: %d: post ID that was sent. */
145 __( 'No post with id %d. Use list-content-items to find valid ids.', 'thinkrank' ),
146 $post_id
147 ),
148 [ 'status' => 404 ]
149 );
150 }
151
152 // Reading a password-protected body would hand its contents to anyone
153 // the connector answers, so it is withheld the way the front end
154 // withholds it.
155 if ( '' !== (string) $post->post_password ) {
156 return new \WP_Error(
157 'thinkrank_post_password_protected',
158 __( 'This post is password protected, so its body is not returned.', 'thinkrank' ),
159 [ 'status' => 403 ]
160 );
161 }
162
163 $source = Builder_Content::resolve( $post );
164 $resolved = trim( wp_strip_all_tags( $source ) );
165 // Counted over the reading text rather than the body returned, so shortcode
166 // syntax is not counted as words and two words split only by a tag stay
167 // two (#893), and a token of punctuation alone is not a word. Words,
168 // whatever the locale: the field says words.
169 $reading = Word_Count_Index::reading_text( $source );
170
171 $builder = $this->builder_for( $post_id );
172
173 $body = $resolved;
174 $truncated = false;
175
176 if ( isset( $input['max_chars'] ) ) {
177 $limit = max( 1, (int) $input['max_chars'] );
178
179 if ( mb_strlen( $body ) > $limit ) {
180 $body = mb_substr( $body, 0, $limit );
181 $truncated = true;
182 }
183 }
184
185 return [
186 'id' => $post_id,
187 'title' => (string) get_the_title( $post ),
188 'post_type' => (string) $post->post_type,
189 'status' => (string) $post->post_status,
190 'url' => (string) get_permalink( $post ),
191 'edit_url' => (string) get_edit_post_link( $post_id, 'raw' ),
192 'excerpt' => (string) $post->post_excerpt,
193 'content' => $body,
194 'post_content' => (string) $post->post_content,
195 'content_source' => null === $builder ? 'post_content' : 'builder',
196 'builder' => $builder,
197 'word_count' => Word_Count_Index::count_words( $reading ),
198 'truncated' => $truncated,
199 ];
200 }
201
202 /**
203 * Which page builder owns this post, if any.
204 *
205 * Derived from the meta keys Builder_Content already resolves through, so
206 * the answer cannot drift from the resolution above.
207 *
208 * @param int $post_id Post ID.
209 * @return string|null Builder label, or null for the block/classic editor.
210 */
211 private function builder_for( int $post_id ): ?string {
212 if ( Builder_Content::bricks_supersedes_post_content( $post_id ) ) {
213 return 'bricks';
214 }
215
216 // Same keys Builder_Content resolves through, in the same order. Kept
217 // as a local map because this one also needs a display label per key,
218 // and Oxygen spans several of them across its generations — Oxygen 6 is
219 // Breakdance under the hood, and Oxygen classic 4.8.3+ prefixes its
220 // keys with an underscore. Missing the JSON and prefixed keys, a
221 // current Oxygen classic page was reported as built in no builder.
222 $labels = [
223 '_breakdance_data' => 'breakdance',
224 '_oxygen_data' => 'oxygen',
225 '_ct_builder_json' => 'oxygen',
226 'ct_builder_json' => 'oxygen',
227 '_ct_builder_shortcodes' => 'oxygen',
228 'ct_builder_shortcodes' => 'oxygen',
229 '_elementor_data' => 'elementor',
230 '_fl_builder_data' => 'beaver-builder',
231 ];
232
233 foreach ( $labels as $key => $label ) {
234 $stored = get_post_meta( $post_id, $key, true );
235
236 if ( ( is_string( $stored ) && '' !== trim( $stored ) ) || ( is_array( $stored ) && ! empty( $stored ) ) ) {
237 return $label;
238 }
239 }
240
241 return null;
242 }
243 }
244