mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 09:18:54 +00:00
879 lines
28 KiB
PHP
879 lines
28 KiB
PHP
<?php
|
|
|
|
namespace App\Services\AdultProcessing\Pipes;
|
|
|
|
use App\Services\AdultProcessing\AdultProcessingPassable;
|
|
use App\Services\AdultProcessing\AdultProcessingResult;
|
|
use App\Services\AdultProcessing\AgeVerificationManager;
|
|
use Closure;
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\Cookie\CookieJar;
|
|
use GuzzleHttp\Cookie\FileCookieJar;
|
|
use GuzzleHttp\Exception\ConnectException;
|
|
use GuzzleHttp\Exception\RequestException;
|
|
use GuzzleHttp\Middleware;
|
|
use GuzzleHttp\Psr7\Request;
|
|
use GuzzleHttp\Psr7\Response;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
use voku\helper\HtmlDomParser;
|
|
|
|
/**
|
|
* Base class for adult movie processing pipe handlers.
|
|
*
|
|
* Each pipe is responsible for processing releases through a specific adult site provider.
|
|
*
|
|
* Note: This class intentionally uses lazy loading for HtmlDomParser to avoid
|
|
* serialization issues with DOMDocument when using Laravel's Concurrency facade.
|
|
*/
|
|
abstract class AbstractAdultProviderPipe
|
|
{
|
|
protected int $priority = 50;
|
|
|
|
protected bool $echoOutput = true;
|
|
|
|
protected ?HtmlDomParser $html = null;
|
|
|
|
protected ?string $cookie = null;
|
|
|
|
/**
|
|
* Minimum similarity threshold for matching (percentage).
|
|
*/
|
|
protected float $minimumSimilarity = 90.0;
|
|
|
|
/**
|
|
* HTTP client for making requests.
|
|
*/
|
|
protected ?Client $httpClient = null;
|
|
|
|
/**
|
|
* Cookie jar for maintaining session cookies.
|
|
*/
|
|
protected CookieJar|FileCookieJar|null $cookieJar = null;
|
|
|
|
/**
|
|
* Age verification manager for handling site-specific cookies.
|
|
*/
|
|
protected ?AgeVerificationManager $ageVerificationManager = null;
|
|
|
|
/**
|
|
* Maximum number of retry attempts for failed requests.
|
|
*/
|
|
protected int $maxRetries = 3;
|
|
|
|
/**
|
|
* Delay between retries in milliseconds.
|
|
*/
|
|
protected int $retryDelay = 1000;
|
|
|
|
/**
|
|
* Rate limit delay between requests in milliseconds.
|
|
*/
|
|
protected int $rateLimitDelay = 500;
|
|
|
|
/**
|
|
* Last request timestamp for rate limiting.
|
|
*/
|
|
protected static array $lastRequestTime = [];
|
|
|
|
/**
|
|
* Cache duration for search results in minutes.
|
|
*/
|
|
protected int $cacheDuration = 60;
|
|
|
|
/**
|
|
* Whether to use caching for this provider.
|
|
*/
|
|
protected bool $useCache = true;
|
|
|
|
public function __construct()
|
|
{
|
|
// Lazy load HtmlDomParser to avoid serialization issues
|
|
}
|
|
|
|
/**
|
|
* Get the HtmlDomParser instance (lazy loaded).
|
|
*/
|
|
protected function getHtmlParser(): HtmlDomParser
|
|
{
|
|
if ($this->html === null) {
|
|
$this->html = new HtmlDomParser;
|
|
}
|
|
|
|
return $this->html;
|
|
}
|
|
|
|
/**
|
|
* Handle the adult movie processing request.
|
|
*/
|
|
public function handle(AdultProcessingPassable $passable, Closure $next): AdultProcessingPassable
|
|
{
|
|
// If we already have a match, skip processing
|
|
if ($passable->shouldStopProcessing()) {
|
|
return $next($passable);
|
|
}
|
|
|
|
// Set the cookie from passable
|
|
$this->cookie = $passable->getCookie();
|
|
|
|
// Skip if this provider shouldn't process
|
|
if ($this->shouldSkip($passable)) {
|
|
$passable->updateResult(
|
|
AdultProcessingResult::skipped('Provider skipped', $this->getName()),
|
|
$this->getName()
|
|
);
|
|
|
|
return $next($passable);
|
|
}
|
|
|
|
// Output processing message
|
|
if ($this->echoOutput) {
|
|
cli()->info('Checking '.$this->getDisplayName().' for movie info');
|
|
}
|
|
|
|
try {
|
|
// Apply rate limiting
|
|
$this->applyRateLimit();
|
|
|
|
// Attempt to process with this provider
|
|
$result = $this->process($passable);
|
|
|
|
// Update the result
|
|
$passable->updateResult($result, $this->getName());
|
|
} catch (\Exception $e) {
|
|
Log::error('Adult provider '.$this->getName().' failed: '.$e->getMessage(), [
|
|
'provider' => $this->getName(),
|
|
'title' => $passable->getCleanTitle(),
|
|
'exception' => get_class($e),
|
|
]);
|
|
|
|
$passable->updateResult(
|
|
AdultProcessingResult::failed($e->getMessage(), $this->getName()),
|
|
$this->getName()
|
|
);
|
|
}
|
|
|
|
return $next($passable);
|
|
}
|
|
|
|
/**
|
|
* Apply rate limiting between requests to the same provider.
|
|
*/
|
|
protected function applyRateLimit(): void
|
|
{
|
|
$providerName = $this->getName();
|
|
$now = microtime(true) * 1000;
|
|
|
|
if (isset(self::$lastRequestTime[$providerName])) {
|
|
$elapsed = $now - self::$lastRequestTime[$providerName];
|
|
if ($elapsed < $this->rateLimitDelay) {
|
|
usleep((int) (($this->rateLimitDelay - $elapsed) * 1000));
|
|
}
|
|
}
|
|
|
|
self::$lastRequestTime[$providerName] = microtime(true) * 1000;
|
|
}
|
|
|
|
/**
|
|
* Get the priority of this provider (lower = higher priority).
|
|
*/
|
|
public function getPriority(): int
|
|
{
|
|
return $this->priority;
|
|
}
|
|
|
|
/**
|
|
* Get the internal name of this provider.
|
|
*/
|
|
abstract public function getName(): string;
|
|
|
|
/**
|
|
* Get the display name for user-facing output.
|
|
*/
|
|
abstract public function getDisplayName(): string;
|
|
|
|
/**
|
|
* Get the base URL for the provider.
|
|
*/
|
|
abstract protected function getBaseUrl(): string;
|
|
|
|
/**
|
|
* Attempt to process the movie through this provider.
|
|
*/
|
|
abstract protected function process(AdultProcessingPassable $passable): AdultProcessingResult;
|
|
|
|
/**
|
|
* Search for a movie on this provider.
|
|
*
|
|
* @return array|false Returns array with 'title' and 'url' keys on success, false on failure
|
|
*/
|
|
abstract protected function search(string $movie): array|false;
|
|
|
|
/**
|
|
* Get all movie information from the provider.
|
|
*/
|
|
abstract protected function getMovieInfo(): array|false;
|
|
|
|
/**
|
|
* Check if this provider should be skipped for the given passable.
|
|
*/
|
|
protected function shouldSkip(AdultProcessingPassable $passable): bool
|
|
{
|
|
return empty($passable->getCleanTitle());
|
|
}
|
|
|
|
/**
|
|
* Set echo output flag.
|
|
*/
|
|
public function setEchoOutput(bool $echo): self
|
|
{
|
|
$this->echoOutput = $echo;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Get cached search result if available.
|
|
*/
|
|
protected function getCachedSearch(string $movie): array|false|null
|
|
{
|
|
if (! $this->useCache) {
|
|
return null;
|
|
}
|
|
|
|
$cacheKey = 'adult_search_'.$this->getName().'_'.md5(strtolower($movie));
|
|
$cached = Cache::get($cacheKey);
|
|
|
|
if ($cached !== null) {
|
|
if ($this->echoOutput) {
|
|
cli()->info('Using cached result for: '.$movie);
|
|
}
|
|
|
|
return $cached;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Cache a search result.
|
|
*/
|
|
protected function cacheSearchResult(string $movie, array|false $result): void
|
|
{
|
|
if (! $this->useCache) {
|
|
return;
|
|
}
|
|
|
|
$cacheKey = 'adult_search_'.$this->getName().'_'.md5(strtolower($movie));
|
|
Cache::put($cacheKey, $result, now()->addMinutes($this->cacheDuration));
|
|
}
|
|
|
|
/**
|
|
* Fetch raw HTML from a URL with retry support.
|
|
*/
|
|
protected function fetchHtml(string $url, ?string $cookie = null, ?array $postData = null): string|false
|
|
{
|
|
$attempt = 0;
|
|
$lastException = null;
|
|
$ageVerificationAttempted = false;
|
|
|
|
while ($attempt < $this->maxRetries) {
|
|
try {
|
|
$attempt++;
|
|
$client = $this->getHttpClient();
|
|
|
|
$options = [
|
|
'headers' => $this->getDefaultHeaders(),
|
|
];
|
|
|
|
// Add custom cookie if provided
|
|
if ($cookie) {
|
|
$options['headers']['Cookie'] = $cookie;
|
|
}
|
|
|
|
// Handle POST data
|
|
if ($postData !== null) {
|
|
$options['form_params'] = $postData;
|
|
$response = $client->post($url, $options);
|
|
} else {
|
|
$response = $client->get($url, $options);
|
|
}
|
|
|
|
$body = $response->getBody()->getContents();
|
|
|
|
// Check if we were redirected to an age verification page
|
|
$finalUrl = $response->getHeaderLine('X-Guzzle-Redirect-History');
|
|
if (empty($finalUrl)) {
|
|
// Use the effective URI if available
|
|
$effectiveUri = $response->getHeader('X-Guzzle-Redirect-History');
|
|
if (! empty($effectiveUri)) {
|
|
$finalUrl = end($effectiveUri);
|
|
}
|
|
}
|
|
|
|
// Check for common error pages
|
|
if ($this->isErrorPage($body)) {
|
|
Log::warning('Received error page from '.$this->getName().': '.$url);
|
|
if ($attempt < $this->maxRetries) {
|
|
usleep($this->retryDelay * 1000);
|
|
|
|
continue;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Check for age verification requirement
|
|
if ($this->requiresAgeVerification($body)) {
|
|
// If we haven't tried age verification yet, refresh cookies and retry
|
|
if (! $ageVerificationAttempted) {
|
|
$ageVerificationAttempted = true;
|
|
|
|
// Refresh cookies using the manager
|
|
$this->getAgeVerificationManager()->refreshCookies($this->getBaseUrl());
|
|
|
|
// Reset HTTP client to pick up new cookies
|
|
$this->httpClient = null;
|
|
$this->cookieJar = null;
|
|
|
|
Log::info('Refreshed age verification cookies for '.$this->getName().', retrying...');
|
|
|
|
continue;
|
|
}
|
|
|
|
$body = $this->handleAgeVerification($url, $body);
|
|
if ($body === false) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return $body;
|
|
|
|
} catch (ConnectException $e) {
|
|
$lastException = $e;
|
|
Log::warning('Connection failed for '.$this->getName().' (attempt '.$attempt.'): '.$e->getMessage());
|
|
|
|
if ($attempt < $this->maxRetries) {
|
|
usleep($this->retryDelay * 1000 * $attempt); // Exponential backoff
|
|
}
|
|
} catch (RequestException $e) {
|
|
$lastException = $e;
|
|
$statusCode = $e->hasResponse() ? $e->getResponse()->getStatusCode() : 0;
|
|
|
|
// Don't retry on 4xx client errors (except 429 rate limit)
|
|
if ($statusCode >= 400 && $statusCode < 500 && $statusCode !== 429) {
|
|
Log::error('HTTP '.$statusCode.' for '.$this->getName().': '.$url);
|
|
|
|
return false;
|
|
}
|
|
|
|
Log::warning('Request failed for '.$this->getName().' (attempt '.$attempt.'): '.$e->getMessage());
|
|
|
|
if ($attempt < $this->maxRetries) {
|
|
// Longer delay for rate limit errors
|
|
$delay = $statusCode === 429 ? $this->retryDelay * 5 : $this->retryDelay * $attempt;
|
|
usleep($delay * 1000);
|
|
}
|
|
} catch (\Exception $e) {
|
|
$lastException = $e;
|
|
Log::error('Unexpected error for '.$this->getName().': '.$e->getMessage());
|
|
|
|
if ($attempt < $this->maxRetries) {
|
|
usleep($this->retryDelay * 1000);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($lastException) {
|
|
Log::error('All retry attempts failed for '.$this->getName().': '.$lastException->getMessage());
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get default HTTP headers.
|
|
*/
|
|
protected function getDefaultHeaders(): array
|
|
{
|
|
return [
|
|
'User-Agent' => $this->getRandomUserAgent(),
|
|
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
|
'Accept-Language' => 'en-US,en;q=0.9',
|
|
'Accept-Encoding' => 'gzip, deflate, br',
|
|
'Cache-Control' => 'no-cache',
|
|
'Pragma' => 'no-cache',
|
|
'Sec-Fetch-Dest' => 'document',
|
|
'Sec-Fetch-Mode' => 'navigate',
|
|
'Sec-Fetch-Site' => 'none',
|
|
'Sec-Fetch-User' => '?1',
|
|
'Upgrade-Insecure-Requests' => '1',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get a random user agent string.
|
|
*/
|
|
protected function getRandomUserAgent(): string
|
|
{
|
|
$userAgents = [
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0',
|
|
];
|
|
|
|
return $userAgents[array_rand($userAgents)];
|
|
}
|
|
|
|
/**
|
|
* Check if the response is an error page.
|
|
*/
|
|
protected function isErrorPage(string $html): bool
|
|
{
|
|
$errorPatterns = [
|
|
'Access Denied',
|
|
'Service Unavailable',
|
|
'503 Service',
|
|
'502 Bad Gateway',
|
|
'temporarily unavailable',
|
|
'maintenance mode',
|
|
'rate limit exceeded',
|
|
];
|
|
|
|
foreach ($errorPatterns as $pattern) {
|
|
if (stripos($html, $pattern) !== false) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if the page requires age verification.
|
|
*/
|
|
protected function requiresAgeVerification(string $html): bool
|
|
{
|
|
// First check if this looks like a proper content page
|
|
// Content pages have actual movie info, cast, etc.
|
|
$contentIndicators = [
|
|
'<title>.*?DVD.*?</title>',
|
|
'product-info',
|
|
'movie-details',
|
|
'cast-list',
|
|
'genre-list',
|
|
'"@type":\s*"Movie"',
|
|
'"@type":\s*"Product"',
|
|
];
|
|
|
|
foreach ($contentIndicators as $pattern) {
|
|
// Note: Using # as delimiter to avoid issues with / in patterns like </title>
|
|
if (preg_match('#'.$pattern.'#is', $html)) {
|
|
return false; // This is a content page, not an age verification page
|
|
}
|
|
}
|
|
|
|
// Check for short page that might just be a redirect/age gate
|
|
if (strlen($html) < 500) {
|
|
return true; // Very short response likely means we got redirected
|
|
}
|
|
|
|
// Now check for explicit age verification indicators
|
|
$agePatterns = [
|
|
'age verification',
|
|
'are you 18',
|
|
'are you over 18',
|
|
'confirm your age',
|
|
'enter your age',
|
|
'must be 18',
|
|
'age-gate',
|
|
'ageGate',
|
|
'AgeConfirmation', // PopPorn specific
|
|
'ageConfirmationButton', // ADE specific
|
|
'age-confirmation', // Generic
|
|
'verify your age',
|
|
'adult content warning',
|
|
'I am 18 or older',
|
|
'I am over 18',
|
|
'this site contains adult',
|
|
];
|
|
|
|
// Count how many patterns match - if multiple match on a short page, it's likely age verification
|
|
$matchCount = 0;
|
|
foreach ($agePatterns as $pattern) {
|
|
if (stripos($html, $pattern) !== false) {
|
|
$matchCount++;
|
|
// If the page is relatively short and has an age pattern, it's probably an age gate
|
|
if (strlen($html) < 10000) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If multiple patterns match, it's likely an age verification page
|
|
return $matchCount >= 2;
|
|
}
|
|
|
|
/**
|
|
* Handle age verification requirement.
|
|
*/
|
|
protected function handleAgeVerification(string $url, string $html): string|false
|
|
{
|
|
// First, try to use site-specific cookies from the AgeVerificationManager
|
|
$manager = $this->getAgeVerificationManager();
|
|
$domain = parse_url($this->getBaseUrl(), PHP_URL_HOST);
|
|
$domain = preg_replace('/^www\./', '', $domain);
|
|
|
|
// Re-initialize cookies from the manager and retry
|
|
if ($this->cookieJar) {
|
|
// The manager already handles setting cookies, but let's ensure they're fresh
|
|
Log::info('Attempting to handle age verification for '.$this->getName().' with domain: '.$domain);
|
|
}
|
|
|
|
// Try to find and submit age verification form
|
|
$this->getHtmlParser()->loadHtml($html);
|
|
|
|
// Look for common age verification form patterns
|
|
$forms = $this->getHtmlParser()->find('form');
|
|
foreach ($forms as $form) {
|
|
$action = $form->action ?? '';
|
|
$method = strtoupper($form->method ?? 'GET');
|
|
|
|
// Check if this looks like an age verification form
|
|
$formHtml = $form->innerHtml ?? '';
|
|
if (stripos($formHtml, 'age') !== false || stripos($formHtml, '18') !== false ||
|
|
stripos($formHtml, 'adult') !== false || stripos($formHtml, 'enter') !== false ||
|
|
stripos($formHtml, 'confirm') !== false) {
|
|
// Try to submit the form with age confirmation
|
|
$postData = $this->extractAgeVerificationFormData($form);
|
|
|
|
if (! empty($postData)) {
|
|
$submitUrl = $action;
|
|
if (! str_starts_with($submitUrl, 'http')) {
|
|
$submitUrl = $this->getBaseUrl().'/'.ltrim($submitUrl, '/');
|
|
}
|
|
|
|
// Submit the age verification
|
|
try {
|
|
$response = $this->getHttpClient()->request($method, $submitUrl, [
|
|
'form_params' => $postData,
|
|
'headers' => $this->getDefaultHeaders(),
|
|
]);
|
|
|
|
$body = $response->getBody()->getContents();
|
|
|
|
// Check if we still get age verification after submit
|
|
if (! $this->requiresAgeVerification($body)) {
|
|
return $body;
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::warning('Age verification submission failed for '.$this->getName().': '.$e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Look for JavaScript-based age verification (click to enter)
|
|
if (preg_match('/onclick\s*=\s*["\'].*?(enter|agree|confirm|over18|adult).*?["\']/i', $html) ||
|
|
preg_match('/<a[^>]*href\s*=\s*["\']([^"\']*)["\'][^>]*>(Enter|I am over 18|Agree|Enter Site|I Agree)/i', $html, $matches)) {
|
|
// Try to follow the link or simulate the click
|
|
if (! empty($matches[1])) {
|
|
$enterUrl = $matches[1];
|
|
if (! str_starts_with($enterUrl, 'http')) {
|
|
$enterUrl = $this->getBaseUrl().'/'.ltrim($enterUrl, '/');
|
|
}
|
|
|
|
try {
|
|
$response = $this->getHttpClient()->get($enterUrl, [
|
|
'headers' => $this->getDefaultHeaders(),
|
|
]);
|
|
$body = $response->getBody()->getContents();
|
|
|
|
if (! $this->requiresAgeVerification($body)) {
|
|
return $body;
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::warning('Age verification link follow failed for '.$this->getName().': '.$e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
// If all else fails, try to just refetch the original URL
|
|
// (sometimes the cookies from previous attempts work)
|
|
try {
|
|
$response = $this->getHttpClient()->get($url, [
|
|
'headers' => $this->getDefaultHeaders(),
|
|
]);
|
|
$body = $response->getBody()->getContents();
|
|
|
|
if (! $this->requiresAgeVerification($body)) {
|
|
return $body;
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::warning('Age verification retry failed for '.$this->getName().': '.$e->getMessage());
|
|
}
|
|
|
|
// If we couldn't handle age verification, log and return false
|
|
Log::warning('Could not handle age verification for '.$this->getName().': '.$url);
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Extract form data for age verification submission.
|
|
*/
|
|
protected function extractAgeVerificationFormData($form): array
|
|
{
|
|
$data = [];
|
|
|
|
// Get all input fields
|
|
foreach ($form->find('input') as $input) {
|
|
$name = $input->name ?? '';
|
|
$type = strtolower($input->type ?? 'text');
|
|
$value = $input->value ?? '';
|
|
|
|
if (empty($name)) {
|
|
continue;
|
|
}
|
|
|
|
// Handle different input types
|
|
switch ($type) {
|
|
case 'hidden':
|
|
$data[$name] = $value;
|
|
break;
|
|
case 'checkbox':
|
|
// Usually age verification checkboxes need to be checked
|
|
if (stripos($name, 'age') !== false || stripos($name, 'agree') !== false || stripos($name, 'confirm') !== false) {
|
|
$data[$name] = $value ?: '1';
|
|
}
|
|
break;
|
|
case 'submit':
|
|
// Include submit button value if it has a name
|
|
if (! empty($value)) {
|
|
$data[$name] = $value;
|
|
}
|
|
break;
|
|
default:
|
|
// For text inputs that might be age/birthdate
|
|
if (stripos($name, 'age') !== false || stripos($name, 'year') !== false) {
|
|
$data[$name] = '1990'; // Default to a valid birth year
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle select elements (for birthdate selection)
|
|
foreach ($form->find('select') as $select) {
|
|
$name = $select->name ?? '';
|
|
if (empty($name)) {
|
|
continue;
|
|
}
|
|
|
|
if (stripos($name, 'year') !== false) {
|
|
$data[$name] = '1990';
|
|
} elseif (stripos($name, 'month') !== false) {
|
|
$data[$name] = '01';
|
|
} elseif (stripos($name, 'day') !== false) {
|
|
$data[$name] = '01';
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Get the age verification manager instance.
|
|
*/
|
|
protected function getAgeVerificationManager(): AgeVerificationManager
|
|
{
|
|
if ($this->ageVerificationManager === null) {
|
|
$this->ageVerificationManager = new AgeVerificationManager;
|
|
}
|
|
|
|
return $this->ageVerificationManager;
|
|
}
|
|
|
|
/**
|
|
* Get or create HTTP client with retry middleware.
|
|
*/
|
|
protected function getHttpClient(): Client
|
|
{
|
|
if ($this->httpClient === null) {
|
|
// Use the AgeVerificationManager to get proper cookie jar with age verification cookies
|
|
$this->cookieJar = $this->getAgeVerificationManager()->getCookieJar($this->getBaseUrl());
|
|
|
|
$this->httpClient = new Client([
|
|
'timeout' => 30,
|
|
'connect_timeout' => 15,
|
|
'verify' => false,
|
|
'cookies' => $this->cookieJar,
|
|
'allow_redirects' => [
|
|
'max' => 5,
|
|
'strict' => false,
|
|
'referer' => true,
|
|
'track_redirects' => true,
|
|
],
|
|
'http_errors' => true,
|
|
]);
|
|
}
|
|
|
|
return $this->httpClient;
|
|
}
|
|
|
|
/**
|
|
* Calculate similarity between two strings using multiple algorithms.
|
|
*/
|
|
protected function calculateSimilarity(string $searchTerm, string $resultTitle): float
|
|
{
|
|
// Clean up both strings for comparison
|
|
$cleanSearch = $this->cleanTitleForComparison($searchTerm);
|
|
$cleanResult = $this->cleanTitleForComparison($resultTitle);
|
|
|
|
// Calculate similarity using multiple methods
|
|
similar_text($cleanSearch, $cleanResult, $similarTextPercent);
|
|
|
|
// Also calculate Levenshtein distance based similarity
|
|
$maxLen = max(strlen($cleanSearch), strlen($cleanResult));
|
|
if ($maxLen > 0) {
|
|
$levenshtein = levenshtein($cleanSearch, $cleanResult);
|
|
$levenshteinPercent = (1 - ($levenshtein / $maxLen)) * 100;
|
|
} else {
|
|
$levenshteinPercent = 0;
|
|
}
|
|
|
|
// Use the higher of the two similarity scores
|
|
return max($similarTextPercent, $levenshteinPercent);
|
|
}
|
|
|
|
/**
|
|
* Clean a title for comparison purposes.
|
|
*/
|
|
protected function cleanTitleForComparison(string $title): string
|
|
{
|
|
$title = strtolower($title);
|
|
$title = str_replace('/XXX/', '', $title);
|
|
|
|
// Remove common adult movie prefixes/suffixes
|
|
$removePatterns = [
|
|
'/\b(xxx|adult|porn|erotic|hd|4k|1080p|720p|dvdrip|webrip|bluray)\b/i',
|
|
'/\(.*?\)/',
|
|
'/\[.*?\]/',
|
|
'/[._-]+/',
|
|
'/\s+/',
|
|
];
|
|
|
|
foreach ($removePatterns as $pattern) {
|
|
$title = preg_replace($pattern, ' ', $title);
|
|
}
|
|
|
|
return trim($title);
|
|
}
|
|
|
|
/**
|
|
* Extract movie information from the loaded HTML.
|
|
*/
|
|
protected function extractCovers(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
protected function extractSynopsis(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
protected function extractCast(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
protected function extractGenres(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
protected function extractProductInfo(bool $extras = false): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
protected function extractTrailers(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Output match success message.
|
|
*/
|
|
protected function outputMatch(string $title): void
|
|
{
|
|
if (! $this->echoOutput) {
|
|
return;
|
|
}
|
|
|
|
cli()->primary('Found match on '.$this->getDisplayName().': '.$title);
|
|
}
|
|
|
|
/**
|
|
* Output failure message.
|
|
*/
|
|
protected function outputNotFound(): void
|
|
{
|
|
if (! $this->echoOutput) {
|
|
return;
|
|
}
|
|
|
|
cli()->notice('No match found on '.$this->getDisplayName());
|
|
}
|
|
|
|
/**
|
|
* Parse JSON-LD structured data from HTML.
|
|
*/
|
|
protected function extractJsonLd(string $html): ?array
|
|
{
|
|
// Note: Using # as delimiter because pattern contains / in </script>
|
|
if (preg_match_all('#<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>#si', $html, $matches)) {
|
|
foreach ($matches[1] as $json) {
|
|
$data = json_decode(trim($json), true);
|
|
if (json_last_error() === JSON_ERROR_NONE && is_array($data)) {
|
|
// Handle both single object and array of objects
|
|
if (isset($data['@type'])) {
|
|
return $data;
|
|
} elseif (isset($data[0]['@type'])) {
|
|
return $data[0];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract Open Graph meta data from HTML.
|
|
*/
|
|
protected function extractOpenGraph(string $html): array
|
|
{
|
|
$og = [];
|
|
$this->getHtmlParser()->loadHtml($html);
|
|
|
|
$metaTags = [
|
|
'og:title' => 'title',
|
|
'og:description' => 'description',
|
|
'og:image' => 'image',
|
|
'og:url' => 'url',
|
|
];
|
|
|
|
foreach ($metaTags as $property => $key) {
|
|
$meta = $this->getHtmlParser()->findOne('meta[property="'.$property.'"]');
|
|
if ($meta && isset($meta->content)) { // @phpstan-ignore booleanAnd.leftAlwaysTrue
|
|
$og[$key] = trim($meta->content);
|
|
}
|
|
}
|
|
|
|
return $og;
|
|
}
|
|
}
|