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

616 lines 21.6 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 * JSON keys whose values hold a link destination.
75 *
76 * Builders store a link's destination in a structured field separate from
77 * its label, either as a bare URL string or as a `{ url: … }` object.
78 * Neither shape survives a text sweep — the key is not content and a bare
79 * URL contains no `<` — so no `<a>` tag reached the link counters.
80 *
81 * @var string[]
82 */
83 private const URL_KEYS = [
84 'link', 'url', 'href', 'link_url', 'button_link', 'permalink', 'link_to',
85 ];
86
87 /**
88 * JSON keys whose values hold an image, as a URL string or `{ url, alt }`.
89 *
90 * @var string[]
91 */
92 private const IMAGE_KEYS = [
93 'image', 'src', 'image_url', 'background_image', 'bg_image', 'photo',
94 ];
95
96 /**
97 * JSON keys that carry a heading level for the node's text.
98 *
99 * A builder heading's text is collected (its key is in CONTENT_KEYS) and so
100 * counts toward the word count, but it arrives as bare text with no `<h2>`
101 * wrapper — which is why heading-structure checks saw none.
102 *
103 * @var string[]
104 */
105 private const HEADING_TAG_KEYS = [
106 'header_size', 'heading_tag', 'html_tag', 'title_tag', 'tag', 'level', 'size',
107 ];
108
109 /**
110 * Keys whose value is alternative text for a sibling image.
111 *
112 * @var string[]
113 */
114 private const ALT_KEYS = ['alt', 'alt_text', 'image_alt', 'title'];
115
116 /**
117 * Resolve the content worth analyzing for a post.
118 *
119 * @param \WP_Post $post Post being analyzed.
120 * @return string HTML/text to analyze.
121 */
122 public static function resolve(\WP_Post $post): string {
123 return self::resolve_markup((string) $post->post_content, $post);
124 }
125
126 /**
127 * Resolve an arbitrary chunk of editor markup for the given post.
128 *
129 * The editor sends its live content to the scorer so an author sees their
130 * unsaved edits reflected. On a builder page that live string is the raw
131 * builder markup — the block editor hands over Divi's
132 * `<!-- wp:divi/... -->` comments verbatim, because it cannot render
133 * blocks it has no client-side registration for. Analyzed as-is it reads
134 * as zero words, which is how a Divi page could show a correct saved score
135 * while the live Content Analysis panel next to it still said
136 * "No content".
137 *
138 * Running the live string through the same chain as stored content keeps
139 * both paths honest, and falling through to the post's builder storage
140 * covers builders (Oxygen) whose editor content is empty to begin with.
141 *
142 * @since 1.23.0
143 *
144 * @param string $raw Markup to analyze.
145 * @param \WP_Post $post Post the markup belongs to.
146 * @return string Content to analyze.
147 */
148 public static function resolve_markup(string $raw, \WP_Post $post): string {
149 $content = self::render_post_content($raw);
150
151 // Block markup that renders to nothing usually means the builder that
152 // owns those blocks did not register them in this context — Divi 5
153 // loads its module library lazily per-request, so in CLI, REST, admin
154 // and block-editor requests do_blocks() yields an empty string while
155 // the words sit right there in the block attributes. Read them
156 // directly.
157 if (self::is_blank($content)) {
158 $from_blocks = self::from_block_attributes($raw);
159 if (!self::is_blank($from_blocks)) {
160 $content = $from_blocks;
161 }
162 }
163
164 // Only reach for builder storage when the markup yielded nothing — a
165 // classic post must never pay for this.
166 if (self::is_blank($content)) {
167 $builder = self::from_builder_meta((int) $post->ID);
168 if (!self::is_blank($builder)) {
169 $content = $builder;
170 }
171 }
172
173 // A resolution that collapsed to nothing is worse than the raw markup.
174 if (self::is_blank($content) && !self::is_blank($raw)) {
175 $content = $raw;
176 }
177
178 /**
179 * Filter the content ThinkRank analyzes for a post.
180 *
181 * Use this to teach ThinkRank about a builder it does not know, or to
182 * override extraction for one it does.
183 *
184 * @since 1.23.0
185 *
186 * @param string $content Resolved content.
187 * @param \WP_Post $post Post being analyzed.
188 * @param string $raw Markup this resolution started from.
189 */
190 return (string) apply_filters('thinkrank_analyzable_content', $content, $post, $raw);
191 }
192
193 /**
194 * Render blocks and shortcodes found in post_content.
195 *
196 * Best-effort: a third-party block that fatals must not take the whole
197 * score down with it.
198 *
199 * @param string $raw Raw post content.
200 * @return string Rendered content.
201 */
202 private static function render_post_content(string $raw): string {
203 if ('' === trim($raw)) {
204 return '';
205 }
206
207 $content = $raw;
208
209 try {
210 if (function_exists('has_blocks') && function_exists('do_blocks') && has_blocks($raw)) {
211 $content = do_blocks($raw);
212 }
213
214 // Block output can itself contain shortcodes, so this runs either way.
215 if (function_exists('do_shortcode') && strpos($content, '[') !== false) {
216 $content = do_shortcode($content);
217 }
218 } catch (\Throwable $e) {
219 return $raw;
220 }
221
222 return self::is_blank($content) ? $raw : $content;
223 }
224
225 /**
226 * Extract text from the attributes of parsed blocks.
227 *
228 * @param string $raw Raw post content containing block markup.
229 * @return string Collected text, or '' when nothing was found.
230 */
231 private static function from_block_attributes(string $raw): string {
232 if (!function_exists('parse_blocks') || !function_exists('has_blocks') || !has_blocks($raw)) {
233 return '';
234 }
235
236 try {
237 $blocks = parse_blocks($raw);
238 } catch (\Throwable $e) {
239 return '';
240 }
241
242 $attrs = [];
243 $collect = static function (array $items) use (&$collect, &$attrs): void {
244 foreach ($items as $block) {
245 if (!empty($block['attrs']) && is_array($block['attrs'])) {
246 $attrs[] = $block['attrs'];
247 }
248 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
249 $collect($block['innerBlocks']);
250 }
251 }
252 };
253 $collect($blocks);
254
255 return empty($attrs) ? '' : self::text_from_tree($attrs);
256 }
257
258 /**
259 * Pull text out of whichever builder stored this post.
260 *
261 * @param int $post_id Post ID.
262 * @return string Extracted text, or '' when no builder data was found.
263 */
264 private static function from_builder_meta(int $post_id): string {
265 foreach (self::BUILDER_META_KEYS as $key) {
266 $stored = get_post_meta($post_id, $key, true);
267
268 if (is_string($stored) && '' !== trim($stored)) {
269 $decoded = json_decode($stored, true);
270
271 // JSON node tree (Breakdance/Oxygen 6, Elementor).
272 if (is_array($decoded)) {
273 $text = self::text_from_tree($decoded);
274 if (!self::is_blank($text)) {
275 return $text;
276 }
277 continue;
278 }
279
280 // Shortcode tree (Oxygen classic).
281 if (strpos($stored, '[') !== false && function_exists('do_shortcode')) {
282 try {
283 $rendered = do_shortcode($stored);
284 } catch (\Throwable $e) {
285 $rendered = $stored;
286 }
287 if (!self::is_blank($rendered)) {
288 return $rendered;
289 }
290 }
291
292 continue;
293 }
294
295 // Some builders store an already-decoded array.
296 if (is_array($stored)) {
297 $text = self::text_from_tree($stored);
298 if (!self::is_blank($text)) {
299 return $text;
300 }
301 }
302 }
303
304 return '';
305 }
306
307 /**
308 * Walk a builder node tree and collect the user-visible text.
309 *
310 * Values are joined with block-level markup so downstream heading, link and
311 * image detection keeps working on the result.
312 *
313 * @param array $tree Decoded builder tree.
314 * @return string Collected HTML.
315 */
316 private static function text_from_tree(array $tree): string {
317 $collected = [];
318
319 // Strings already represented inside reconstructed markup, so the plain
320 // sweep below doesn't emit a link label or heading a second time and
321 // double it in the word count.
322 $consumed = [];
323
324 // Pass 1 — rebuild <a>, <img> and <hN> from node *shape*. This has to
325 // happen per node rather than per leaf: a link's label and its
326 // destination are separate sibling fields, so once the tree is
327 // flattened to leaves the pairing is gone.
328 $reconstruct = static function ($node) use (&$reconstruct, &$collected, &$consumed): void {
329 if (!is_array($node)) {
330 return;
331 }
332
333 $markup = self::markup_for_node($node, $consumed);
334 if ('' !== $markup) {
335 $collected[] = $markup;
336 }
337
338 foreach ($node as $child_key => $child) {
339 // A `link` / `image` sub-object is a destination descriptor the
340 // parent has already folded into its markup. Descending into it
341 // would emit the same URL a second time as a bare link, and
342 // would turn an image's own `url` field into a spurious <a>.
343 if (is_string($child_key)
344 && (in_array(strtolower($child_key), self::URL_KEYS, true)
345 || in_array(strtolower($child_key), self::IMAGE_KEYS, true))
346 ) {
347 continue;
348 }
349
350 $reconstruct($child);
351 }
352 };
353 $reconstruct($tree);
354
355 // Pass 2 — remaining visible text.
356 $walk = static function ($node, $key = null) use (&$walk, &$collected, &$consumed): void {
357 if (is_array($node)) {
358 foreach ($node as $child_key => $child) {
359 $walk($child, is_string($child_key) ? $child_key : $key);
360 }
361 return;
362 }
363
364 if (!is_string($node) || '' === trim($node)) {
365 return;
366 }
367
368 // Already inside a reconstructed tag.
369 if (in_array($node, $consumed, true)) {
370 return;
371 }
372
373 $is_content_key = is_string($key)
374 && in_array(strtolower($key), self::CONTENT_KEYS, true);
375
376 // Markup is content wherever it appears; bare strings only count
377 // when their key says they are content, so slugs and class names
378 // stay out of the word count.
379 if ($is_content_key || strpos($node, '<') !== false) {
380 $collected[] = $node;
381 }
382 };
383
384 $walk($tree);
385
386 if (empty($collected)) {
387 return '';
388 }
389
390 // De-duplicate: builder trees often repeat a value across responsive
391 // breakpoints, which would otherwise multiply the word count.
392 $collected = array_unique($collected);
393
394 return implode("\n", $collected);
395 }
396
397 /**
398 * Rebuild the HTML a single builder node represents, if any.
399 *
400 * Looks only at the node's own fields (plus one level of nesting, because
401 * builders commonly wrap a destination as `{ url: … }`). Returns an empty
402 * string for the vast majority of nodes, which are layout or configuration.
403 *
404 * Any leaf string folded into the returned markup is appended to $consumed
405 * so the plain-text sweep doesn't count it twice.
406 *
407 * @param array $node Builder node.
408 * @param array $consumed Collects strings represented in the returned markup.
409 * @return string Reconstructed HTML, or '' when the node carries none.
410 */
411 private static function markup_for_node(array $node, array &$consumed): string {
412 $text = self::first_value($node, self::CONTENT_KEYS);
413 $url = self::url_from($node, self::URL_KEYS);
414 $image = self::image_from($node);
415 $tag = self::heading_tag_from($node);
416
417 $parts = [];
418
419 // Image: alt text matters as much as the tag, since alt checks run over
420 // whatever this returns.
421 if ('' !== $image['url']) {
422 $alt = '' !== $image['alt'] ? $image['alt'] : (string) self::first_value($node, self::ALT_KEYS);
423 if ('' !== $alt) {
424 $consumed[] = $alt;
425 }
426 $parts[] = sprintf(
427 '<img src="%s" alt="%s" />',
428 esc_url_raw($image['url']),
429 htmlspecialchars($alt, ENT_QUOTES)
430 );
431 }
432
433 if ('' !== $text) {
434 $inner = $text;
435
436 if ('' !== $url) {
437 $consumed[] = $text;
438 $inner = sprintf('<a href="%s">%s</a>', esc_url_raw($url), $text);
439 }
440
441 if ('' !== $tag) {
442 $consumed[] = $text;
443 $parts[] = sprintf('<%1$s>%2$s</%1$s>', $tag, $inner);
444 } elseif ('' !== $url) {
445 $parts[] = $inner;
446 }
447 } elseif ('' !== $url) {
448 // A destination with no label still counts as a link for link
449 // checks; the URL doubles as its anchor text.
450 $parts[] = sprintf('<a href="%1$s">%1$s</a>', esc_url_raw($url));
451 }
452
453 return implode("\n", $parts);
454 }
455
456 /**
457 * First non-empty scalar value under any of the given keys.
458 *
459 * @param array $node Builder node.
460 * @param string[] $keys Candidate keys.
461 * @return string Trimmed value, or '' when none match.
462 */
463 private static function first_value(array $node, array $keys): string {
464 foreach ($node as $key => $value) {
465 if (!is_string($key) || !is_string($value)) {
466 continue;
467 }
468 if (in_array(strtolower($key), $keys, true) && '' !== trim($value)) {
469 return trim($value);
470 }
471 }
472
473 return '';
474 }
475
476 /**
477 * Link destination held by a node, as a bare string or a `{ url: … }` object.
478 *
479 * @param array $node Builder node.
480 * @param string[] $keys Candidate keys.
481 * @return string URL, or '' when the node holds none.
482 */
483 private static function url_from(array $node, array $keys): string {
484 foreach ($node as $key => $value) {
485 if (!is_string($key) || !in_array(strtolower($key), $keys, true)) {
486 continue;
487 }
488
489 if (is_string($value) && self::looks_like_url($value)) {
490 return trim($value);
491 }
492
493 // Elementor and Breakdance both nest the destination one level down.
494 if (is_array($value)) {
495 foreach ($value as $nested_key => $nested) {
496 if (is_string($nested_key)
497 && in_array(strtolower($nested_key), ['url', 'href', 'permalink'], true)
498 && is_string($nested)
499 && self::looks_like_url($nested)
500 ) {
501 return trim($nested);
502 }
503 }
504 }
505 }
506
507 return '';
508 }
509
510 /**
511 * Image URL and alt text held by a node.
512 *
513 * @param array $node Builder node.
514 * @return array{url:string,alt:string}
515 */
516 private static function image_from(array $node): array {
517 foreach ($node as $key => $value) {
518 if (!is_string($key) || !in_array(strtolower($key), self::IMAGE_KEYS, true)) {
519 continue;
520 }
521
522 if (is_string($value) && self::looks_like_url($value)) {
523 return ['url' => trim($value), 'alt' => ''];
524 }
525
526 if (is_array($value)) {
527 $url = '';
528 $alt = '';
529 foreach ($value as $nested_key => $nested) {
530 if (!is_string($nested_key) || !is_string($nested)) {
531 continue;
532 }
533 $nested_key = strtolower($nested_key);
534 if ('' === $url && in_array($nested_key, ['url', 'src'], true) && self::looks_like_url($nested)) {
535 $url = trim($nested);
536 }
537 if ('' === $alt && in_array($nested_key, self::ALT_KEYS, true)) {
538 $alt = trim($nested);
539 }
540 }
541 if ('' !== $url) {
542 return ['url' => $url, 'alt' => $alt];
543 }
544 }
545 }
546
547 return ['url' => '', 'alt' => ''];
548 }
549
550 /**
551 * Heading tag a node asks for, normalised to h1–h6.
552 *
553 * Accepts both the `h2` form and a bare level like `2`.
554 *
555 * @param array $node Builder node.
556 * @return string Tag name, or '' when the node is not a heading.
557 */
558 private static function heading_tag_from(array $node): string {
559 foreach ($node as $key => $value) {
560 if (!is_string($key) || !in_array(strtolower($key), self::HEADING_TAG_KEYS, true)) {
561 continue;
562 }
563
564 if (is_string($value) && preg_match('/^h([1-6])$/i', trim($value), $m)) {
565 return 'h' . $m[1];
566 }
567
568 // A bare level only counts under a key that unambiguously means one;
569 // `size` and `tag` carry values like "large" or "div" far more often.
570 if (is_numeric($value)
571 && in_array(strtolower($key), ['level'], true)
572 && (int) $value >= 1 && (int) $value <= 6
573 ) {
574 return 'h' . (int) $value;
575 }
576 }
577
578 return '';
579 }
580
581 /**
582 * Whether a string is plausibly a link or asset destination.
583 *
584 * Deliberately permissive about relative paths — builders store internal
585 * links that way — but rejects the option slugs and CSS values that make up
586 * most of a builder tree.
587 *
588 * @param string $value Candidate.
589 * @return bool
590 */
591 private static function looks_like_url(string $value): bool {
592 $value = trim($value);
593
594 if ('' === $value || strlen($value) > 2048) {
595 return false;
596 }
597
598 if (preg_match('#^(https?:)?//#i', $value) || str_starts_with($value, '/')) {
599 return true;
600 }
601
602 // Protocol-ish destinations a link node can legitimately hold.
603 return (bool) preg_match('#^(mailto:|tel:|\#)#i', $value);
604 }
605
606 /**
607 * Whether a value carries no readable text.
608 *
609 * @param string $value Candidate content.
610 * @return bool
611 */
612 private static function is_blank(string $value): bool {
613 return '' === trim(wp_strip_all_tags($value));
614 }
615 }
616