PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.30.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.30.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 / seo / class-builder-content.php

class-builder-content.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.30.0, at includes/seo/class-builder-content.php

322 lines 11.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Page-builder content extraction.
4 *
5 * SEO analysis reads `post_content`, which is only the real text on a classic
6 * post. Page builders keep the words somewhere else, and every server-side
7 * scoring path — bulk analysis, the post-list SEO Overview column, the MCP
8 * abilities, cron reports — saw an empty page as a result:
9 *
10 * - Oxygen / Breakdance leave `post_content` completely EMPTY and store the
11 * node tree in postmeta. Nothing to render, nothing to strip: the analyzer
12 * reported "No content" on pages with well over a thousand visible words.
13 * - Elementor does the same via `_elementor_data`.
14 * - Divi 5 and Gutenberg do store block markup in `post_content`, but Divi
15 * keeps module text inside the block's JSON attributes — inside an HTML
16 * comment, which tag stripping removes wholesale.
17 * - Divi 4 and other shortcode builders keep text in shortcode attributes.
18 *
19 * Extraction reads the builder's own stored data rather than invoking its
20 * render engine. Rendering an Oxygen page outside a front-end request is slow,
21 * stateful and can fatal in an admin context, whereas the stored tree is just
22 * JSON — cheap, side-effect free and safe to touch during a bulk run.
23 *
24 * @package ThinkRank\SEO
25 * @since 1.23.0
26 */
27
28 declare(strict_types=1);
29
30 namespace ThinkRank\SEO;
31
32 if (!defined('ABSPATH')) {
33 exit;
34 }
35
36 /**
37 * Resolves the analyzable content of a post, whatever built it.
38 */
39 class Builder_Content {
40
41 /**
42 * Post meta keys that hold builder data, in priority order.
43 *
44 * Several generations of the same builder are listed on purpose: Oxygen 6
45 * is Breakdance under the hood (`_breakdance_data`), while earlier Oxygen
46 * releases used `_oxygen_data` or the shortcode-based
47 * `ct_builder_shortcodes`. A site can only have one of them.
48 *
49 * @var string[]
50 */
51 private const BUILDER_META_KEYS = [
52 '_breakdance_data', // Oxygen 6+ / Breakdance
53 '_oxygen_data', // Oxygen (earlier releases)
54 'ct_builder_shortcodes', // Oxygen classic
55 '_elementor_data', // Elementor
56 ];
57
58 /**
59 * JSON keys whose values are user-visible text.
60 *
61 * Builder trees mix content with configuration, so a blind string sweep
62 * would count CSS classes and option slugs as words. Matching on the key
63 * keeps the word count honest.
64 *
65 * @var string[]
66 */
67 private const CONTENT_KEYS = [
68 'text', 'title', 'subtitle', 'heading', 'subheading', 'content',
69 'description', 'caption', 'excerpt', 'label', 'value', 'html',
70 'editor', 'quote', 'answer', 'question', 'body', 'button_text',
71 ];
72
73 /**
74 * Resolve the content worth analyzing for a post.
75 *
76 * @param \WP_Post $post Post being analyzed.
77 * @return string HTML/text to analyze.
78 */
79 public static function resolve(\WP_Post $post): string {
80 return self::resolve_markup((string) $post->post_content, $post);
81 }
82
83 /**
84 * Resolve an arbitrary chunk of editor markup for the given post.
85 *
86 * The editor sends its live content to the scorer so an author sees their
87 * unsaved edits reflected. On a builder page that live string is the raw
88 * builder markup — the block editor hands over Divi's
89 * `<!-- wp:divi/... -->` comments verbatim, because it cannot render
90 * blocks it has no client-side registration for. Analyzed as-is it reads
91 * as zero words, which is how a Divi page could show a correct saved score
92 * while the live Content Analysis panel next to it still said
93 * "No content".
94 *
95 * Running the live string through the same chain as stored content keeps
96 * both paths honest, and falling through to the post's builder storage
97 * covers builders (Oxygen) whose editor content is empty to begin with.
98 *
99 * @since 1.23.0
100 *
101 * @param string $raw Markup to analyze.
102 * @param \WP_Post $post Post the markup belongs to.
103 * @return string Content to analyze.
104 */
105 public static function resolve_markup(string $raw, \WP_Post $post): string {
106 $content = self::render_post_content($raw);
107
108 // Block markup that renders to nothing usually means the builder that
109 // owns those blocks did not register them in this context — Divi 5
110 // loads its module library lazily per-request, so in CLI, REST, admin
111 // and block-editor requests do_blocks() yields an empty string while
112 // the words sit right there in the block attributes. Read them
113 // directly.
114 if (self::is_blank($content)) {
115 $from_blocks = self::from_block_attributes($raw);
116 if (!self::is_blank($from_blocks)) {
117 $content = $from_blocks;
118 }
119 }
120
121 // Only reach for builder storage when the markup yielded nothing — a
122 // classic post must never pay for this.
123 if (self::is_blank($content)) {
124 $builder = self::from_builder_meta((int) $post->ID);
125 if (!self::is_blank($builder)) {
126 $content = $builder;
127 }
128 }
129
130 // A resolution that collapsed to nothing is worse than the raw markup.
131 if (self::is_blank($content) && !self::is_blank($raw)) {
132 $content = $raw;
133 }
134
135 /**
136 * Filter the content ThinkRank analyzes for a post.
137 *
138 * Use this to teach ThinkRank about a builder it does not know, or to
139 * override extraction for one it does.
140 *
141 * @since 1.23.0
142 *
143 * @param string $content Resolved content.
144 * @param \WP_Post $post Post being analyzed.
145 * @param string $raw Markup this resolution started from.
146 */
147 return (string) apply_filters('thinkrank_analyzable_content', $content, $post, $raw);
148 }
149
150 /**
151 * Render blocks and shortcodes found in post_content.
152 *
153 * Best-effort: a third-party block that fatals must not take the whole
154 * score down with it.
155 *
156 * @param string $raw Raw post content.
157 * @return string Rendered content.
158 */
159 private static function render_post_content(string $raw): string {
160 if ('' === trim($raw)) {
161 return '';
162 }
163
164 $content = $raw;
165
166 try {
167 if (function_exists('has_blocks') && function_exists('do_blocks') && has_blocks($raw)) {
168 $content = do_blocks($raw);
169 }
170
171 // Block output can itself contain shortcodes, so this runs either way.
172 if (function_exists('do_shortcode') && strpos($content, '[') !== false) {
173 $content = do_shortcode($content);
174 }
175 } catch (\Throwable $e) {
176 return $raw;
177 }
178
179 return self::is_blank($content) ? $raw : $content;
180 }
181
182 /**
183 * Extract text from the attributes of parsed blocks.
184 *
185 * @param string $raw Raw post content containing block markup.
186 * @return string Collected text, or '' when nothing was found.
187 */
188 private static function from_block_attributes(string $raw): string {
189 if (!function_exists('parse_blocks') || !function_exists('has_blocks') || !has_blocks($raw)) {
190 return '';
191 }
192
193 try {
194 $blocks = parse_blocks($raw);
195 } catch (\Throwable $e) {
196 return '';
197 }
198
199 $attrs = [];
200 $collect = static function (array $items) use (&$collect, &$attrs): void {
201 foreach ($items as $block) {
202 if (!empty($block['attrs']) && is_array($block['attrs'])) {
203 $attrs[] = $block['attrs'];
204 }
205 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
206 $collect($block['innerBlocks']);
207 }
208 }
209 };
210 $collect($blocks);
211
212 return empty($attrs) ? '' : self::text_from_tree($attrs);
213 }
214
215 /**
216 * Pull text out of whichever builder stored this post.
217 *
218 * @param int $post_id Post ID.
219 * @return string Extracted text, or '' when no builder data was found.
220 */
221 private static function from_builder_meta(int $post_id): string {
222 foreach (self::BUILDER_META_KEYS as $key) {
223 $stored = get_post_meta($post_id, $key, true);
224
225 if (is_string($stored) && '' !== trim($stored)) {
226 $decoded = json_decode($stored, true);
227
228 // JSON node tree (Breakdance/Oxygen 6, Elementor).
229 if (is_array($decoded)) {
230 $text = self::text_from_tree($decoded);
231 if (!self::is_blank($text)) {
232 return $text;
233 }
234 continue;
235 }
236
237 // Shortcode tree (Oxygen classic).
238 if (strpos($stored, '[') !== false && function_exists('do_shortcode')) {
239 try {
240 $rendered = do_shortcode($stored);
241 } catch (\Throwable $e) {
242 $rendered = $stored;
243 }
244 if (!self::is_blank($rendered)) {
245 return $rendered;
246 }
247 }
248
249 continue;
250 }
251
252 // Some builders store an already-decoded array.
253 if (is_array($stored)) {
254 $text = self::text_from_tree($stored);
255 if (!self::is_blank($text)) {
256 return $text;
257 }
258 }
259 }
260
261 return '';
262 }
263
264 /**
265 * Walk a builder node tree and collect the user-visible text.
266 *
267 * Values are joined with block-level markup so downstream heading, link and
268 * image detection keeps working on the result.
269 *
270 * @param array $tree Decoded builder tree.
271 * @return string Collected HTML.
272 */
273 private static function text_from_tree(array $tree): string {
274 $collected = [];
275
276 $walk = static function ($node, $key = null) use (&$walk, &$collected): void {
277 if (is_array($node)) {
278 foreach ($node as $child_key => $child) {
279 $walk($child, is_string($child_key) ? $child_key : $key);
280 }
281 return;
282 }
283
284 if (!is_string($node) || '' === trim($node)) {
285 return;
286 }
287
288 $is_content_key = is_string($key)
289 && in_array(strtolower($key), self::CONTENT_KEYS, true);
290
291 // Markup is content wherever it appears; bare strings only count
292 // when their key says they are content, so slugs and class names
293 // stay out of the word count.
294 if ($is_content_key || strpos($node, '<') !== false) {
295 $collected[] = $node;
296 }
297 };
298
299 $walk($tree);
300
301 if (empty($collected)) {
302 return '';
303 }
304
305 // De-duplicate: builder trees often repeat a value across responsive
306 // breakpoints, which would otherwise multiply the word count.
307 $collected = array_unique($collected);
308
309 return implode("\n", $collected);
310 }
311
312 /**
313 * Whether a value carries no readable text.
314 *
315 * @param string $value Candidate content.
316 * @return bool
317 */
318 private static function is_blank(string $value): bool {
319 return '' === trim(wp_strip_all_tags($value));
320 }
321 }
322