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