'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;
}
}