mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-31 02:08:57 +00:00
511 lines
16 KiB
PHP
511 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Services\AdultProcessing\Pipes;
|
|
|
|
use App\Services\AdultProcessing\AdultProcessingPassable;
|
|
use App\Services\AdultProcessing\AdultProcessingResult;
|
|
|
|
/**
|
|
* Popporn provider pipe.
|
|
*
|
|
* Handles movie information extraction from popporn.com
|
|
*/
|
|
class PoppornPipe extends AbstractAdultProviderPipe
|
|
{
|
|
protected int $priority = 20;
|
|
|
|
private const BASE_URL = 'https://www.popporn.com';
|
|
|
|
private const SEARCH_ENDPOINT = '/search?q=';
|
|
|
|
protected string $directUrl = '';
|
|
|
|
protected string $title = '';
|
|
|
|
protected string $response = '';
|
|
|
|
public function getName(): string
|
|
{
|
|
return 'pop';
|
|
}
|
|
|
|
public function getDisplayName(): string
|
|
{
|
|
return 'PopPorn';
|
|
}
|
|
|
|
protected function getBaseUrl(): string
|
|
{
|
|
return self::BASE_URL;
|
|
}
|
|
|
|
protected function process(AdultProcessingPassable $passable): AdultProcessingResult
|
|
{
|
|
$movie = $passable->getCleanTitle();
|
|
|
|
$searchResult = $this->search($movie);
|
|
|
|
if ($searchResult === false) {
|
|
$this->outputNotFound();
|
|
|
|
return AdultProcessingResult::notFound($this->getName());
|
|
}
|
|
|
|
$this->title = $searchResult['title'];
|
|
$this->directUrl = $searchResult['url'];
|
|
|
|
// Fetch the movie details page
|
|
$this->response = $this->fetchHtml($this->directUrl, $this->cookie);
|
|
|
|
if ($this->response === false) { // @phpstan-ignore identical.alwaysFalse
|
|
return AdultProcessingResult::failed('Failed to fetch movie details page', $this->getName());
|
|
}
|
|
|
|
$this->getHtmlParser()->loadHtml($this->response);
|
|
|
|
$movieInfo = $this->getMovieInfo();
|
|
|
|
if ($movieInfo === false) {
|
|
return AdultProcessingResult::notFound($this->getName());
|
|
}
|
|
|
|
$this->outputMatch($this->title);
|
|
|
|
return AdultProcessingResult::matched(
|
|
$this->title,
|
|
$this->getName(),
|
|
$movieInfo
|
|
);
|
|
}
|
|
|
|
protected function search(string $movie): array|false
|
|
{
|
|
if (empty($movie)) {
|
|
return false;
|
|
}
|
|
|
|
// First, establish a session by visiting the AgeConfirmation endpoint to set cookies
|
|
$this->acceptAgeVerification();
|
|
|
|
$searchUrl = self::BASE_URL.self::SEARCH_ENDPOINT.urlencode($movie);
|
|
$response = $this->fetchHtml($searchUrl, $this->cookie);
|
|
|
|
if (empty($response)) {
|
|
return false;
|
|
}
|
|
|
|
$this->getHtmlParser()->loadHtml($response);
|
|
|
|
$bestMatch = null;
|
|
$highestSimilarity = 0;
|
|
|
|
// Try multiple selector patterns for search results
|
|
$resultSelectors = [
|
|
'div.product-info a, div.title a',
|
|
'div.product-title a',
|
|
'h3.product-title a',
|
|
];
|
|
|
|
foreach ($resultSelectors as $selector) {
|
|
$results = $this->getHtmlParser()->find($selector);
|
|
|
|
if (! empty($results)) {
|
|
foreach ($results as $result) {
|
|
$title = $result->title ?? $result->plaintext;
|
|
$url = $result->href; // @phpstan-ignore property.notFound
|
|
|
|
if (! empty($title)) {
|
|
$similarity = $this->calculateSimilarity($movie, $title);
|
|
|
|
if ($similarity > $highestSimilarity) {
|
|
$highestSimilarity = $similarity;
|
|
$bestMatch = [
|
|
'title' => trim($title),
|
|
'url' => str_starts_with($url, 'http') ? $url : self::BASE_URL.$url,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($bestMatch !== null && $highestSimilarity >= $this->minimumSimilarity) {
|
|
return $bestMatch;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected function getMovieInfo(): array|false
|
|
{
|
|
$results = [];
|
|
|
|
if (! empty($this->directUrl)) {
|
|
if (! empty($this->title)) {
|
|
$results['title'] = $this->title;
|
|
}
|
|
$results['directurl'] = $this->directUrl;
|
|
}
|
|
|
|
// Get all the movie data
|
|
$results = array_merge($results, $this->extractSynopsis());
|
|
$results = array_merge($results, $this->extractProductInfo(true));
|
|
$results = array_merge($results, $this->extractCast());
|
|
$results = array_merge($results, $this->extractGenres());
|
|
$results = array_merge($results, $this->extractCovers());
|
|
$results = array_merge($results, $this->extractTrailers());
|
|
|
|
if (empty($results)) {
|
|
return false;
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
protected function extractCovers(): array
|
|
{
|
|
$res = [];
|
|
|
|
// Method 1: Try structured data
|
|
if (preg_match('/"image":\s*"(.*?)"/is', $this->response, $match)) {
|
|
$res['boxcover'] = trim($match[1]);
|
|
// Try to determine backcover from boxcover pattern
|
|
if (stripos(trim($match[1]), '_aa') !== false) {
|
|
$res['backcover'] = str_ireplace('_aa', '_bb', trim($match[1]));
|
|
} else {
|
|
$res['backcover'] = str_ireplace('.jpg', '_b.jpg', trim($match[1]));
|
|
}
|
|
|
|
return $res;
|
|
}
|
|
|
|
// Method 2: Try multiple selectors
|
|
$selectors = [
|
|
'div[id=box-art], a[rel=box-art]',
|
|
'img.front',
|
|
'div.box-cover img',
|
|
'div.product-image img',
|
|
];
|
|
|
|
foreach ($selectors as $selector) {
|
|
if ($ret = $this->getHtmlParser()->findOne($selector)) { // @phpstan-ignore if.alwaysTrue
|
|
$res['boxcover'] = $ret->href ?? $ret->src; // @phpstan-ignore property.notFound
|
|
|
|
// Try to determine backcover
|
|
if (stripos($res['boxcover'], '_aa') !== false) {
|
|
$res['backcover'] = str_ireplace('_aa', '_bb', $res['boxcover']);
|
|
} else {
|
|
$res['backcover'] = str_ireplace('.jpg', '_b.jpg', $res['boxcover']);
|
|
}
|
|
|
|
return $res;
|
|
}
|
|
}
|
|
|
|
return $res; // @phpstan-ignore deadCode.unreachable
|
|
}
|
|
|
|
protected function extractSynopsis(): array
|
|
{
|
|
$res = [];
|
|
|
|
// Method 1: Try structured data
|
|
if (preg_match('/"description":\s*"(.*?)"/is', $this->response, $match)) {
|
|
$res['synopsis'] = trim(html_entity_decode(str_replace('\\u', '\\u', $match[1])));
|
|
|
|
return $res;
|
|
}
|
|
|
|
// Method 2: Try multiple selectors
|
|
$selectors = [
|
|
'div[id=product-info] h3[class=highlight] + *',
|
|
'div.product-description',
|
|
'div.synopsis',
|
|
'meta[name="description"]',
|
|
];
|
|
|
|
foreach ($selectors as $selector) {
|
|
if ($ret = $this->getHtmlParser()->findOne($selector)) { // @phpstan-ignore if.alwaysTrue
|
|
$text = $ret->plaintext ?? $ret->content; // @phpstan-ignore property.notFound
|
|
|
|
// Filter out "POPPORN EXCLUSIVE" text
|
|
if (stripos(trim($text), 'POPPORN EXCLUSIVE') !== false) {
|
|
if ($ret->next_sibling()) {
|
|
$text = trim($ret->next_sibling()->plaintext);
|
|
}
|
|
}
|
|
|
|
if (! empty($text)) {
|
|
$res['synopsis'] = trim($text);
|
|
|
|
return $res;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $res;
|
|
}
|
|
|
|
protected function extractTrailers(): array
|
|
{
|
|
$res = [];
|
|
|
|
// Method 1: Try structured data
|
|
if (preg_match('/"contentUrl":\s*"(.*?)"/is', $this->response, $match)) {
|
|
$url = trim($match[1]);
|
|
if (! empty($url)) {
|
|
$res['trailers']['url'] = $url;
|
|
|
|
return $res;
|
|
}
|
|
}
|
|
|
|
// Method 2: Modern video embeds
|
|
$videoSelectors = [
|
|
'video source',
|
|
'iframe[src*="trailer"]',
|
|
'video[src]',
|
|
];
|
|
|
|
foreach ($videoSelectors as $selector) {
|
|
$ret = $this->getHtmlParser()->findOne($selector);
|
|
if ($ret && isset($ret->src) && ! empty(trim($ret->src))) { // @phpstan-ignore booleanAnd.leftAlwaysTrue
|
|
$res['trailers']['url'] = trim($ret->src);
|
|
|
|
return $res;
|
|
}
|
|
}
|
|
|
|
return $res;
|
|
}
|
|
|
|
protected function extractProductInfo(bool $extras = false): array
|
|
{
|
|
$res = [];
|
|
$productInfo = [];
|
|
$director = '';
|
|
|
|
// Method 1: Try structured data
|
|
if (preg_match('/"director":\s*{[^}]*"name":\s*"(.*?)"/is', $this->response, $match)) {
|
|
$director = trim($match[1]);
|
|
}
|
|
|
|
// Method 2: Look for product details in various formats
|
|
$selectors = [
|
|
'div#lside',
|
|
'div.product-details',
|
|
'div.product-info',
|
|
];
|
|
|
|
foreach ($selectors as $selector) {
|
|
if ($ret = $this->getHtmlParser()->findOne($selector)) { // @phpstan-ignore if.alwaysTrue
|
|
// Extract country information
|
|
$country = false;
|
|
$rawInfo = [];
|
|
|
|
foreach ($ret->find('text') as $e) {
|
|
$e = trim($e->innertext);
|
|
$e = str_replace([', ', '...', ' '], '', $e);
|
|
|
|
if (stripos($e, 'Country:') !== false) {
|
|
$country = true;
|
|
}
|
|
|
|
if ($country === true) {
|
|
if (stripos($e, 'addthis_config') === false) {
|
|
if (! empty($e)) {
|
|
$rawInfo[] = $e;
|
|
}
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (! empty($rawInfo)) {
|
|
$productInfo = array_chunk($rawInfo, 2, false);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$res['productinfo'] = $productInfo;
|
|
$res['director'] = $director;
|
|
|
|
// Get extras if requested
|
|
if ($extras === true) {
|
|
$features = false;
|
|
$extrasData = [];
|
|
|
|
$featureSelectors = [
|
|
'ul.stock-information',
|
|
'div.features',
|
|
'div.extras',
|
|
];
|
|
|
|
foreach ($featureSelectors as $selector) {
|
|
if ($ret = $this->getHtmlParser()->findOne($selector)) { // @phpstan-ignore if.alwaysTrue
|
|
foreach ($ret->find('li') as $e) {
|
|
$text = trim($e->plaintext);
|
|
if ($text === 'Features:') {
|
|
$features = true;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($features === true && ! empty($text)) {
|
|
$extrasData[] = $text;
|
|
}
|
|
}
|
|
|
|
if (! empty($extrasData)) {
|
|
$res['extras'] = $extrasData;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $res;
|
|
}
|
|
|
|
protected function extractCast(): array
|
|
{
|
|
$res = [];
|
|
$cast = [];
|
|
$director = '';
|
|
|
|
// Method 1: Try structured data
|
|
if (preg_match_all('/"actor":\s*{[^}]*"name":\s*"(.*?)"/is', $this->response, $matches)) {
|
|
foreach ($matches[1] as $actor) {
|
|
$cast[] = trim($actor);
|
|
}
|
|
}
|
|
|
|
if (preg_match('/"director":\s*{[^}]*"name":\s*"(.*?)"/is', $this->response, $match)) {
|
|
$director = trim($match[1]);
|
|
}
|
|
|
|
// Method 2: Try multiple selectors
|
|
if (empty($cast)) {
|
|
$castSelectors = [
|
|
'div.cast a',
|
|
'div.stars a',
|
|
'div.performers a',
|
|
];
|
|
|
|
foreach ($castSelectors as $selector) {
|
|
$elements = $this->getHtmlParser()->find($selector);
|
|
if (! empty($elements)) {
|
|
foreach ($elements as $element) {
|
|
$cast[] = trim($element->plaintext);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$res['cast'] = array_unique(array_filter($cast));
|
|
$res['director'] = $director;
|
|
|
|
return $res;
|
|
}
|
|
|
|
protected function extractGenres(): array
|
|
{
|
|
$res = [];
|
|
$genres = [];
|
|
|
|
// Method 1: Try structured data
|
|
if (preg_match_all('/"genre":\s*"(.*?)"/is', $this->response, $matches)) {
|
|
foreach ($matches[1] as $genre) {
|
|
$genres[] = trim($genre);
|
|
}
|
|
}
|
|
|
|
// Method 2: Try multiple selectors
|
|
if (empty($genres)) {
|
|
$selectors = [
|
|
'div[id=thekeywords] a',
|
|
'p[class=keywords] a',
|
|
'div.categories a',
|
|
'div.tags a',
|
|
];
|
|
|
|
foreach ($selectors as $selector) {
|
|
$elements = $this->getHtmlParser()->find($selector);
|
|
if (! empty($elements)) {
|
|
foreach ($elements as $e) {
|
|
$genres[] = trim($e->plaintext);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$res['genres'] = array_unique(array_filter($genres));
|
|
|
|
return $res;
|
|
}
|
|
|
|
/**
|
|
* Accept age verification by visiting the confirmation endpoint.
|
|
* PopPorn uses a redirect-based age verification system.
|
|
*/
|
|
protected function acceptAgeVerification(): void
|
|
{
|
|
try {
|
|
// PopPorn requires visiting the AgeConfirmation endpoint with the URL you want to go to
|
|
// The confirmation sets a cookie that allows access
|
|
$client = $this->getHttpClient();
|
|
|
|
// PopPorn redirects to /AgeConfirmation?url2=/ on first visit
|
|
// We need to visit that page and follow through to set the etoken cookie
|
|
|
|
// First, make a request that disables redirects to see where we're being sent
|
|
try {
|
|
$response = $client->get(self::BASE_URL.'/', [
|
|
'headers' => $this->getDefaultHeaders(),
|
|
'allow_redirects' => false,
|
|
'http_errors' => false,
|
|
]);
|
|
|
|
// Check for redirect to age confirmation
|
|
$statusCode = $response->getStatusCode();
|
|
if ($statusCode === 302 || $statusCode === 301) {
|
|
$location = $response->getHeaderLine('Location');
|
|
|
|
// If redirected to AgeConfirmation, follow the flow
|
|
if (stripos($location, 'AgeConfirmation') !== false) {
|
|
// The redirect URL includes ?url2= parameter, we need to visit it
|
|
$ageConfirmUrl = $location;
|
|
if (! str_starts_with($ageConfirmUrl, 'http')) {
|
|
$ageConfirmUrl = self::BASE_URL.$ageConfirmUrl;
|
|
}
|
|
|
|
// Visit the age confirmation page
|
|
$ageResponse = $client->get($ageConfirmUrl, [
|
|
'headers' => $this->getDefaultHeaders(),
|
|
'allow_redirects' => true,
|
|
'http_errors' => false,
|
|
]);
|
|
|
|
\Illuminate\Support\Facades\Log::debug('PopPorn age confirmation visited: '.$ageConfirmUrl);
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Ignore redirect errors
|
|
}
|
|
|
|
// Wait a moment to simulate human behavior
|
|
usleep(500000); // 500ms
|
|
|
|
} catch (\Exception $e) {
|
|
// Log but don't fail - we'll try the search anyway
|
|
\Illuminate\Support\Facades\Log::debug('PopPorn age verification setup: '.$e->getMessage());
|
|
}
|
|
}
|
|
}
|