v1.0.0
This commit is contained in:
parent
ac2c7a8014
commit
f7702f2872
25 changed files with 6453 additions and 0 deletions
583
includes/class-robotstxt-og-image-resolver.php
Normal file
583
includes/class-robotstxt-og-image-resolver.php
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
<?php
|
||||
/**
|
||||
* Image Resolver Class
|
||||
*
|
||||
* Handles detection and resolution of compatible fallback images for Open Graph tags.
|
||||
*
|
||||
* @package ROBOTSTXT_OG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_OG_Image_Resolver
|
||||
*
|
||||
* Resolves compatible image formats for social media crawlers.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Robotstxt_OG_Image_Resolver {
|
||||
|
||||
/**
|
||||
* Transient prefix for negative cache entries.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
const NEGATIVE_CACHE_PREFIX = 'robotstxt_og_miss_';
|
||||
|
||||
/**
|
||||
* How long to cache negative results (URL not found), in seconds.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var int
|
||||
*/
|
||||
const NEGATIVE_CACHE_TTL = HOUR_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* Get fallback image URL for a post.
|
||||
*
|
||||
* Checks cache first, then resolves if needed.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return string Image URL or empty string if not found.
|
||||
*/
|
||||
public function get_fallback_image( int $post_id ): string {
|
||||
// Check postmeta cache first.
|
||||
$cached_url = get_post_meta( $post_id, '_og_image_fallback_url', true );
|
||||
|
||||
if ( ! empty( $cached_url ) && $this->is_valid_url( $cached_url ) ) {
|
||||
$this->log(
|
||||
'cache_hit',
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'url' => $cached_url,
|
||||
)
|
||||
);
|
||||
return esc_url_raw( $cached_url );
|
||||
}
|
||||
|
||||
$this->log( 'cache_miss', array( 'post_id' => $post_id ) );
|
||||
|
||||
// No valid cache, resolve image.
|
||||
return $this->resolve_image( $post_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve fallback image for a post.
|
||||
*
|
||||
* Detects featured image format and finds compatible alternative if needed.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return string Resolved image URL or empty string.
|
||||
*/
|
||||
public function resolve_image( int $post_id ): string {
|
||||
// Get featured image ID.
|
||||
$image_id = get_post_thumbnail_id( $post_id );
|
||||
|
||||
if ( empty( $image_id ) ) {
|
||||
// No featured image, check for global fallback.
|
||||
$this->log( 'no_featured_image', array( 'post_id' => $post_id ) );
|
||||
return $this->get_global_fallback();
|
||||
}
|
||||
|
||||
// Get image URL.
|
||||
$image_url = wp_get_attachment_url( $image_id );
|
||||
|
||||
if ( empty( $image_url ) ) {
|
||||
$this->log(
|
||||
'no_attachment_url',
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'image_id' => $image_id,
|
||||
)
|
||||
);
|
||||
return $this->get_global_fallback();
|
||||
}
|
||||
|
||||
// Detect file extension.
|
||||
$path_info = pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) );
|
||||
$extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : '';
|
||||
|
||||
// If already a compatible format, save and return.
|
||||
if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) {
|
||||
$this->log(
|
||||
'compatible_format',
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'url' => $image_url,
|
||||
)
|
||||
);
|
||||
$this->save_cache( $post_id, $image_url );
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
// Modern format detected (webp, avif, etc.), find alternative.
|
||||
if ( in_array( $extension, array( 'webp', 'avif', 'gif', 'bmp', 'svg', 'tiff', 'tif' ), true ) ) {
|
||||
$this->log(
|
||||
'modern_format_detected',
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'extension' => $extension,
|
||||
'url' => $image_url,
|
||||
)
|
||||
);
|
||||
|
||||
$alternative = $this->find_compatible_alternative( $image_url );
|
||||
|
||||
if ( ! empty( $alternative ) ) {
|
||||
$this->log(
|
||||
'alternative_found',
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'original' => $image_url,
|
||||
'alternative' => $alternative,
|
||||
)
|
||||
);
|
||||
$this->save_cache( $post_id, $alternative );
|
||||
return esc_url_raw( $alternative );
|
||||
}
|
||||
|
||||
$this->log(
|
||||
'no_alternative_found',
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'url' => $image_url,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// No alternative found, return original (don't cache to allow retry on next request).
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Find compatible image alternative by checking .jpg and .png versions.
|
||||
*
|
||||
* Uses HTTP HEAD requests to verify file existence.
|
||||
* Respects the `robotstxt_og_external_image_enabled` filter for external images.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $image_url Original image URL.
|
||||
* @return string Compatible image URL or empty string if not found.
|
||||
*/
|
||||
public function find_compatible_alternative( string $image_url ): string {
|
||||
// Check if external images are allowed.
|
||||
if ( $this->is_external_url( $image_url ) ) {
|
||||
/**
|
||||
* Filter whether to attempt resolution for external images.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param bool $enabled Whether external image resolution is enabled.
|
||||
* @param string $image_url The external image URL being resolved.
|
||||
*/
|
||||
$enabled = apply_filters( 'robotstxt_og_external_image_enabled', true, $image_url );
|
||||
|
||||
if ( ! $enabled ) {
|
||||
$this->log( 'external_image_skipped', array( 'url' => $image_url ) );
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Strip query string for URL manipulation.
|
||||
$clean_url = strtok( $image_url, '?' );
|
||||
|
||||
// Parse URL and get base path without extension.
|
||||
$path_info = pathinfo( wp_parse_url( $clean_url, PHP_URL_PATH ) );
|
||||
$filename = $path_info['filename'] ?? '';
|
||||
$dir = $path_info['dirname'] ?? '';
|
||||
|
||||
if ( empty( $filename ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Reconstruct base URL.
|
||||
$parsed = wp_parse_url( $clean_url );
|
||||
$base_url = ( $parsed['scheme'] ?? 'https' ) . '://' . ( $parsed['host'] ?? '' );
|
||||
|
||||
if ( ! empty( $parsed['port'] ) ) {
|
||||
$base_url .= ':' . $parsed['port'];
|
||||
}
|
||||
|
||||
$base_url .= trailingslashit( $dir ) . $filename;
|
||||
|
||||
// Try .jpg first, then .png.
|
||||
$alternatives = array( '.jpg', '.png', '.jpeg' );
|
||||
|
||||
foreach ( $alternatives as $ext ) {
|
||||
$test_url = $base_url . $ext;
|
||||
|
||||
if ( $this->url_exists( $test_url ) ) {
|
||||
return $test_url;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL exists using HTTP HEAD request.
|
||||
*
|
||||
* Uses negative caching to avoid repeated requests for non-existent URLs.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $url URL to check.
|
||||
* @return bool True if URL returns 200-299 status code.
|
||||
*/
|
||||
private function url_exists( string $url ): bool {
|
||||
// Check negative cache to avoid repeated failed requests.
|
||||
$cache_key = self::NEGATIVE_CACHE_PREFIX . md5( $url );
|
||||
|
||||
if ( false !== get_transient( $cache_key ) ) {
|
||||
$this->log( 'negative_cache_hit', array( 'url' => $url ) );
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the timeout in seconds for external image HEAD requests.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $timeout The timeout in seconds.
|
||||
* @param string $url The URL being checked.
|
||||
*/
|
||||
$timeout = (int) apply_filters( 'robotstxt_og_external_image_timeout', 5, $url );
|
||||
|
||||
// Use WordPress HTTP API.
|
||||
$response = wp_remote_head(
|
||||
$url,
|
||||
array(
|
||||
'timeout' => max( 1, $timeout ),
|
||||
'redirection' => 5,
|
||||
'user-agent' => 'WordPress/' . get_bloginfo( 'version' ) . '; ' . get_bloginfo( 'url' ),
|
||||
)
|
||||
);
|
||||
|
||||
// Handle WP_Error (network failure, timeout, etc.).
|
||||
if ( is_wp_error( $response ) ) {
|
||||
$this->log(
|
||||
'head_request_error',
|
||||
array(
|
||||
'url' => $url,
|
||||
'error' => $response->get_error_message(),
|
||||
)
|
||||
);
|
||||
set_transient( $cache_key, '1', self::NEGATIVE_CACHE_TTL );
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check response code.
|
||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
$exists = $code >= 200 && $code < 300;
|
||||
|
||||
if ( ! $exists ) {
|
||||
$this->log(
|
||||
'head_request_failed',
|
||||
array(
|
||||
'url' => $url,
|
||||
'code' => $code,
|
||||
)
|
||||
);
|
||||
set_transient( $cache_key, '1', self::NEGATIVE_CACHE_TTL );
|
||||
} else {
|
||||
$this->log(
|
||||
'head_request_success',
|
||||
array(
|
||||
'url' => $url,
|
||||
'code' => $code,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is external (hosted on a different domain).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $url URL to check.
|
||||
* @return bool True if the URL is external.
|
||||
*/
|
||||
private function is_external_url( string $url ): bool {
|
||||
$site_host = wp_parse_url( get_bloginfo( 'url' ), PHP_URL_HOST );
|
||||
$image_host = wp_parse_url( $url, PHP_URL_HOST );
|
||||
|
||||
if ( empty( $site_host ) || empty( $image_host ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtolower( $site_host ) !== strtolower( $image_host );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global fallback image URL from settings.
|
||||
*
|
||||
* If the stored image is in an incompatible format (AVIF, WebP, etc.),
|
||||
* attempts to resolve a JPEG/PNG alternative. Returns empty string if
|
||||
* no compatible image is found.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return string Compatible image URL or empty string.
|
||||
*/
|
||||
private function get_global_fallback(): string {
|
||||
$fallback_url = (string) get_option( 'robotstxt_og_fallback_image', '' );
|
||||
|
||||
if ( empty( $fallback_url ) || ! $this->is_valid_url( $fallback_url ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return esc_url_raw( $fallback_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured homepage image URL.
|
||||
*
|
||||
* If the stored image is in an incompatible format (AVIF, WebP, etc.),
|
||||
* attempts to resolve a JPEG/PNG alternative before falling back to
|
||||
* the global fallback image.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*
|
||||
* @return string Compatible image URL or empty string.
|
||||
*/
|
||||
public function get_homepage_image(): string {
|
||||
$image_url = (string) get_option( 'robotstxt_og_homepage_image', '' );
|
||||
|
||||
if ( ! empty( $image_url ) && $this->is_valid_url( $image_url ) ) {
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
// Fall back to global fallback image.
|
||||
return $this->get_global_fallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an image URL is in a format compatible with social media crawlers.
|
||||
*
|
||||
* Returns the URL unchanged for JPEG/PNG. For incompatible formats
|
||||
* (AVIF, WebP, GIF, SVG, BMP, TIFF), attempts to find a JPEG/PNG
|
||||
* alternative via find_compatible_alternative(). Returns empty string
|
||||
* if no compatible version can be found.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param string $image_url Image URL to check.
|
||||
* @return string Compatible image URL or empty string.
|
||||
*/
|
||||
public function ensure_compatible_format( string $image_url ): string {
|
||||
$path_info = pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) );
|
||||
$extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : '';
|
||||
|
||||
// Already a compatible format.
|
||||
if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) {
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
// Incompatible modern format — try to find a JPEG/PNG alternative.
|
||||
if ( in_array( $extension, array( 'webp', 'avif', 'gif', 'bmp', 'svg', 'tiff', 'tif' ), true ) ) {
|
||||
$this->log(
|
||||
'fallback_image_incompatible_format',
|
||||
array(
|
||||
'url' => $image_url,
|
||||
'extension' => $extension,
|
||||
)
|
||||
);
|
||||
|
||||
$alternative = $this->find_compatible_alternative( $image_url );
|
||||
|
||||
if ( ! empty( $alternative ) ) {
|
||||
$this->log(
|
||||
'fallback_image_alternative_found',
|
||||
array(
|
||||
'original' => $image_url,
|
||||
'alternative' => $alternative,
|
||||
)
|
||||
);
|
||||
return esc_url_raw( $alternative );
|
||||
}
|
||||
|
||||
$this->log( 'fallback_image_no_alternative', array( 'url' => $image_url ) );
|
||||
|
||||
// No compatible alternative found — discard this image.
|
||||
return '';
|
||||
}
|
||||
|
||||
// Unknown extension — return as-is and let the caller decide.
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save resolved URL to postmeta cache.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $url Resolved image URL.
|
||||
* @return void
|
||||
*/
|
||||
private function save_cache( int $post_id, string $url ): void {
|
||||
update_post_meta( $post_id, '_og_image_fallback_url', esc_url_raw( $url ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached fallback URL for a post.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return void
|
||||
*/
|
||||
public function clear_cache( int $post_id ): void {
|
||||
delete_post_meta( $post_id, '_og_image_fallback_url' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback image URL for a taxonomy term.
|
||||
*
|
||||
* Checks term meta cache first, then looks for a term image set by
|
||||
* supported plugins (e.g., Yoast SEO term images, custom term meta).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $term_id Term ID.
|
||||
* @return string Image URL or empty string if not found.
|
||||
*/
|
||||
public function get_taxonomy_fallback_image( int $term_id ): string {
|
||||
// Check term meta cache first.
|
||||
$cached_url = get_term_meta( $term_id, '_og_image_fallback_url', true );
|
||||
|
||||
if ( ! empty( $cached_url ) && $this->is_valid_url( $cached_url ) ) {
|
||||
$this->log(
|
||||
'taxonomy_cache_hit',
|
||||
array(
|
||||
'term_id' => $term_id,
|
||||
'url' => $cached_url,
|
||||
)
|
||||
);
|
||||
return esc_url_raw( $cached_url );
|
||||
}
|
||||
|
||||
$this->log( 'taxonomy_cache_miss', array( 'term_id' => $term_id ) );
|
||||
|
||||
return $this->resolve_taxonomy_image( $term_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve fallback image for a taxonomy term.
|
||||
*
|
||||
* Checks for term images set by Yoast SEO or other plugins via a filter.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $term_id Term ID.
|
||||
* @return string Resolved image URL or empty string.
|
||||
*/
|
||||
public function resolve_taxonomy_image( int $term_id ): string {
|
||||
/**
|
||||
* Filter the image URL for a taxonomy term.
|
||||
*
|
||||
* Use this filter to integrate with plugins that assign images to
|
||||
* taxonomy terms (e.g., custom term meta, theme functions).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $image_url The image URL. Empty string if none.
|
||||
* @param int $term_id The term ID being resolved.
|
||||
*/
|
||||
$image_url = (string) apply_filters( 'robotstxt_og_taxonomy_image', '', $term_id );
|
||||
|
||||
if ( empty( $image_url ) || ! $this->is_valid_url( $image_url ) ) {
|
||||
return $this->get_global_fallback();
|
||||
}
|
||||
|
||||
// Detect file extension and resolve if needed.
|
||||
$path_info = pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) );
|
||||
$extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : '';
|
||||
|
||||
if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) {
|
||||
update_term_meta( $term_id, '_og_image_fallback_url', esc_url_raw( $image_url ) );
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
if ( in_array( $extension, array( 'webp', 'avif', 'gif', 'bmp', 'svg', 'tiff', 'tif' ), true ) ) {
|
||||
$alternative = $this->find_compatible_alternative( $image_url );
|
||||
|
||||
if ( ! empty( $alternative ) ) {
|
||||
update_term_meta( $term_id, '_og_image_fallback_url', esc_url_raw( $alternative ) );
|
||||
return esc_url_raw( $alternative );
|
||||
}
|
||||
}
|
||||
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached fallback URL for a taxonomy term.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $term_id Term ID.
|
||||
* @return void
|
||||
*/
|
||||
public function clear_taxonomy_cache( int $term_id ): void {
|
||||
delete_term_meta( $term_id, '_og_image_fallback_url' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string is a valid URL.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $url URL to validate.
|
||||
* @return bool True if valid URL.
|
||||
*/
|
||||
private function is_valid_url( string $url ): bool {
|
||||
return false !== filter_var( $url, FILTER_VALIDATE_URL );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a resolution event if logging is enabled.
|
||||
*
|
||||
* Uses the `robotstxt_og_enable_logging` filter to toggle logging.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $event Event name (e.g., 'cache_hit', 'head_request_error').
|
||||
* @param array $context Additional context data.
|
||||
* @return void
|
||||
*/
|
||||
private function log( string $event, array $context = array() ): void {
|
||||
/**
|
||||
* Filter whether to enable debug logging for image resolution.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param bool $enabled Whether logging is enabled. Default false.
|
||||
*/
|
||||
if ( ! apply_filters( 'robotstxt_og_enable_logging', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'[robotstxt-og] %s | %s',
|
||||
$event,
|
||||
wp_json_encode( $context )
|
||||
);
|
||||
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log( $message );
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue