v1.0.0
This commit is contained in:
commit
6708bbb67f
43 changed files with 8342 additions and 0 deletions
128
includes/External/AbstractProvider.php
vendored
Normal file
128
includes/External/AbstractProvider.php
vendored
Normal 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
334
includes/External/ExternalScanner.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
177
includes/External/GoogleVisionProvider.php
vendored
Normal file
177
includes/External/GoogleVisionProvider.php
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
99
includes/External/ResultsConsolidator.php
vendored
Normal file
99
includes/External/ResultsConsolidator.php
vendored
Normal 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
75
includes/External/ScanResult.php
vendored
Normal 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
151
includes/External/TinEyeProvider.php
vendored
Normal 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 );
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue