robotstxt-mediaaudit/includes/External/AbstractProvider.php
2026-06-03 06:30:49 +00:00

131 lines
3.7 KiB
PHP

<?php
/**
* Base class for all external reverse-image-search providers.
*
* @package MediaRightsAudit\External
*/
namespace MediaRightsAudit\External;
/**
* Provides rate-limited scanning via a transient-based per-minute counter.
*
* Subclasses implement do_scan() with the provider-specific HTTP call.
* The public scan() wrapper enforces the rate limit before delegating.
*/
abstract class AbstractProvider {
/**
* Machine-readable provider identifier stored in mra_external_results.provider.
*
* @return string
*/
abstract public function provider_slug(): string;
/**
* Human-readable provider display name.
*
* @return string
*/
abstract public function provider_name(): string;
/**
* Maximum API requests allowed per minute for this provider.
*
* Subclasses may override to read a per-provider setting.
*
* @return positive-int
*/
public function rate_limit(): int {
$raw = get_option( 'robotstxt_mediaaudit_settings', array() );
$opts = is_array( $raw ) ? $raw : array();
$val = $opts['rate_limit_per_minute'] ?? null;
return is_numeric( $val ) ? max( 1, (int) $val ) : 10;
}
/**
* Performs the provider-specific scan and returns a result.
*
* @param int $attachment_id WordPress attachment ID.
* @param string $file_url Public URL of the image to analyse.
*
* @return ScanResult
*
* @throws \RuntimeException On HTTP or parse errors.
*/
abstract protected function do_scan( int $attachment_id, string $file_url ): ScanResult;
/**
* Scans an image URL after checking the per-minute rate limit.
*
* @param int $attachment_id WordPress attachment ID.
* @param string $file_url Public URL of the image to analyse.
*
* @return ScanResult
*
* @throws \RuntimeException When rate limit is exceeded or the scan fails.
*/
final public function scan( int $attachment_id, string $file_url ): ScanResult {
$this->enforce_rate_limit();
return $this->do_scan( $attachment_id, $file_url );
}
/**
* Builds a domain → count map from a list of URL-bearing items.
*
* Each item must be an array with a string 'url' key. Items missing the key
* or whose host cannot be parsed are silently skipped.
*
* @param array<mixed> $items URL-bearing items (e.g. pages or backlink objects).
*
* @return array<string, int> Domain → occurrence count, sorted descending, max 10.
*/
protected function extract_top_domains( array $items ): array {
$counts = array();
foreach ( $items as $item ) {
if ( ! is_array( $item ) ) {
continue;
}
$url_val = $item['url'] ?? null;
if ( ! is_string( $url_val ) || '' === $url_val ) {
continue;
}
$host = wp_parse_url( $url_val, PHP_URL_HOST );
if ( ! is_string( $host ) || '' === $host ) {
continue;
}
$counts[ $host ] = ( $counts[ $host ] ?? 0 ) + 1;
}
arsort( $counts );
return array_slice( $counts, 0, 50, true );
}
/**
* Checks and increments the per-minute request counter via transients.
*
* Throws if the counter has reached the configured limit.
*
* @throws \RuntimeException When the rate limit for the current minute is exhausted.
*/
private function enforce_rate_limit(): void {
$key = 'mra_rl_' . $this->provider_slug() . '_' . gmdate( 'YmdHi' );
$raw = get_transient( $key );
$current = is_numeric( $raw ) ? (int) $raw : 0;
if ( $current >= $this->rate_limit() ) {
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw new \RuntimeException(
sprintf(
'Rate limit of %d req/min exceeded for provider "%s".',
$this->rate_limit(),
$this->provider_slug()
)
);
// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
set_transient( $key, $current + 1, 90 );
}
}