loadHTML( '' . $html, LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_COMPACT | LIBXML_PARSEHUGE ); libxml_clear_errors(); libxml_use_internal_errors( $prev ); return $ok ? $doc : null; } /** * Tidy text. * * @param string $text Text. * @return string */ public static function clean( $text ) { return trim( preg_replace( '/\s+/u', ' ', (string) $text ) ); } /** * Does an id look machine generated (so unsuitable for a stable selector)? * * @param string $id Id. * @return bool */ private static function unstable_id( $id ) { return (bool) preg_match( '/\d{2,}|[0-9a-f]{8,}|^[a-z]+-[0-9]+$/i', $id ); } /** * Build a short, stable CSS selector for an element. * * @param \DOMElement $el Element. * @return string */ public static function selector( \DOMElement $el ) { $parts = array(); $node = $el; $depth = 0; while ( $node instanceof \DOMElement && $depth < 6 ) { $tag = strtolower( $node->tagName ); if ( 'html' === $tag ) { $parts[] = 'html'; break; } $id = $node->getAttribute( 'id' ); if ( '' !== $id && ! self::unstable_id( $id ) && preg_match( '/^[A-Za-z][\w-]*$/', $id ) ) { $parts[] = $tag . '#' . $id; break; } $part = $tag; if ( 'body' === $tag ) { $parts[] = 'body'; break; } $classes = preg_split( '/\s+/', trim( $node->getAttribute( 'class' ) ) ); $stable = array(); foreach ( (array) $classes as $c ) { if ( '' !== $c && preg_match( '/^[A-Za-z_][\w-]*$/', $c ) && ! preg_match( '/\d{2,}|^(is|has)-|^wp-image-|^post-|^page-id|^align|^wp-block-group|^wp-site-blocks|^wp-singular|^page-template|-is-layout-/', $c ) ) { $stable[] = $c; } if ( count( $stable ) >= 2 ) { break; } } if ( $stable ) { $part .= '.' . implode( '.', $stable ); } if ( 'body' !== $tag && $node->parentNode instanceof \DOMElement ) { $same = 0; $pos = 0; foreach ( $node->parentNode->childNodes as $sib ) { if ( $sib instanceof \DOMElement && $sib->tagName === $node->tagName ) { ++$same; if ( $sib->isSameNode( $node ) ) { $pos = $same; } } } if ( $same > 1 ) { $part .= ':nth-of-type(' . $pos . ')'; } } $parts[] = $part; if ( 'body' === $tag ) { break; } $node = $node->parentNode; ++$depth; } return implode( ' > ', array_reverse( $parts ) ); } /** * Short, readable snippet of an element: its opening tag and a bit of text. * * @param \DOMElement $el Element. * @return string */ public static function snippet( \DOMElement $el ) { $tag = strtolower( $el->tagName ); if ( in_array( $tag, array( 'body', 'html', 'head', 'main' ), true ) ) { return '<' . $tag . '>'; } $attrs = ''; $skip = array( 'srcset', 'sizes', 'style', 'data-src', 'data-srcset', 'loading', 'decoding', 'fetchpriority' ); foreach ( $el->attributes as $attr ) { if ( in_array( $attr->name, $skip, true ) || 0 === strpos( $attr->name, 'data-' ) ) { continue; } $val = $attr->value; if ( strlen( $val ) > 70 ) { $val = substr( $val, 0, 67 ) . '...'; } $attrs .= ' ' . $attr->name . '="' . str_replace( '"', '"', $val ) . '"'; if ( strlen( $attrs ) > 220 ) { $attrs .= ' ...'; break; } } $void = in_array( $tag, array( 'img', 'input', 'br', 'hr', 'meta', 'link', 'area', 'source', 'track' ), true ); if ( $void ) { return '<' . $tag . $attrs . '>'; } $text = self::clean( $el->textContent ); if ( strlen( $text ) > 60 ) { $text = rtrim( substr( $text, 0, 57 ) ) . '...'; } return '<' . $tag . $attrs . '>' . $text . ''; } /** * Normalise a selector or snippet so template-level repeats get the same fingerprint. * * @param string $value Value. * @return string */ public static function normalise( $value ) { $value = strtolower( self::clean( $value ) ); $value = preg_replace( '/\b(wp-image|post|page-id|postid|attachment|menu-item|cat-item|comment)-\d+/', '$1-n', $value ); $value = preg_replace( '/\b(\w+)-\d{4,}\b/', '$1-n', $value ); return $value; } /** * Resolve a link against the page URL so relative and absolute forms compare equal. * * @param string $href Link. * @param string $base Page URL. * @return string */ public static function resolve( $href, $base ) { $href = trim( preg_replace( '/#.*$/', '', $href ) ); if ( preg_match( '#^https?://#i', $href ) ) { return strtolower( untrailingslashit( $href ) ); } $p = wp_parse_url( $base ); if ( empty( $p['host'] ) ) { return strtolower( untrailingslashit( $href ) ); } $origin = ( isset( $p['scheme'] ) ? $p['scheme'] : 'http' ) . '://' . $p['host'] . ( isset( $p['port'] ) ? ':' . $p['port'] : '' ); if ( 0 === strpos( $href, '//' ) ) { return strtolower( untrailingslashit( ( isset( $p['scheme'] ) ? $p['scheme'] : 'http' ) . ':' . $href ) ); } if ( 0 === strpos( $href, '/' ) ) { return strtolower( untrailingslashit( $origin . $href ) ); } if ( 0 === strpos( $href, '?' ) ) { return strtolower( untrailingslashit( $origin . ( isset( $p['path'] ) ? $p['path'] : '/' ) . $href ) ); } $dir = isset( $p['path'] ) ? preg_replace( '#/[^/]*$#', '/', $p['path'] ) : '/'; return strtolower( untrailingslashit( $origin . $dir . $href ) ); } /** * Visible text of a node, skipping aria-hidden branches. * * @param \DOMNode $node Node. * @return string */ public static function text_of( \DOMNode $node ) { $out = ''; foreach ( $node->childNodes as $child ) { if ( $child instanceof \DOMText ) { $out .= $child->nodeValue; } elseif ( $child instanceof \DOMElement ) { $tag = strtolower( $child->tagName ); if ( in_array( $tag, array( 'script', 'style', 'template' ), true ) ) { continue; } if ( 'true' === $child->getAttribute( 'aria-hidden' ) ) { continue; } if ( 'img' === $tag ) { $out .= ' ' . $child->getAttribute( 'alt' ) . ' '; } elseif ( 'svg' === $tag ) { $out .= ' ' . self::svg_name( $child ) . ' '; } else { $label = $child->getAttribute( 'aria-label' ); $out .= ' ' . ( '' !== $label ? $label : self::text_of( $child ) ) . ' '; } } } return $out; } /** * Name of an SVG from aria-label / title. * * @param \DOMElement $svg SVG. * @return string */ public static function svg_name( \DOMElement $svg ) { if ( '' !== trim( $svg->getAttribute( 'aria-label' ) ) ) { return trim( $svg->getAttribute( 'aria-label' ) ); } foreach ( $svg->childNodes as $child ) { if ( $child instanceof \DOMElement && 'title' === strtolower( $child->tagName ) ) { return trim( $child->textContent ); } } return ''; } /** * Simplified accessible name for links, buttons and form controls. * * @param \DOMElement $el Element. * @param \DOMXPath $xp XPath. * @return string */ public static function accessible_name( \DOMElement $el, \DOMXPath $xp ) { $by = trim( $el->getAttribute( 'aria-labelledby' ) ); if ( '' !== $by ) { $text = ''; foreach ( preg_split( '/\s+/', $by ) as $id ) { $target = self::by_id( $xp, $id ); if ( $target ) { $text .= ' ' . self::clean( $target->textContent ); } } if ( '' !== trim( $text ) ) { return trim( $text ); } } $label = trim( $el->getAttribute( 'aria-label' ) ); if ( '' !== $label ) { return $label; } $tag = strtolower( $el->tagName ); if ( 'input' === $tag ) { $type = strtolower( $el->getAttribute( 'type' ) ); if ( in_array( $type, array( 'submit', 'button', 'reset' ), true ) && '' !== trim( $el->getAttribute( 'value' ) ) ) { return trim( $el->getAttribute( 'value' ) ); } if ( 'submit' === $type || 'reset' === $type ) { return 'x'; } if ( 'image' === $type && '' !== trim( $el->getAttribute( 'alt' ) ) ) { return trim( $el->getAttribute( 'alt' ) ); } } else { $text = self::clean( self::text_of( $el ) ); if ( '' !== $text ) { return $text; } } $title = trim( $el->getAttribute( 'title' ) ); return $title; } /** * Find an element by id. * * @param \DOMXPath $xp XPath. * @param string $id Id. * @return \DOMElement|null */ public static function by_id( \DOMXPath $xp, $id ) { if ( '' === $id ) { return null; } $q = $xp->query( '//*[@id=' . self::xpath_literal( $id ) . ']' ); $node = $q && $q->length ? $q->item( 0 ) : null; return $node instanceof \DOMElement ? $node : null; } /** * Quote a string for XPath. * * @param string $s String. * @return string */ public static function xpath_literal( $s ) { if ( false === strpos( $s, "'" ) ) { return "'" . $s . "'"; } if ( false === strpos( $s, '"' ) ) { return '"' . $s . '"'; } return "concat('" . str_replace( "'", "',\"'\",'", $s ) . "')"; } /** * Is the element inside one of the ancestors that we should ignore? * * @param \DOMElement $el Element. * @return bool */ public static function in_ignored( \DOMElement $el ) { $node = $el; while ( $node instanceof \DOMElement ) { $tag = strtolower( $node->tagName ); if ( in_array( $tag, array( 'template', 'noscript', 'script', 'style' ), true ) ) { return true; } $id = $node->getAttribute( 'id' ); if ( 'wpadminbar' === $id || 'a11ymate-toolbar' === $id || 'a11ymate-root' === $id ) { return true; } $node = $node->parentNode; } return false; } /** * Is the element hidden from everyone (display:none via hidden attribute or inline style)? * * @param \DOMElement $el Element. * @return bool */ public static function is_hidden( \DOMElement $el ) { $node = $el; while ( $node instanceof \DOMElement ) { if ( $node->hasAttribute( 'hidden' ) ) { return true; } $style = strtolower( $node->getAttribute( 'style' ) ); if ( '' !== $style && preg_match( '/display\s*:\s*none|visibility\s*:\s*hidden/', $style ) ) { return true; } if ( 'input' === strtolower( $node->tagName ) && 'hidden' === strtolower( $node->getAttribute( 'type' ) ) ) { return true; } $node = $node->parentNode; } return false; } }