'href', 'img' => 'src', 'iframe' => 'src', 'script' => 'src', 'link' => 'href', ); /** * Elements that delimit the "surrounding text" used for context. */ private const BLOCK_TAGS = array( 'p', 'li', 'td', 'th', 'dd', 'dt', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'figure', 'figcaption', 'section', 'article', 'header', 'footer', 'nav', 'aside', 'body' ); /** * Context length in characters. */ public const CONTEXT_LENGTH = 120; /** * Refuse to parse absurdly large fragments (protects memory on shared hosting). */ private const MAX_HTML_BYTES = 4194304; /** * Extract links from an HTML fragment. * * @param string $html HTML fragment. * @param string $base Absolute base URL for relative links. * @param string $source_key Source key stored with each instance. * @param bool $include_images Whether to include . * @return array */ public static function extract( string $html, string $base, string $source_key = '', bool $include_images = true ): array { if ( '' === trim( $html ) || strlen( $html ) > self::MAX_HTML_BYTES ) { return array(); } if ( false === stripos( $html, 'href' ) && false === stripos( $html, 'src' ) ) { return array(); } $doc = self::load( $html ); if ( null === $doc ) { return array(); } $found = array(); foreach ( self::ELEMENTS as $tag => $attribute ) { if ( 'img' === $tag && ! $include_images ) { continue; } $nodes = $doc->getElementsByTagName( $tag ); foreach ( $nodes as $node ) { if ( ! $node instanceof \DOMElement || ! $node->hasAttribute( $attribute ) ) { continue; } $url = Url::normalize( $node->getAttribute( $attribute ), $base, false ); if ( null === $url ) { continue; } $anchor = self::anchor_text( $node ); $found[] = array( 'url' => $url, 'element' => $tag, 'anchor_text' => self::truncate( $anchor, 255 ), 'context' => self::context( $node, $anchor ), 'source_key' => $source_key, ); } } return $found; } /** * Load an HTML fragment into a DOMDocument as UTF-8, silencing libxml warnings. * * @param string $html HTML fragment. */ public static function load( string $html ): ?\DOMDocument { $previous = libxml_use_internal_errors( true ); $doc = new \DOMDocument(); $wrapped = '' . $html . ''; $ok = $doc->loadHTML( $wrapped, LIBXML_NONET ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); return $ok ? $doc : null; } /** * Visible text that represents a link. * * @param \DOMElement $node Element. */ private static function anchor_text( \DOMElement $node ): string { $tag = strtolower( $node->tagName ); if ( 'a' === $tag ) { $text = self::squash( $node->textContent ); if ( '' !== $text ) { return $text; } $images = $node->getElementsByTagName( 'img' ); if ( $images->length > 0 ) { $img = $images->item( 0 ); if ( $img instanceof \DOMElement && '' !== trim( $img->getAttribute( 'alt' ) ) ) { return self::squash( $img->getAttribute( 'alt' ) ); } } foreach ( array( 'aria-label', 'title' ) as $attr ) { if ( '' !== trim( $node->getAttribute( $attr ) ) ) { return self::squash( $node->getAttribute( $attr ) ); } } return ''; } if ( 'img' === $tag ) { return self::squash( $node->getAttribute( 'alt' ) ); } if ( 'iframe' === $tag ) { return self::squash( $node->getAttribute( 'title' ) ); } return ''; } /** * About 120 characters of text surrounding the link, taken from its nearest block ancestor. * * @param \DOMElement $node Element. * @param string $anchor Anchor text. */ private static function context( \DOMElement $node, string $anchor ): string { $block = $node->parentNode; while ( $block instanceof \DOMElement && ! in_array( strtolower( $block->tagName ), self::BLOCK_TAGS, true ) ) { $block = $block->parentNode; } $text = self::squash( $block instanceof \DOMNode ? (string) $block->textContent : '' ); if ( '' === $text ) { return ''; } $length = mb_strlen( $text ); if ( $length <= self::CONTEXT_LENGTH ) { return $text; } $start = 0; if ( '' !== $anchor ) { $byte_pos = strpos( $text, $anchor ); if ( false !== $byte_pos ) { $char_pos = mb_strlen( substr( $text, 0, $byte_pos ) ); $center = $char_pos + (int) floor( mb_strlen( $anchor ) / 2 ); $start = max( 0, min( $length - self::CONTEXT_LENGTH, $center - (int) ( self::CONTEXT_LENGTH / 2 ) ) ); } } $snippet = mb_substr( $text, $start, self::CONTEXT_LENGTH ); return ( $start > 0 ? '…' : '' ) . trim( $snippet ) . ( $start + self::CONTEXT_LENGTH < $length ? '…' : '' ); } /** * Collapse whitespace. * * @param string $text Text. */ public static function squash( string $text ): string { return trim( (string) preg_replace( '/\s+/u', ' ', $text ) ); } /** * Truncate to a character length. * * @param string $text Text. * @param int $length Max characters. */ public static function truncate( string $text, int $length ): string { return mb_strlen( $text ) > $length ? mb_substr( $text, 0, $length ) : $text; } }