# Define here the models for your spider middleware # # See documentation in: # https://docs.scrapy.org/en/latest/topics/spider-middleware.html import asyncio import random from urllib.parse import urlparse import httpx from scrapy import signals, Request from scrapy.downloadermiddlewares.retry import RetryMiddleware from scrapy.exceptions import IgnoreRequest from scrapy.utils.response import response_status_message from twisted.internet import reactor, defer from db import database class MediafusionScrapySpiderMiddleware: # Not all methods need to be defined. If a method is not defined, # scrapy acts as if the spider middleware does not modify the # passed objects. @classmethod def from_crawler(cls, crawler): # This method is used by Scrapy to create your spiders. s = cls() crawler.signals.connect(s.spider_opened, signal=signals.spider_opened) return s def process_spider_input(self, response, spider): # Called for each response that goes through the spider # middleware and into the spider. # Should return None or raise an exception. return None def process_spider_output(self, response, result, spider): # Called with the results returned from the Spider, after # it has processed the response. # Must return an iterable of Request, or item objects. for i in result: yield i def process_spider_exception(self, response, exception, spider): # Called when a spider or process_spider_input() method # (from other spider middleware) raises an exception. # Should return either None or an iterable of Request or item objects. pass def process_start_requests(self, start_requests, spider): # Called with the start requests of the spider, and works # similarly to the process_spider_output() method, except # that it doesn’t have a response associated. # Must return only requests (not items). for r in start_requests: yield r def spider_opened(self, spider): spider.logger.info("Spider opened: %s" % spider.name) class MediafusionScrapyDownloaderMiddleware: # Not all methods need to be defined. If a method is not defined, # scrapy acts as if the downloader middleware does not modify the # passed objects. @classmethod def from_crawler(cls, crawler): # This method is used by Scrapy to create your spiders. s = cls() crawler.signals.connect(s.spider_opened, signal=signals.spider_opened) return s def process_request(self, request, spider): # Called for each request that goes through the downloader # middleware. # Must either: # - return None: continue processing this request # - or return a Response object # - or return a Request object # - or raise IgnoreRequest: process_exception() methods of # installed downloader middleware will be called return None def process_response(self, request, response, spider): # Called with the response returned from the downloader. # Must either; # - return a Response object # - return a Request object # - or raise IgnoreRequest return response def process_exception(self, request, exception, spider): # Called when a download handler or a process_request() # (from other downloader middleware) raises an exception. # Must either: # - return None: continue processing this exception # - return a Response object: stops process_exception() chain # - return a Request object: stops process_exception() chain pass def spider_opened(self, spider): spider.logger.info("Spider opened: %s" % spider.name) class DatabaseInitializationMiddleware: @classmethod def from_crawler(cls, crawler): # This method is used by Scrapy to create your middleware instance middleware = cls() crawler.signals.connect(middleware.spider_opened, signal=signals.spider_opened) return middleware async def spider_opened(self, spider): # Initialize your database here await database.init() spider.logger.info("Database initialized successfully.") class TooManyRequestsRetryMiddleware(RetryMiddleware): """ Middleware to handle 429 Too Many Requests with a backoff retry mechanism. """ DEFAULT_DELAY = 30 # Default initial delay in seconds. MAX_DELAY = 180 # Max delay between retries. BACKOFF_FACTOR = 3 # Exponential backoff factor. def __init__(self, settings): super().__init__(settings) self.max_retry_times = settings.getint( "RETRY_TIMES", 5 ) # Get max retries from settings. @classmethod def from_crawler(cls, crawler): return cls(crawler.settings) def process_response(self, request, response, spider): """ Handle response with 429 status by retrying the request with an exponential backoff delay. """ if request.meta.get("dont_retry", False): return response # If we get a 429 Too Many Requests response if response.status == 429: retries = request.meta.get("retry_times", 0) + 1 # If max retries exceeded, give up if retries > self.max_retry_times: spider.logger.error( f"Gave up retrying {request.url} after {retries} attempts." ) return response # Calculate the delay with exponential backoff retry_after = response.headers.get("retry-after") try: retry_after = int(retry_after) + random.randint(1, 10) except (ValueError, TypeError): delay = min( self.MAX_DELAY, self.DEFAULT_DELAY * (self.BACKOFF_FACTOR**retries) ) else: delay = min( self.MAX_DELAY, retry_after + random.randint(5, 120) ) # Add some jitter spider.logger.info( f"Retrying {request.url} in {delay} seconds (retry {retries}/{self.max_retry_times})." ) # Update retry meta information request.meta["retry_times"] = retries # Defer the retry with the calculated delay deferred = defer.Deferred() reactor.callLater(delay, deferred.callback, None) def retry_request(_): reason = response_status_message(response.status) return self._retry(request, reason, spider) or response deferred.addCallback(retry_request) return deferred return response class FlaresolverrMiddleware: def __init__(self, flaresolverr_url, cache_duration, max_timeout, max_attempts): self.flaresolverr_url = flaresolverr_url self.cache_duration = cache_duration self.max_timeout = max_timeout self.max_attempts = max_attempts self.solved_domains = {} self.client = httpx.AsyncClient() @classmethod def from_crawler(cls, crawler): return cls( flaresolverr_url=crawler.settings.get( "FLARESOLVERR_URL", "http://localhost:8191/v1" ), cache_duration=crawler.settings.get("FLARESOLVERR_CACHE_DURATION", 3600), max_timeout=crawler.settings.get("FLARESOLVERR_MAX_TIMEOUT", 60000), max_attempts=crawler.settings.get("FLARESOLVERR_MAX_ATTEMPTS", 3), ) async def process_request(self, request, spider): return None async def process_response(self, request, response, spider): if not hasattr(spider, "use_flaresolverr") or not spider.use_flaresolverr: return response if response.status == 403 or ( response.status == 503 and "cloudflare" in response.text.lower() ): return await self._handle_cloudflare(request, spider) return response async def _handle_cloudflare(self, request, spider): domain = urlparse(request.url).netloc current_time = reactor.seconds() if domain in self.solved_domains: last_solved_time, solution = self.solved_domains[domain] if current_time - last_solved_time < self.cache_duration: return self._apply_solution(request, solution) for attempt in range(self.max_attempts): try: timeout = min( self.max_timeout, 30000 * (2**attempt) ) # Exponential backoff flaresolverr_response = await self.client.post( self.flaresolverr_url, headers={"Content-Type": "application/json"}, json={ "cmd": "request.get", "url": request.url, "maxTimeout": timeout, }, timeout=timeout / 1000 + 5, ) if flaresolverr_response.status_code == 200: solution = flaresolverr_response.json() if solution.get("status") == "ok": self.solved_domains[domain] = (current_time, solution) return self._apply_solution(request, solution) spider.logger.error( f"FlareSolverr attempt {attempt + 1} failed: {flaresolverr_response.text}" ) await asyncio.sleep(2**attempt) # Wait before next attempt except httpx.RequestError as e: spider.logger.error( f"FlareSolverr request error on attempt {attempt + 1}: {e}" ) await asyncio.sleep(2**attempt) spider.logger.error( f"Failed to solve Cloudflare challenge for {request.url} after {self.max_attempts} attempts" ) raise IgnoreRequest() def _apply_solution(self, original_request, solution): solution_response = solution.get("solution", {}).get("response", {}) return Request( url=original_request.url, headers=solution_response.get("headers", {}), cookies={ cookie["name"]: cookie["value"] for cookie in solution_response.get("cookies", []) }, dont_filter=True, meta={"flaresolverr_solved": True, **original_request.meta}, ) async def close_spider(self, spider): await self.client.aclose()