Files
newznab-tmux/app/Services/AdultProcessing/Pipes/AbstractAdultProviderPipe.php
T
2026-02-10 16:16:29 +01:00

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;
}
}