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

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