This commit is contained in:
Javier Casares 2026-06-03 06:24:03 +00:00
commit 6708bbb67f
43 changed files with 8342 additions and 0 deletions

128
includes/External/AbstractProvider.php vendored Normal file
View file

@ -0,0 +1,128 @@
<?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 {
return 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, 10, 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 );
}
}

334
includes/External/ExternalScanner.php vendored Normal file
View file

@ -0,0 +1,334 @@
<?php
/**
* External scan batch processor.
*
* @package MediaRightsAudit\External
*/
namespace MediaRightsAudit\External;
use MediaRightsAudit\Core\Queue\Scheduler;
/**
* Processes the external scan queue: runs each pending attachment through
* all active providers and stores results in mra_external_results.
*
* Active providers are resolved at runtime via the mra/external/providers filter.
* Scanning is triggered manually by an administrator (Run External Scan bulk action
* or wp mra scan-external CLI) never automatically, to control API costs.
*
* Extension hook: mra/external/providers filter to register additional providers.
*/
class ExternalScanner {
/**
* Action Scheduler hook name for background external scanning.
*/
const AS_HOOK = 'mra_external_scan_batch';
/**
* Scans one batch of pending attachments with all active providers.
*
* @param int $batch_size Maximum number of attachments to process.
*
* @return int Number of attachments attempted.
*/
public static function scan_batch( int $batch_size = 10 ): int {
global $wpdb;
$providers = self::get_active_providers();
if ( empty( $providers ) ) {
return 0;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT attachment_id, file_url
FROM {$wpdb->prefix}mra_media_index
WHERE external_status = 'queued'
ORDER BY attachment_id ASC
LIMIT %d",
$batch_size
),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! $rows ) {
return 0;
}
foreach ( $rows as $row ) {
$aid_val = $row['attachment_id'] ?? null;
$url_val = $row['file_url'] ?? null;
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
$url = is_string( $url_val ) ? $url_val : '';
if ( $aid <= 0 || '' === $url ) {
continue;
}
self::process_attachment( $aid, $url, $providers );
}
return count( $rows );
}
/**
* Returns the count of attachments still waiting for external scanning.
*
* @return int
*/
public static function get_pending_count(): int {
global $wpdb;
$result = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
"SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE external_status = 'queued'"
);
return is_numeric( $result ) ? (int) $result : 0;
}
/**
* Processes one scheduled batch and re-queues if more remain.
*
* Called by Action Scheduler via the mra_external_scan_batch hook.
*
* @return void
*/
public static function process_scheduled_batch(): void {
self::scan_batch();
if ( self::get_pending_count() > 0 ) {
Scheduler::schedule_single( self::AS_HOOK );
}
}
/**
* Promotes all attachments with external_status = 'pending' to 'queued'.
*
* Does not schedule an Action Scheduler action. Useful in CLI context where
* processing runs synchronously in a loop rather than via background jobs.
*
* @return void
*/
public static function queue_all_pending(): void {
global $wpdb;
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
"UPDATE {$wpdb->prefix}mra_media_index
SET external_status = 'queued'
WHERE external_status = 'pending'"
);
}
/**
* Marks all pending attachments as queued and schedules a background scan batch.
*
* Moves every attachment with external_status = 'pending' to 'queued' so the
* status change is visible immediately in the admin list. Only schedules the
* Action Scheduler action if there are now queued items and no batch is already running.
*
* @return void
*/
public static function schedule(): void {
global $wpdb;
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
"UPDATE {$wpdb->prefix}mra_media_index
SET external_status = 'queued'
WHERE external_status = 'pending'"
);
if ( self::get_pending_count() > 0 && ! Scheduler::has_pending( self::AS_HOOK ) ) {
Scheduler::schedule_single( self::AS_HOOK );
}
}
/**
* Marks specific attachments as pending and schedules a scan batch.
*
* Idempotent: already-pending attachments are left unchanged;
* previously-scanned or error attachments are reset to pending.
*
* @param array<int, int> $attachment_ids Attachment IDs to queue. Must be non-empty.
*
* @return void
*/
public static function queue_attachments( array $attachment_ids ): void {
if ( empty( $attachment_ids ) ) {
return;
}
global $wpdb;
$ids = array_map( 'intval', $attachment_ids );
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->prefix}mra_media_index
SET external_status = 'queued', external_scanned_at = NULL
WHERE attachment_id IN ({$placeholders})
AND external_status != 'queued'",
...$ids
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
// Schedule directly — do not call schedule() which would also mark all 'pending' items.
if ( ! Scheduler::has_pending( self::AS_HOOK ) ) {
Scheduler::schedule_single( self::AS_HOOK );
}
}
/**
* Deletes external scan results and resets status to pending for given IDs.
*
* @param array<int, int> $attachment_ids Attachment IDs to purge.
*
* @return void
*/
public static function purge( array $attachment_ids ): void {
if ( empty( $attachment_ids ) ) {
return;
}
global $wpdb;
$ids = array_map( 'intval', $attachment_ids );
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->prefix}mra_external_results
WHERE attachment_id IN ({$placeholders})",
...$ids
)
);
$wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->prefix}mra_media_index
SET external_status = 'pending', external_scanned_at = NULL
WHERE attachment_id IN ({$placeholders})",
...$ids
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Scans a single attachment with all providers and updates the index.
*
* @param int $attachment_id WordPress attachment ID.
* @param string $file_url Public URL of the image.
* @param array<AbstractProvider> $providers Resolved provider list.
*
* @return void
*/
private static function process_attachment( int $attachment_id, string $file_url, array $providers ): void {
global $wpdb;
$any_success = false;
$any_match = false;
foreach ( $providers as $provider ) {
try {
$result = $provider->scan( $attachment_id, $file_url );
self::save_result( $attachment_id, $provider->provider_slug(), $result );
$any_success = true;
if ( $result->match_count() > 0 ) {
$any_match = true;
}
} catch ( \RuntimeException $e ) {
if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log(
sprintf(
'[MRA] %s scan failed for attachment %d: %s',
$provider->provider_slug(),
$attachment_id,
$e->getMessage()
)
);
}
}
}
if ( ! $any_success ) {
$new_status = 'error';
} elseif ( $any_match ) {
$new_status = 'matches';
} else {
$new_status = 'scanned';
}
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prefix . 'mra_media_index',
array(
'external_status' => $new_status,
'external_scanned_at' => current_time( 'mysql', true ),
),
array( 'attachment_id' => $attachment_id ),
array( '%s', '%s' ),
array( '%d' )
);
}
/**
* Persists a provider's scan result (upsert via REPLACE INTO).
*
* @param int $attachment_id WordPress attachment ID.
* @param string $provider_slug Provider machine-readable slug.
* @param ScanResult $result Scan result to store.
*
* @return void
*/
private static function save_result( int $attachment_id, string $provider_slug, ScanResult $result ): void {
global $wpdb;
$raw_json = wp_json_encode( $result->raw_response() );
$domains_json = wp_json_encode( $result->top_domains() );
$wpdb->replace( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prefix . 'mra_external_results',
array(
'attachment_id' => $attachment_id,
'provider' => $provider_slug,
'raw_response' => ( false !== $raw_json ) ? $raw_json : '{}',
'match_count' => $result->match_count(),
'top_domains' => ( false !== $domains_json ) ? $domains_json : '{}',
'created_at' => current_time( 'mysql', true ),
),
array( '%d', '%s', '%s', '%d', '%s', '%s' )
);
}
/**
* Resolves active providers from the mra/external/providers filter.
*
* @return array<AbstractProvider>
*/
private static function get_active_providers(): array {
$raw = apply_filters( 'mra/external/providers', array() ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
$providers = array();
if ( is_array( $raw ) ) {
foreach ( $raw as $p ) {
if ( $p instanceof AbstractProvider ) {
$providers[] = $p;
}
}
}
return $providers;
}
}

View file

@ -0,0 +1,177 @@
<?php
/**
* Google Cloud Vision Web Detection provider.
*
* @package MediaRightsAudit\External
*/
namespace MediaRightsAudit\External;
use MediaRightsAudit\Admin\Settings;
/**
* Sends images to the Google Cloud Vision API (WEB_DETECTION feature) and
* normalises the response into a ScanResult.
*
* Reads the API key from the plugin settings (google_vision_api_key).
* Supports public-URL image submissions; for private/staging sites the
* file_url may not be publicly accessible a warning is surfaced via
* the scan error message in that case.
*/
class GoogleVisionProvider extends AbstractProvider {
/**
* Google Vision API endpoint (without key).
*/
const ENDPOINT = 'https://vision.googleapis.com/v1/images:annotate';
/**
* Returns the machine-readable provider identifier.
*
* @return string
*/
public function provider_slug(): string {
return 'google_vision';
}
/**
* Returns the human-readable provider display name.
*
* @return string
*/
public function provider_name(): string {
return __( 'Google Cloud Vision', 'robotstxt-mediaaudit' );
}
/**
* Sends the image to Google Cloud Vision Web Detection and returns a ScanResult.
*
* @param int $attachment_id WordPress attachment ID.
* @param string $file_url Public URL of the image.
*
* @return ScanResult
*
* @throws \RuntimeException On configuration, HTTP, or parse errors.
*/
protected function do_scan( int $attachment_id, string $file_url ): ScanResult {
$api_key = $this->get_api_key();
if ( '' === $api_key ) {
throw new \RuntimeException( 'Google Vision API key is not configured.' );
}
$body = wp_json_encode(
array(
'requests' => array(
array(
'image' => array( 'source' => array( 'imageUri' => $file_url ) ),
'features' => array(
array(
'type' => 'WEB_DETECTION',
'maxResults' => 100,
),
),
),
),
)
);
if ( false === $body ) {
throw new \RuntimeException( 'Failed to encode Google Vision request body.' );
}
$response = wp_remote_post(
self::ENDPOINT,
array(
'headers' => array(
'Content-Type' => 'application/json; charset=utf-8',
'X-Goog-Api-Key' => $api_key,
),
'body' => $body,
'timeout' => 30,
)
);
if ( is_wp_error( $response ) ) {
throw new \RuntimeException( $response->get_error_message() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
$http_code = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $http_code ) {
throw new \RuntimeException(
sprintf( 'Google Vision API returned HTTP %d.', $http_code ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
);
}
$raw_body = wp_remote_retrieve_body( $response );
$decoded = json_decode( $raw_body, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException( 'Failed to parse Google Vision API response.' );
}
return $this->parse_response( $decoded );
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Reads the Google Vision API key from plugin settings.
*
* @return string Empty string if not configured.
*/
private function get_api_key(): string {
$raw = get_option( Settings::OPTION_NAME, array() );
$opts = is_array( $raw ) ? $raw : array();
$val = $opts['google_vision_api_key'] ?? null;
return is_string( $val ) ? trim( $val ) : '';
}
/**
* Parses the decoded Vision API response into a ScanResult.
*
* @param array<mixed, mixed> $decoded json_decode()'d API response.
*
* @return ScanResult
*/
private function parse_response( array $decoded ): ScanResult {
$web_detection = $this->extract_web_detection( $decoded );
$pages_raw = $web_detection['pagesWithMatchingImages'] ?? null;
$pages = is_array( $pages_raw ) ? $pages_raw : array();
$full_raw = $web_detection['fullMatchingImages'] ?? null;
$full = is_array( $full_raw ) ? $full_raw : array();
$partial_raw = $web_detection['partialMatchingImages'] ?? null;
$partial = is_array( $partial_raw ) ? $partial_raw : array();
$match_count = count( $pages ) + count( $full );
$top_domains = $this->extract_top_domains( array_merge( $pages, $full, $partial ) );
return new ScanResult( $match_count, $top_domains, $decoded );
}
/**
* Extracts the webDetection sub-object from the full API response.
*
* @param array<mixed, mixed> $decoded Full decoded API response.
*
* @return array<mixed, mixed>
*/
private function extract_web_detection( array $decoded ): array {
$responses_raw = $decoded['responses'] ?? null;
if ( ! is_array( $responses_raw ) ) {
return array();
}
$first = reset( $responses_raw );
if ( ! is_array( $first ) ) {
return array();
}
$wd = $first['webDetection'] ?? null;
return is_array( $wd ) ? $wd : array();
}
}

View file

@ -0,0 +1,99 @@
<?php
/**
* Cross-provider result consolidation.
*
* @package MediaRightsAudit\External
*/
namespace MediaRightsAudit\External;
/**
* Aggregates external scan results across multiple providers to surface
* domains that appear in more than one provider's findings (consensus signals).
*
* Consensus detection provides stronger copyright-risk signals: a domain
* independently found by both Google Vision and TinEye is more likely to be
* a genuine unauthorised copy than one found by a single provider.
*/
class ResultsConsolidator {
/**
* Returns domains confirmed by results from at least $min_providers distinct providers.
*
* Reads top_domains JSON stored in mra_external_results for the given attachment
* and counts how many providers reported each domain. Only domains meeting the
* threshold are returned, sorted descending by provider-agreement count.
*
* @param int $attachment_id WordPress attachment ID.
* @param int $min_providers Minimum number of providers that must agree (default: 2).
*
* @return array<string, int> Domain agreeing-provider count, sorted descending.
*/
public static function get_consensus_domains( int $attachment_id, int $min_providers = 2 ): array {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT provider, top_domains FROM {$wpdb->prefix}mra_external_results WHERE attachment_id = %d",
$attachment_id
),
ARRAY_A
);
if ( ! $rows ) {
return array();
}
return self::aggregate_consensus( $rows, $min_providers );
}
/**
* Computes consensus domains from an array of provider result rows.
*
* Each row must contain 'provider' (string) and 'top_domains' (JSON string
* mapping domain count). Designed to be called directly in unit tests
* by passing mock row data.
*
* @param array<int, array<string, mixed>> $rows Provider result rows.
* @param int $min_providers Minimum provider agreement.
*
* @return array<string, int> Domain agreeing-provider count, sorted descending.
*/
public static function aggregate_consensus( array $rows, int $min_providers = 2 ): array {
$domain_to_providers = array();
foreach ( $rows as $row ) {
$provider_val = $row['provider'] ?? null;
$provider = is_string( $provider_val ) ? $provider_val : '';
if ( '' === $provider ) {
continue;
}
$domains_val = $row['top_domains'] ?? null;
$domains_raw = is_string( $domains_val ) ? json_decode( $domains_val, true ) : null;
$domains = is_array( $domains_raw ) ? $domains_raw : array();
foreach ( $domains as $domain => $count ) {
if ( ! is_string( $domain ) ) {
continue;
}
if ( ! isset( $domain_to_providers[ $domain ] ) ) {
$domain_to_providers[ $domain ] = array();
}
$domain_to_providers[ $domain ][ $provider ] = true;
}
}
$consensus = array();
foreach ( $domain_to_providers as $domain => $providers_map ) {
$provider_count = count( $providers_map );
if ( $provider_count >= $min_providers ) {
$consensus[ $domain ] = $provider_count;
}
}
arsort( $consensus );
return $consensus;
}
}

75
includes/External/ScanResult.php vendored Normal file
View file

@ -0,0 +1,75 @@
<?php
/**
* Value object representing the outcome of one external provider scan.
*
* @package MediaRightsAudit\External
*/
namespace MediaRightsAudit\External;
/**
* Immutable result returned by AbstractProvider::scan().
*/
class ScanResult {
/**
* Number of pages / images where the attachment was found.
*
* @var int
*/
private int $match_count;
/**
* Top domains where matches were found (domain occurrence count), max 10.
*
* @var array<string, int>
*/
private array $top_domains;
/**
* Full decoded API response for storage.
*
* @var array<mixed, mixed>
*/
private array $raw_response;
/**
* Constructs a new scan result.
*
* @param int $match_count Number of pages or images found.
* @param array<string, int> $top_domains Domain count map.
* @param array<mixed, mixed> $raw_response Full decoded API payload.
*/
public function __construct( int $match_count, array $top_domains, array $raw_response ) {
$this->match_count = $match_count;
$this->top_domains = $top_domains;
$this->raw_response = $raw_response;
}
/**
* Returns the number of matching pages or images found.
*
* @return int
*/
public function match_count(): int {
return $this->match_count;
}
/**
* Returns the top-domain occurrence map (domain count), max 10 entries.
*
* @return array<string, int>
*/
public function top_domains(): array {
return $this->top_domains;
}
/**
* Returns the full decoded API response payload.
*
* @return array<mixed, mixed>
*/
public function raw_response(): array {
return $this->raw_response;
}
}

151
includes/External/TinEyeProvider.php vendored Normal file
View file

@ -0,0 +1,151 @@
<?php
/**
* TinEye reverse image search provider.
*
* @package MediaRightsAudit\External
*/
namespace MediaRightsAudit\External;
use MediaRightsAudit\Admin\Settings;
/**
* Submits images to the TinEye Commercial API and normalises the response
* into a ScanResult.
*
* Uses URL-based image submission (GET request with image_url parameter).
* Reads the API key from the plugin settings (tineye_api_key).
* Match count reflects the number of distinct matching images found;
* top domains are extracted from the backlink URLs of all matches.
*/
class TinEyeProvider extends AbstractProvider {
/**
* TinEye Commercial API search endpoint.
*/
const ENDPOINT = 'https://api.tineye.com/rest/search/';
/**
* Returns the machine-readable provider identifier.
*
* @return string
*/
public function provider_slug(): string {
return 'tineye';
}
/**
* Returns the human-readable provider display name.
*
* @return string
*/
public function provider_name(): string {
return 'TinEye';
}
/**
* Submits the image URL to TinEye and returns a ScanResult.
*
* @param int $attachment_id WordPress attachment ID.
* @param string $file_url Public URL of the image.
*
* @return ScanResult
*
* @throws \RuntimeException On configuration, HTTP, or parse errors.
*/
protected function do_scan( int $attachment_id, string $file_url ): ScanResult {
$api_key = $this->get_api_key();
if ( '' === $api_key ) {
throw new \RuntimeException( 'TinEye API key is not configured.' );
}
$url = add_query_arg(
array(
'api_key' => $api_key,
'image_url' => $file_url,
),
self::ENDPOINT
);
$response = wp_remote_get( $url, array( 'timeout' => 30 ) );
if ( is_wp_error( $response ) ) {
throw new \RuntimeException( $response->get_error_message() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
$http_code = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $http_code ) {
throw new \RuntimeException(
sprintf( 'TinEye API returned HTTP %d.', $http_code ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
);
}
$raw_body = wp_remote_retrieve_body( $response );
$decoded = json_decode( $raw_body, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException( 'Failed to parse TinEye API response.' );
}
return $this->parse_response( $decoded );
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Reads the TinEye API key from plugin settings.
*
* @return string Empty string if not configured.
*/
private function get_api_key(): string {
$raw = get_option( Settings::OPTION_NAME, array() );
$opts = is_array( $raw ) ? $raw : array();
$val = $opts['tineye_api_key'] ?? null;
return is_string( $val ) ? trim( $val ) : '';
}
/**
* Parses a decoded TinEye API response into a ScanResult.
*
* Navigates results.matches, extracts backlink page URLs to build the
* domain-frequency map, and counts distinct image matches.
*
* @param array<mixed, mixed> $decoded json_decode()'d API response.
*
* @return ScanResult
*/
private function parse_response( array $decoded ): ScanResult {
$results_raw = $decoded['results'] ?? null;
$results = is_array( $results_raw ) ? $results_raw : array();
$matches_raw = $results['matches'] ?? null;
$matches = is_array( $matches_raw ) ? $matches_raw : array();
$backlink_items = array();
foreach ( $matches as $match ) {
if ( ! is_array( $match ) ) {
continue;
}
$bls_raw = $match['backlinks'] ?? null;
if ( ! is_array( $bls_raw ) ) {
continue;
}
foreach ( $bls_raw as $bl ) {
if ( ! is_array( $bl ) ) {
continue;
}
$url_val = $bl['url'] ?? null;
if ( is_string( $url_val ) && '' !== $url_val ) {
$backlink_items[] = array( 'url' => $url_val );
}
}
}
$match_count = count( $matches );
$top_domains = $this->extract_top_domains( $backlink_items );
return new ScanResult( $match_count, $top_domains, $decoded );
}
}