mirror of
https://github.com/Viren070/MediaFusion.git
synced 2025-12-01 23:21:11 +01:00
505 lines
18 KiB
Python
505 lines
18 KiB
Python
import asyncio
|
|
import math
|
|
from datetime import timedelta, datetime
|
|
from typing import List, Any, Optional, AsyncGenerator, Tuple
|
|
from urllib.parse import quote
|
|
|
|
import PTT
|
|
import httpx
|
|
from bs4 import BeautifulSoup
|
|
|
|
from db.config import settings
|
|
from db.models import TorrentStreams, EpisodeFile, MediaFusionMetaData
|
|
from scrapers.base_scraper import BaseScraper
|
|
from utils.network import CircuitBreaker, batch_process_with_circuit_breaker
|
|
from utils.parser import convert_size_to_bytes, is_contain_18_plus_keywords
|
|
from utils.runtime_const import BT4G_SEARCH_TTL
|
|
from utils.validation_helper import is_video_file
|
|
|
|
|
|
class BT4GScraper(BaseScraper):
|
|
MOVIE_SEARCH_QUERY_TEMPLATES = [
|
|
"{title} {year}", # Title with year
|
|
"{title}", # Title only
|
|
]
|
|
SERIES_SEARCH_QUERY_TEMPLATES = [
|
|
"{title} S{season:02d}E{episode:02d}", # Standard SXXEYY format
|
|
"{title} S{season:02d}", # Season-only format
|
|
"{title}", # Title only
|
|
]
|
|
ITEMS_PER_PAGE = 15 # BT4G shows 15 items per page
|
|
cache_key_prefix = "bt4g"
|
|
|
|
def __init__(self):
|
|
super().__init__(cache_key_prefix=self.cache_key_prefix, logger_name=__name__)
|
|
self.semaphore = asyncio.Semaphore(10)
|
|
self.http_client = httpx.AsyncClient(
|
|
proxy=settings.requests_proxy_url,
|
|
timeout=settings.bt4g_search_timeout,
|
|
headers={
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
},
|
|
)
|
|
|
|
@BaseScraper.cache(ttl=BT4G_SEARCH_TTL)
|
|
@BaseScraper.rate_limit(calls=2, period=timedelta(seconds=1))
|
|
async def _scrape_and_parse(
|
|
self,
|
|
user_data,
|
|
metadata: MediaFusionMetaData,
|
|
catalog_type: str,
|
|
season: Optional[int] = None,
|
|
episode: Optional[int] = None,
|
|
) -> List[TorrentStreams]:
|
|
results = []
|
|
processed_unique_data = set()
|
|
|
|
search_generators = []
|
|
if catalog_type == "movie":
|
|
for query_template in self.MOVIE_SEARCH_QUERY_TEMPLATES:
|
|
search_query = query_template.format(
|
|
title=metadata.title, year=metadata.year
|
|
)
|
|
search_generators.append(
|
|
self.scrape_by_query(
|
|
processed_unique_data,
|
|
metadata,
|
|
search_query,
|
|
catalog_type,
|
|
)
|
|
)
|
|
if settings.scrape_with_aka_titles:
|
|
for aka_title in metadata.aka_titles:
|
|
search_generators.append(
|
|
self.scrape_by_query(
|
|
processed_unique_data,
|
|
metadata,
|
|
aka_title,
|
|
catalog_type,
|
|
)
|
|
)
|
|
else: # series
|
|
for query_template in self.SERIES_SEARCH_QUERY_TEMPLATES:
|
|
search_query = query_template.format(
|
|
title=metadata.title,
|
|
season=season,
|
|
episode=episode,
|
|
)
|
|
search_generators.append(
|
|
self.scrape_by_query(
|
|
processed_unique_data,
|
|
metadata,
|
|
search_query,
|
|
catalog_type,
|
|
season=season,
|
|
episode=episode,
|
|
)
|
|
)
|
|
if settings.scrape_with_aka_titles:
|
|
for aka_title in metadata.aka_titles:
|
|
search_generators.append(
|
|
self.scrape_by_query(
|
|
processed_unique_data,
|
|
metadata,
|
|
aka_title,
|
|
catalog_type,
|
|
season=season,
|
|
episode=episode,
|
|
)
|
|
)
|
|
|
|
try:
|
|
async for stream in self.process_streams(
|
|
*search_generators,
|
|
max_process=settings.bt4g_immediate_max_process,
|
|
max_process_time=settings.bt4g_immediate_max_process_time,
|
|
catalog_type=catalog_type,
|
|
season=season,
|
|
episode=episode,
|
|
):
|
|
results.append(stream)
|
|
except Exception as e:
|
|
self.metrics.record_error("stream_processing_error")
|
|
self.logger.error(f"Error processing streams: {e}")
|
|
|
|
return results
|
|
|
|
@staticmethod
|
|
def _get_search_url(search_query: str, page: int = 1) -> str:
|
|
encoded_query = quote(search_query)
|
|
return (
|
|
f"{settings.bt4g_url}/search"
|
|
f"?q={encoded_query}"
|
|
f"&category=movie"
|
|
f"&p={page}"
|
|
)
|
|
|
|
async def parse_first_page(self, html: str) -> Tuple[List[Any], Optional[int]]:
|
|
"""Parse first page and return results and total results count"""
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
|
|
# Find total results count
|
|
total_results = None
|
|
results_text = soup.find("span", {"class": "badge"})
|
|
if results_text:
|
|
try:
|
|
total_text = results_text.get_text()
|
|
total_results = int(total_text)
|
|
except (ValueError, AttributeError):
|
|
self.logger.warning("Could not parse total results count")
|
|
|
|
# Find search results
|
|
results = soup.find_all("div", class_="result-item")
|
|
return results, total_results
|
|
|
|
async def scrape_by_query(
|
|
self,
|
|
processed_unique_data: set[str],
|
|
metadata: MediaFusionMetaData,
|
|
search_query: str,
|
|
catalog_type: str,
|
|
season: Optional[int] = None,
|
|
episode: Optional[int] = None,
|
|
) -> AsyncGenerator[TorrentStreams, None]:
|
|
try:
|
|
# Get first page
|
|
first_page_url = self._get_search_url(search_query, page=1)
|
|
response = await self.make_request(first_page_url, is_expected_to_fail=True)
|
|
|
|
# Parse first page
|
|
first_page_results, total_results = await self.parse_first_page(
|
|
response.text
|
|
)
|
|
if not first_page_results:
|
|
return
|
|
|
|
# Calculate needed pages based on max_process and items per page
|
|
max_process = settings.bt4g_immediate_max_process
|
|
if total_results:
|
|
total_pages = min(
|
|
math.ceil(max_process / self.ITEMS_PER_PAGE),
|
|
math.ceil(total_results / self.ITEMS_PER_PAGE),
|
|
)
|
|
else:
|
|
total_pages = math.ceil(max_process / self.ITEMS_PER_PAGE)
|
|
|
|
self.logger.info(
|
|
f"Found {total_results if total_results else 'unknown'} results "
|
|
f"for {search_query} in BT4G site, processing only {total_pages} pages"
|
|
)
|
|
|
|
# Process first page results
|
|
async for stream in self.process_page_results(
|
|
first_page_results,
|
|
metadata,
|
|
catalog_type,
|
|
season,
|
|
episode,
|
|
processed_unique_data,
|
|
):
|
|
yield stream
|
|
|
|
# Fetch and process additional pages if needed
|
|
if total_pages > 1:
|
|
tasks = []
|
|
for page in range(2, total_pages + 1):
|
|
page_url = self._get_search_url(search_query, page)
|
|
# Fetch all pages concurrently
|
|
tasks.append(self.make_request(page_url, is_expected_to_fail=True))
|
|
|
|
# Process all additional pages
|
|
try:
|
|
responses = await asyncio.gather(*tasks)
|
|
for response in responses:
|
|
soup = BeautifulSoup(response.text, "html.parser")
|
|
results = soup.find_all("div", class_="result-item")
|
|
|
|
async for stream in self.process_page_results(
|
|
results,
|
|
metadata,
|
|
catalog_type,
|
|
season,
|
|
episode,
|
|
processed_unique_data,
|
|
):
|
|
yield stream
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error processing additional pages: {e}")
|
|
|
|
except Exception as e:
|
|
self.metrics.record_error("search_error")
|
|
self.logger.exception(f"Error searching BT4G: {e}")
|
|
|
|
async def fetch_and_process_page(
|
|
self,
|
|
page_url: str,
|
|
metadata: MediaFusionMetaData,
|
|
catalog_type: str,
|
|
season: Optional[int],
|
|
episode: Optional[int],
|
|
processed_unique_data: set[str],
|
|
) -> AsyncGenerator[TorrentStreams, None]:
|
|
"""Fetch and process a single page of results"""
|
|
try:
|
|
response = await self.make_request(page_url, is_expected_to_fail=True)
|
|
|
|
soup = BeautifulSoup(response.text, "html.parser")
|
|
results = soup.find_all("div", class_="result-item")
|
|
|
|
async for stream in self.process_page_results(
|
|
results,
|
|
metadata,
|
|
catalog_type,
|
|
season,
|
|
episode,
|
|
processed_unique_data,
|
|
):
|
|
yield stream
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error fetching page {page_url}: {e}")
|
|
|
|
async def process_page_results(
|
|
self,
|
|
results: List[Any],
|
|
metadata: MediaFusionMetaData,
|
|
catalog_type: str,
|
|
season: Optional[int],
|
|
episode: Optional[int],
|
|
processed_unique_data: set[str],
|
|
) -> AsyncGenerator[TorrentStreams, None]:
|
|
"""Process results from a single page"""
|
|
self.metrics.record_found_items(len(results))
|
|
|
|
circuit_breaker = CircuitBreaker(
|
|
failure_threshold=2, recovery_timeout=10, half_open_attempts=3
|
|
)
|
|
|
|
async for result in batch_process_with_circuit_breaker(
|
|
self.process_search_result,
|
|
results,
|
|
5, # batch_size
|
|
3, # max_concurrent_batches
|
|
circuit_breaker,
|
|
5, # max_retries
|
|
metadata=metadata,
|
|
catalog_type=catalog_type,
|
|
season=season,
|
|
episode=episode,
|
|
processed_unique_data=processed_unique_data,
|
|
):
|
|
if result:
|
|
yield result
|
|
|
|
async def process_search_result(
|
|
self,
|
|
result: Any,
|
|
metadata: MediaFusionMetaData,
|
|
catalog_type: str,
|
|
processed_unique_data: set[str],
|
|
season: Optional[int] = None,
|
|
episode: Optional[int] = None,
|
|
) -> Optional[TorrentStreams]:
|
|
"""Process a single search result"""
|
|
try:
|
|
title_element = result.find("h5")
|
|
if not title_element:
|
|
return None
|
|
|
|
torrent_title = title_element.get_text(strip=True)
|
|
|
|
if is_contain_18_plus_keywords(torrent_title):
|
|
self.metrics.record_skip("Adult content")
|
|
return None
|
|
|
|
parsed_data = PTT.parse_title(torrent_title, True)
|
|
|
|
if not self.validate_title_and_year(
|
|
parsed_data,
|
|
metadata,
|
|
catalog_type,
|
|
torrent_title,
|
|
):
|
|
return None
|
|
|
|
info_elements = result.find("p").find_all("span")
|
|
|
|
created_date = info_elements[2].get_text()
|
|
# example: 'Creation Time:\xa02024-03-23'
|
|
created_date = datetime.strptime(
|
|
created_date.split(":")[1].strip(), "%Y-%m-%d"
|
|
)
|
|
seeders = int(result.find("b", {"id": "seeders"}).get_text())
|
|
|
|
# Drop if seeders are less than 1 and created date is older than 30 days
|
|
if seeders < 1 and created_date < datetime.now() - timedelta(days=30):
|
|
self.metrics.record_skip("Old torrent with no seeders")
|
|
return None
|
|
|
|
total_size = info_elements[4].get_text()
|
|
# example: 'Total Size:4.23GB'
|
|
total_size = convert_size_to_bytes(total_size.split(":")[1].strip())
|
|
|
|
page_url = title_element.find("a", href=True)["href"]
|
|
|
|
if page_url in processed_unique_data:
|
|
self.metrics.record_skip("Duplicate page URL")
|
|
return None
|
|
|
|
# Add page URL to the processed set to avoid duplicates scraping the same page
|
|
processed_unique_data.add(page_url)
|
|
response = await self.make_request(
|
|
settings.bt4g_url + page_url, is_expected_to_fail=True
|
|
)
|
|
soup = BeautifulSoup(response.text, "html.parser")
|
|
|
|
magnet_element = soup.find("a", {"class": "btn-info"})
|
|
if not magnet_element:
|
|
self.metrics.record_skip("Cloudflare protection")
|
|
return None
|
|
|
|
magnet_link = magnet_element["href"]
|
|
info_hash = magnet_link.split("btih:")[1].split("&")[0].lower()
|
|
|
|
file_info_elements = soup.find_all("div", {"class": "card-body"})[
|
|
-1
|
|
].find_all("li")
|
|
file_info = []
|
|
seasons = set()
|
|
episodes = set()
|
|
for index, element in enumerate(file_info_elements):
|
|
if not element.contents or element.contents[0] is None:
|
|
self.logger.warning(f"Skipping empty or invalid element: {element}")
|
|
continue
|
|
|
|
file_name = element.contents[0].strip()
|
|
file_size = convert_size_to_bytes(
|
|
element.find("b", {"class": "cpill"}).get_text(strip=True)
|
|
)
|
|
if (
|
|
not is_video_file(file_name)
|
|
or file_size < settings.min_scraping_video_size
|
|
):
|
|
continue
|
|
file_parsed_data = PTT.parse_title(file_name)
|
|
seasons.update(file_parsed_data.get("seasons", []))
|
|
episodes.update(file_parsed_data.get("episodes", []))
|
|
season_number = (
|
|
file_parsed_data.get("seasons")[0]
|
|
if file_parsed_data.get("seasons")
|
|
else None
|
|
)
|
|
if (
|
|
season_number is None
|
|
and parsed_data.get("seasons")
|
|
and len(parsed_data["seasons"]) == 1
|
|
):
|
|
season_number = parsed_data["seasons"][0]
|
|
episode_number = (
|
|
file_parsed_data.get("episodes")[0]
|
|
if file_parsed_data.get("episodes")
|
|
else None
|
|
)
|
|
if (
|
|
episode_number is None
|
|
and parsed_data.get("episodes")
|
|
and len(parsed_data["episodes"]) == 1
|
|
):
|
|
episode_number = parsed_data["episodes"][0]
|
|
file_info.append(
|
|
{
|
|
"filename": file_name,
|
|
"file_size": file_size,
|
|
"index": index,
|
|
"season_number": season_number,
|
|
"episode_number": episode_number,
|
|
}
|
|
)
|
|
|
|
if not file_info:
|
|
self.metrics.record_skip("No valid video files")
|
|
return None
|
|
|
|
largest_file = max(file_info, key=lambda x: x["file_size"])
|
|
|
|
stream = TorrentStreams(
|
|
id=info_hash,
|
|
meta_id=metadata.id,
|
|
torrent_name=torrent_title,
|
|
filename=largest_file["filename"] if catalog_type == "movie" else None,
|
|
file_index=largest_file["index"] if catalog_type == "movie" else None,
|
|
size=total_size,
|
|
languages=parsed_data["languages"],
|
|
resolution=parsed_data.get("resolution"),
|
|
codec=parsed_data.get("codec"),
|
|
quality=parsed_data.get("quality"),
|
|
audio=parsed_data.get("audio"),
|
|
hdr=parsed_data.get("hdr"),
|
|
source="BT4G",
|
|
catalog=["bt4g_streams"],
|
|
seeders=seeders,
|
|
announce_list=[],
|
|
)
|
|
|
|
if catalog_type == "series":
|
|
if not parsed_data["seasons"]:
|
|
parsed_data["seasons"] = list(seasons)
|
|
if not parsed_data["episodes"]:
|
|
parsed_data["episodes"] = list(episodes)
|
|
if not self._process_series_data(stream, parsed_data, file_info):
|
|
return None
|
|
else:
|
|
# For the Movies, should not have seasons and episodes
|
|
if (
|
|
parsed_data.get("seasons")
|
|
or parsed_data.get("episodes")
|
|
or seasons
|
|
or episodes
|
|
):
|
|
self.metrics.record_skip("Unexpected season/episode info")
|
|
return None
|
|
|
|
self.metrics.record_processed_item()
|
|
self.metrics.record_quality(stream.quality)
|
|
self.metrics.record_source(stream.source)
|
|
|
|
return stream
|
|
|
|
except Exception as e:
|
|
self.metrics.record_error("result_processing_error")
|
|
self.logger.exception(f"Error processing search result: {e}")
|
|
return None
|
|
|
|
def _process_series_data(
|
|
self,
|
|
stream: TorrentStreams,
|
|
parsed_data: dict,
|
|
file_info: List[dict],
|
|
) -> bool:
|
|
"""Process series-specific data and validate season/episode information"""
|
|
if not parsed_data.get("seasons"):
|
|
self.metrics.record_skip("Missing season info")
|
|
return False
|
|
|
|
# Prepare episode data based on detailed file data
|
|
episode_data = [
|
|
EpisodeFile(
|
|
season_number=file.get("season_number"),
|
|
episode_number=file.get("episode_number"),
|
|
filename=file.get("filename"),
|
|
size=file.get("file_size"),
|
|
file_index=file.get("index"),
|
|
)
|
|
for file in file_info
|
|
if file.get("season_number") is not None
|
|
and file.get("episode_number") is not None
|
|
]
|
|
|
|
if not episode_data:
|
|
self.metrics.record_skip("No valid episodes")
|
|
return False
|
|
|
|
stream.episode_files = episode_data
|
|
return True
|