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

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