string, 'anchor' => string, 'type' => 'link'|'image' ).
*/
public static function extract( $html, $base_url = '' ) {
$found = array();
if ( ! is_string( $html ) || '' === trim( $html ) ) {
return $found;
}
// Hyperlinks: capture href and inner content.
if ( preg_match_all( '#]*?href\s*=\s*(["\'])(.*?)\1[^>]*>(.*?)#is', $html, $matches, PREG_SET_ORDER ) ) {
foreach ( $matches as $match ) {
$url = self::normalize( $match[2], $base_url );
if ( null === $url ) {
continue;
}
$anchor = trim( preg_replace( '/\s+/', ' ', html_entity_decode( wp_strip_all_tags( $match[3] ), ENT_QUOTES, 'UTF-8' ) ) );
if ( '' === $anchor ) {
$anchor = false !== stripos( $match[3], '
$url,
'anchor' => self::truncate( $anchor ),
'type' => 'link',
);
}
}
// Images: capture src.
if ( preg_match_all( '#
]*?src\s*=\s*(["\'])(.*?)\1#is', $html, $matches, PREG_SET_ORDER ) ) {
foreach ( $matches as $match ) {
$url = self::normalize( $match[2], $base_url );
if ( null === $url ) {
continue;
}
$found[] = array(
'url' => $url,
'anchor' => '[image]',
'type' => 'image',
);
}
}
// De-duplicate on url + type, keeping the first anchor seen.
$unique = array();
foreach ( $found as $item ) {
$key = $item['type'] . '|' . $item['url'];
if ( ! isset( $unique[ $key ] ) ) {
$unique[ $key ] = $item;
}
}
return array_values( $unique );
}
/**
* Decodes, resolves and validates a raw attribute URL.
*
* @param string $raw Raw href/src attribute value.
* @param string $base_url Base URL for relative resolution.
* @return string|null Absolute http(s) URL, or null to skip.
*/
public static function normalize( $raw, $base_url = '' ) {
$url = trim( html_entity_decode( $raw, ENT_QUOTES, 'UTF-8' ) );
if ( '' === $url || '#' === $url[0] ) {
return null;
}
$scheme = '';
if ( preg_match( '#^([a-z][a-z0-9+.\-]*):#i', $url, $m ) ) {
$scheme = strtolower( $m[1] );
}
if ( $scheme && in_array( $scheme, self::$skipped_schemes, true ) ) {
return null;
}
if ( $scheme && 'http' !== $scheme && 'https' !== $scheme ) {
return null;
}
// Protocol-relative URL.
if ( 0 === strpos( $url, '//' ) ) {
$base_scheme = 'https';
if ( $base_url && preg_match( '#^(https?)://#i', $base_url, $bm ) ) {
$base_scheme = strtolower( $bm[1] );
}
$url = $base_scheme . ':' . $url;
} elseif ( ! $scheme ) {
// Relative URL: resolve against base.
if ( ! $base_url ) {
return null;
}
$url = self::resolve( $url, $base_url );
if ( null === $url ) {
return null;
}
}
// Drop the fragment; it is irrelevant to HTTP status.
$hash_pos = strpos( $url, '#' );
if ( false !== $hash_pos ) {
$url = substr( $url, 0, $hash_pos );
}
if ( '' === $url || strlen( $url ) > 2000 ) {
return null;
}
if ( ! preg_match( '#^https?://[^/]+#i', $url ) ) {
return null;
}
return $url;
}
/**
* Resolves a relative URL against an absolute base URL (RFC 3986, simplified).
*
* @param string $relative Relative path/query.
* @param string $base Absolute base URL.
* @return string|null
*/
public static function resolve( $relative, $base ) {
$parts = wp_parse_url( $base );
if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
return null;
}
$origin = $parts['scheme'] . '://' . $parts['host'];
if ( ! empty( $parts['port'] ) ) {
$origin .= ':' . $parts['port'];
}
if ( '' === $relative ) {
return null;
}
if ( '/' === $relative[0] ) {
return $origin . self::remove_dot_segments( $relative );
}
if ( '?' === $relative[0] ) {
$path = isset( $parts['path'] ) ? $parts['path'] : '/';
return $origin . $path . $relative;
}
$base_path = isset( $parts['path'] ) ? $parts['path'] : '/';
$directory = substr( $base_path, 0, (int) strrpos( $base_path, '/' ) + 1 );
if ( '' === $directory ) {
$directory = '/';
}
return $origin . self::remove_dot_segments( $directory . $relative );
}
/**
* Removes "./" and "../" segments from a path.
*
* @param string $path URL path.
* @return string
*/
private static function remove_dot_segments( $path ) {
$query = '';
$q_pos = strpos( $path, '?' );
if ( false !== $q_pos ) {
$query = substr( $path, $q_pos );
$path = substr( $path, 0, $q_pos );
}
$output = array();
foreach ( explode( '/', $path ) as $segment ) {
if ( '.' === $segment ) {
continue;
}
if ( '..' === $segment ) {
array_pop( $output );
continue;
}
$output[] = $segment;
}
$result = implode( '/', $output );
if ( '' === $result || '/' !== $result[0] ) {
$result = '/' . $result;
}
return $result . $query;
}
/**
* Whether a URL matches any exclusion pattern (simple substring match).
*
* @param string $url URL to test.
* @param string[] $patterns Exclusion patterns.
* @return bool
*/
public static function is_excluded( $url, $patterns ) {
if ( empty( $patterns ) || ! is_array( $patterns ) ) {
return false;
}
foreach ( $patterns as $pattern ) {
$pattern = trim( (string) $pattern );
if ( '' !== $pattern && false !== stripos( $url, $pattern ) ) {
return true;
}
}
return false;
}
/**
* Truncates anchor text for storage.
*
* @param string $text Anchor text.
* @return string
*/
private static function truncate( $text ) {
if ( function_exists( 'mb_substr' ) ) {
return mb_substr( $text, 0, 200 );
}
return substr( $text, 0, 200 );
}
}