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 = [ '.*?DVD.*?', '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 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('/]*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 if (preg_match_all('#]*type=["\']application/ld\+json["\'][^>]*>(.*?)#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; } }