From f48418abbcda38f0493164e8b96ce8f8112855cb Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Thu, 2 Apr 2026 16:35:43 +0200 Subject: [PATCH 01/54] docs: adding a new sponsor --- README.md | 10 ++++++++++ docs/README_AR.md | 10 ++++++++++ docs/README_CN.md | 10 ++++++++++ docs/README_DE.md | 10 ++++++++++ docs/README_ES.md | 10 ++++++++++ docs/README_FR.md | 10 ++++++++++ docs/README_JP.md | 10 ++++++++++ docs/README_KR.md | 10 ++++++++++ docs/README_RU.md | 10 ++++++++++ docs/index.md | 3 +++ images/MangoProxy.png | Bin 0 -> 15055 bytes 11 files changed, 93 insertions(+) create mode 100644 images/MangoProxy.png diff --git a/README.md b/README.md index 62bbfd0..5a549f6 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,16 @@ MySpider().start() Proxy-Seller provides reliable proxy infrastructure for web scraping, offering IPv4, IPv6, ISP, Residential, and Mobile proxies with stable performance, broad geo coverage, and flexible plans for business-scale data collection. + + + + + + + + Stable proxies for scraping, automation, and multi-accounting. Clean IPs, fast response, and reliable performance under load. Built for scalable workflows. + + Do you want to show your ad here? Click [here](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/README_AR.md b/docs/README_AR.md index f15f0f1..d394f3d 100644 --- a/docs/README_AR.md +++ b/docs/README_AR.md @@ -169,6 +169,16 @@ MySpider().start() Proxy-Seller يوفر بنية تحتية موثوقة للبروكسي لكشط الويب، بما في ذلك بروكسيات IPv4 وIPv6 وISP والسكنية والمحمولة مع أداء مستقر وتغطية جغرافية واسعة وخطط مرنة لجمع البيانات على نطاق الأعمال. + + + + + + + + بروكسيات مستقرة للكشط والأتمتة وإدارة الحسابات المتعددة. عناوين IP نظيفة، استجابة سريعة، وأداء موثوق تحت الضغط. مصممة لسير العمل القابل للتوسع. + + هل تريد عرض إعلانك هنا؟ انقر [هنا](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/README_CN.md b/docs/README_CN.md index 97f1ec1..bb5f0bc 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -169,6 +169,16 @@ MySpider().start() Proxy-Seller 提供可靠的网页抓取代理基础设施,包括 IPv4、IPv6、ISP、住宅和移动代理,具备稳定性能、广泛的地理覆盖和灵活的企业级数据采集方案。 + + + + + + + + 稳定的代理,适用于数据抓取、自动化和多账号管理。干净的 IP、快速响应、高负载下可靠的性能。专为可扩展的工作流程而构建。 + + 想在这里展示您的广告吗?点击 [这里](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/README_DE.md b/docs/README_DE.md index 04dfbae..9002b43 100644 --- a/docs/README_DE.md +++ b/docs/README_DE.md @@ -169,6 +169,16 @@ MySpider().start() Proxy-Seller bietet zuverlässige Proxy-Infrastruktur für Web Scraping mit IPv4-, IPv6-, ISP-, Residential- und Mobile-Proxys – stabile Leistung, breite geografische Abdeckung und flexible Tarife für die Datenerfassung im Unternehmensmaßstab. + + + + + + + + Stabile Proxys für Scraping, Automatisierung und Multi-Accounting. Saubere IPs, schnelle Reaktionszeiten und zuverlässige Leistung unter Last. Entwickelt für skalierbare Workflows. + + Möchten Sie Ihre Anzeige hier zeigen? Klicken Sie [hier](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/README_ES.md b/docs/README_ES.md index da8c757..f2862c1 100644 --- a/docs/README_ES.md +++ b/docs/README_ES.md @@ -169,6 +169,16 @@ MySpider().start() Proxy-Seller ofrece una infraestructura de proxy fiable para web scraping, con proxies IPv4, IPv6, ISP, residenciales y móviles con rendimiento estable, amplia cobertura geográfica y planes flexibles para la recopilación de datos a escala empresarial. + + + + + + + + Proxies estables para scraping, automatización y multicuentas. IPs limpias, respuesta rápida y rendimiento fiable bajo carga. Diseñado para flujos de trabajo escalables. + + ¿Quieres mostrar tu anuncio aquí? Haz clic [aquí](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/README_FR.md b/docs/README_FR.md index 7103040..6e3479a 100644 --- a/docs/README_FR.md +++ b/docs/README_FR.md @@ -169,6 +169,16 @@ MySpider().start() Proxy-Seller fournit une infrastructure proxy fiable pour le web scraping, avec des proxys IPv4, IPv6, ISP, résidentiels et mobiles offrant des performances stables, une large couverture géographique et des plans flexibles pour la collecte de données à l'échelle entreprise. + + + + + + + + Des proxys stables pour le scraping, l'automatisation et la gestion multi-comptes. Des IPs propres, une réponse rapide et des performances fiables sous charge. Conçu pour des flux de travail évolutifs. + + Vous souhaitez afficher votre publicité ici ? Cliquez [ici](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/README_JP.md b/docs/README_JP.md index 87cb86e..fd604a3 100644 --- a/docs/README_JP.md +++ b/docs/README_JP.md @@ -169,6 +169,16 @@ MySpider().start() Proxy-Seller は Web スクレイピング向けの信頼性の高いプロキシインフラを提供しています。IPv4、IPv6、ISP、レジデンシャル、モバイルプロキシに対応し、安定したパフォーマンス、幅広い地理的カバレッジ、企業規模のデータ収集に柔軟なプランを備えています。 + + + + + + + + 安定したプロキシ。スクレイピング、自動化、マルチアカウント管理に対応。クリーンな IP、高速レスポンス、高負荷時でも信頼性の高いパフォーマンス。スケーラブルなワークフロー向けに設計。 + + ここに広告を表示したいですか?[こちら](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646)をクリック diff --git a/docs/README_KR.md b/docs/README_KR.md index d556959..37e1cb9 100644 --- a/docs/README_KR.md +++ b/docs/README_KR.md @@ -169,6 +169,16 @@ MySpider().start() Proxy-Seller는 웹 스크래핑을 위한 안정적인 프록시 인프라를 제공합니다. IPv4, IPv6, ISP, 주거용 및 모바일 프록시를 지원하며, 안정적인 성능, 광범위한 지역 커버리지, 기업 규모의 데이터 수집을 위한 유연한 요금제를 갖추고 있습니다. + + + + + + + + 안정적인 프록시. 스크래핑, 자동화, 멀티 계정 관리에 적합합니다. 깨끗한 IP, 빠른 응답, 높은 부하에서도 신뢰할 수 있는 성능. 확장 가능한 워크플로우를 위해 설계되었습니다. + + 여기에 광고를 게재하고 싶으신가요? [여기](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646)를 클릭하세요 diff --git a/docs/README_RU.md b/docs/README_RU.md index d80d573..d1331f8 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -172,6 +172,16 @@ MySpider().start() Proxy-Seller предоставляет надёжную прокси-инфраструктуру для веб-скрейпинга: IPv4, IPv6, ISP, резидентные и мобильные прокси со стабильной производительностью, широким географическим покрытием и гибкими тарифами для сбора данных в масштабах бизнеса. + + + + + + + + Стабильные прокси для скрапинга, автоматизации и мультиаккаунтинга. Чистые IP, быстрый отклик и надёжная работа под нагрузкой. Созданы для масштабируемых рабочих процессов. + + Хотите показать здесь свою рекламу? Нажмите [здесь](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/index.md b/docs/index.md index 912af95..7642490 100644 --- a/docs/index.md +++ b/docs/index.md @@ -80,6 +80,9 @@ MySpider().start() + + +

diff --git a/images/MangoProxy.png b/images/MangoProxy.png new file mode 100644 index 0000000000000000000000000000000000000000..9789f0484ffdad4345d69c156bc7dc7772355b7e GIT binary patch literal 15055 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 JE0H%1`X6*ge@*}Z literal 0 HcmV?d00001 From 11f238bb638a6a904eaeebaadb18796f220b5730 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Thu, 2 Apr 2026 16:39:37 +0200 Subject: [PATCH 02/54] docs: fix --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 7642490..01d9921 100644 --- a/docs/index.md +++ b/docs/index.md @@ -81,7 +81,7 @@ MySpider().start() - +

From 6c6aabeb73a872202db5bc295890226859b5eccb Mon Sep 17 00:00:00 2001 From: yetval Date: Thu, 2 Apr 2026 11:57:17 -0400 Subject: [PATCH 03/54] fix: proxy rotation page pool leak --- scrapling/engines/_browsers/_base.py | 6 ++++++ tests/fetchers/test_pages.py | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/scrapling/engines/_browsers/_base.py b/scrapling/engines/_browsers/_base.py index eefb9f3..2d42b08 100644 --- a/scrapling/engines/_browsers/_base.py +++ b/scrapling/engines/_browsers/_base.py @@ -196,11 +196,14 @@ class SyncSession: context_options = self._build_context_with_proxy(proxy) context: BrowserContext = self.browser.new_context(**context_options) + page_info = None try: context = self._initialize_context(self._config, context) page_info = self._get_page(timeout, extra_headers, disable_resources, blocked_domains, context=context) yield page_info finally: + if page_info is not None and page_info in self.page_pool.pages: + self.page_pool.pages.remove(page_info) context.close() else: # Standard mode: use PagePool with persistent context @@ -380,6 +383,7 @@ class AsyncSession: context_options = self._build_context_with_proxy(proxy) context: AsyncBrowserContext = await self.browser.new_context(**context_options) + page_info = None try: context = await self._initialize_context(self._config, context) page_info = await self._get_page( @@ -387,6 +391,8 @@ class AsyncSession: ) yield page_info finally: + if page_info is not None and page_info in self.page_pool.pages: + self.page_pool.pages.remove(page_info) await context.close() else: # Standard mode: use PagePool with persistent context diff --git a/tests/fetchers/test_pages.py b/tests/fetchers/test_pages.py index 69dc4ba..b726c74 100644 --- a/tests/fetchers/test_pages.py +++ b/tests/fetchers/test_pages.py @@ -84,6 +84,15 @@ class TestPagePool: with pytest.raises(RuntimeError): pool.add_page(Mock()) + def test_proxy_rotation_pool_leak(self): + pool = PagePool(max_pages=1) + page_info = pool.add_page(Mock()) + assert pool.pages_count == 1 + pool.pages.remove(page_info) + assert pool.pages_count == 0 + pool.add_page(Mock()) + assert pool.pages_count == 1 + def test_cleanup_error_pages(self): From 1d15349e07d91c34cc0cd7f26076bdf150882fcd Mon Sep 17 00:00:00 2001 From: Abdullah <52079299+AbdullahY36@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:08:33 +0200 Subject: [PATCH 04/54] feat(deps): add protego for robots.txt parsing and fix pyright type error in static.py --- pyproject.toml | 1 + scrapling/engines/static.py | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index bb69194..3aee4f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dependencies = [ "tld>=0.13.2", "w3lib>=2.4.1", "typing_extensions", + "protego>=0.4.0", ] [project.optional-dependencies] diff --git a/scrapling/engines/static.py b/scrapling/engines/static.py index 1f4b09b..a962ccf 100644 --- a/scrapling/engines/static.py +++ b/scrapling/engines/static.py @@ -250,6 +250,7 @@ class _SyncSessionLogic(_ConfigurationLogic): request_args = self._merge_request_args(stealth=stealth, proxy=proxy, **kwargs) try: response = session.request(method, **request_args) + assert response is not None result = ResponseFactory.from_http_request(response, selector_config, meta={"proxy": proxy}) return result except CurlError as e: # pragma: no cover From 0bbe62fc7f7011e28dc3d535d36d32944bd30d9a Mon Sep 17 00:00:00 2001 From: Abdullah <52079299+AbdullahY36@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:08:33 +0200 Subject: [PATCH 05/54] feat(spiders): implement RobotsTxtManager with concurrent fetch deduplication --- scrapling/spiders/robotstxt.py | 169 +++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 scrapling/spiders/robotstxt.py diff --git a/scrapling/spiders/robotstxt.py b/scrapling/spiders/robotstxt.py new file mode 100644 index 0000000..4e8612f --- /dev/null +++ b/scrapling/spiders/robotstxt.py @@ -0,0 +1,169 @@ +from asyncio import Event +from urllib.parse import urlparse + +from protego import Protego + +from scrapling.core._types import Dict, Optional, Callable, Awaitable +from scrapling.core.utils import log + + +class RobotsTxtManager: + """Manages fetching, parsing, and caching of robots.txt files. + + Accepts a fetch callable ``(url: str, sid: str) -> Awaitable[Response]`` + so it stays decoupled from any specific session or transport layer. + + All public methods accept only ``(url, sid)`` — domain and scheme are + derived internally from the URL so callers don't pass redundant data. + + Handles all standard robots.txt directives including: + - User-agent specific rules + - Allow/Disallow directives (including wildcards and $ anchors) + - Crawl-delay directives + + Deduplicates concurrent robots.txt fetches for the same domain — if multiple + requests for the same domain arrive before the first fetch completes, they + all wait for that single fetch instead of triggering redundant requests. + """ + + def __init__(self, fetch_fn: Callable[[str, str], Awaitable]): + self._fetch_fn = fetch_fn + self._cache: Dict[tuple[str, str], Protego] = {} + self._inflight: Dict[tuple[str, str], Event] = {} + + async def _get_parser(self, url: str, sid: str) -> Protego: + parsed = urlparse(url) + domain = parsed.netloc + scheme = parsed.scheme or "https" + cache_key = (domain, sid) + + # Return cached parser if available + if cache_key in self._cache: + return self._cache[cache_key] + + # If a fetch is already in-flight for this domain, wait for it to complete + if cache_key in self._inflight: + await self._inflight[cache_key].wait() + return self._cache[cache_key] + + # Mark fetch as in-flight to deduplicate concurrent requests + event = Event() + self._inflight[cache_key] = event + + try: + robots_url = f"{scheme}://{domain}/robots.txt" + content = "" + try: + response = await self._fetch_fn(robots_url, sid) + if response.status == 200: + content = response.body.decode(response.encoding, errors="replace") + except Exception as e: + log.warning(f"Failed to fetch robots.txt for {domain}: {e}") + + try: + parser = Protego.parse(content) + except Exception as e: + log.warning(f"Failed to parse robots.txt for {domain}: {e}") + parser = Protego.parse("") + + self._cache[cache_key] = parser + finally: + event.set() + del self._inflight[cache_key] + + return parser + + async def can_fetch(self, url: str, sid: str) -> bool: + """Check if a URL can be fetched according to the domain's robots.txt. + + Handles: + - User-agent specific rules (e.g., User-agent: SpinarakBot) + - Wildcard user-agent rules (User-agent: *) + - Allow/Disallow directives with wildcards (e.g., /*.pdf$) + - Allow directives that override Disallow (e.g., Allow: /admin/public-docs/) + + Uses the wildcard user-agent (*) which matches standard robots.txt directives + that apply to all bots. This is the conservative approach — if a URL is + disallowed for all bots, we respect that. + + Args: + url: The full URL to check + sid: Session ID for fetching robots.txt + + Returns: + True if the URL can be fetched, False otherwise + """ + parser = await self._get_parser(url, sid) + return parser.can_fetch(url, "*") + + async def get_crawl_delay(self, url: str, sid: str) -> Optional[float]: + """Get the crawl delay for this crawler. + + Uses the wildcard user-agent (*) to get the general crawl delay + that applies to all bots. + + Args: + url: Any URL on the domain to check + sid: Session ID for fetching robots.txt + + Returns: + The crawl delay in seconds, or None if not specified + """ + parser = await self._get_parser(url, sid) + delay = parser.crawl_delay("*") + return float(delay) if delay is not None else None + + async def get_request_rate(self, url: str, sid: str) -> Optional[tuple[int, int]]: + """Get the request rate for this crawler. + + Uses the wildcard user-agent (*) to get the general request rate + that applies to all bots. + + Args: + url: Any URL on the domain to check + sid: Session ID for fetching robots.txt + + Returns: + A tuple of (requests, seconds) if specified, or None if not specified + """ + parser = await self._get_parser(url, sid) + rate = parser.request_rate("*") + if rate is not None: + return (rate.requests, rate.seconds) + return None + + async def _get_delay_directives(self, url: str, sid: str) -> tuple[Optional[float], Optional[tuple[int, int]]]: + """Return both crawl-delay and request-rate in a single parser lookup. + + Args: + url: Any URL on the domain to check + sid: Session ID for fetching robots.txt + + Returns: + A tuple of (crawl_delay, request_rate) where crawl_delay is in seconds + or None, and request_rate is (requests, seconds) or None. + """ + parser = await self._get_parser(url, sid) + c_delay = parser.crawl_delay("*") + rate = parser.request_rate("*") + return ( + float(c_delay) if c_delay is not None else None, + (rate.requests, rate.seconds) if rate is not None else None, + ) + + def clear_cache(self, domain: Optional[str] = None, sid: Optional[str] = None) -> None: + """Clear the robots.txt cache. + + Args: + domain: If specified, only clear cache for this domain + sid: If specified, only clear cache for this session ID + If both are None, clears the entire cache + """ + if domain is None and sid is None: + self._cache.clear() + else: + keys_to_remove = [ + key for key in self._cache if (domain is None or key[0] == domain) and (sid is None or key[1] == sid) + ] + for key in keys_to_remove: + del self._cache[key] From 5c40c6a8539282fcf6573f82f9152099c41120fa Mon Sep 17 00:00:00 2001 From: Abdullah <52079299+AbdullahY36@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:08:33 +0200 Subject: [PATCH 06/54] feat(spiders): integrate robots.txt compliance into the crawl engine --- scrapling/spiders/engine.py | 84 ++++++++++++++++++++++++++++++++++--- scrapling/spiders/result.py | 2 + scrapling/spiders/spider.py | 3 ++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/scrapling/spiders/engine.py b/scrapling/spiders/engine.py index d77f838..cf1715e 100644 --- a/scrapling/spiders/engine.py +++ b/scrapling/spiders/engine.py @@ -10,6 +10,7 @@ from scrapling.core.utils import log from scrapling.spiders.request import Request from scrapling.spiders.scheduler import Scheduler from scrapling.spiders.session import SessionManager +from scrapling.spiders.robotstxt import RobotsTxtManager from scrapling.spiders.result import CrawlStats, ItemList from scrapling.spiders.checkpoint import CheckpointManager, CheckpointData from scrapling.core._types import Dict, Union, Optional, TYPE_CHECKING, Any, AsyncGenerator @@ -41,8 +42,18 @@ class CrawlerEngine: ) self.stats = CrawlStats() + if self.spider.robots_txt_obey: + + async def _fetch_robots(url: str, sid: str): + return await self.session_manager.fetch(Request(url, sid=sid)) + + self._robots_manager: Optional[RobotsTxtManager] = RobotsTxtManager(_fetch_robots) + else: + self._robots_manager = None + self._global_limiter = CapacityLimiter(spider.concurrent_requests) self._domain_limiters: dict[str, CapacityLimiter] = {} + self._domain_delays: dict[str, float] = {} self._allowed_domains: set[str] = spider.allowed_domains or set() self._active_tasks: int = 0 @@ -68,13 +79,58 @@ class CrawlerEngine: return True return False + async def _get_domain_delay(self, request: Request) -> float: + """Resolve the effective download delay for a domain. + + Takes the max of the spider's configured delay and any robots.txt + directives (Crawl-delay / Request-rate). Result is cached per domain. + Also pre-creates a per-domain concurrency limiter of 1 when robots.txt + enforces any delay, before the caller acquires it via _rate_limiter(). + """ + robots_manager = self._robots_manager + if robots_manager is None: + return self.spider.download_delay + + domain = request.domain + + # Return cached delay if available + if domain in self._domain_delays: + return self._domain_delays[domain] + + # Fetch both robots.txt directives in a single parser lookup + c_delay, r_rate = await robots_manager._get_delay_directives(request.url, request.sid) + + delay = self.spider.download_delay + robots_enforced_delay = False + + if r_rate: + req_count, period = r_rate + if req_count > 0: + delay = max(delay, period / req_count) + robots_enforced_delay = True + + if c_delay is not None: + delay = max(delay, c_delay) + robots_enforced_delay = True + + self._domain_delays[domain] = delay + + # Enforce 1 concurrent request for this domain when robots.txt adds a delay + if robots_enforced_delay and delay > 0 and domain not in self._domain_limiters: + if self.spider.concurrent_requests_per_domain: + log.warning( + f"robots.txt for {domain} enforces a delay, overriding" + f" concurrent_requests_per_domain={self.spider.concurrent_requests_per_domain} with 1" + ) + self._domain_limiters[domain] = CapacityLimiter(1) + + return delay + def _rate_limiter(self, domain: str) -> CapacityLimiter: """Get or create a per-domain concurrency limiter if enabled, otherwise use the global limiter.""" if self.spider.concurrent_requests_per_domain: - if domain not in self._domain_limiters: - self._domain_limiters[domain] = CapacityLimiter(self.spider.concurrent_requests_per_domain) - return self._domain_limiters[domain] - return self._global_limiter + self._domain_limiters.setdefault(domain, CapacityLimiter(self.spider.concurrent_requests_per_domain)) + return self._domain_limiters.get(domain, self._global_limiter) def _normalize_request(self, request: Request) -> None: """Normalize request fields before enqueueing. @@ -87,9 +143,21 @@ class CrawlerEngine: async def _process_request(self, request: Request) -> None: """Download and process a single request.""" + if self._robots_manager: + can_fetch = await self._robots_manager.can_fetch(request.url, request.sid) + if not can_fetch: + self.stats.robots_disallowed_count += 1 + log.debug(f"Request disallowed by robots.txt: {request.url}") + return + # Must be called before _rate_limiter: may create CapacityLimiter(1) in _domain_limiters + # when robots.txt enforces a delay, which _rate_limiter then picks up. + delay = await self._get_domain_delay(request) + else: + delay = self.spider.download_delay + async with self._rate_limiter(request.domain): - if self.spider.download_delay: - await anyio.sleep(self.spider.download_delay) + if delay: + await anyio.sleep(delay) if request._session_kwargs.get("proxy"): self.stats.proxies.append(request._session_kwargs["proxy"]) @@ -227,15 +295,19 @@ class CrawlerEngine: self._pause_requested = False self._force_stop = False self.stats = CrawlStats(start_time=anyio.current_time()) + self._domain_limiters.clear() + self._domain_delays.clear() # Check for existing checkpoint resuming = (await self._restore_from_checkpoint()) if self._checkpoint_system_enabled else False self._last_checkpoint_time = anyio.current_time() async with self.session_manager: + # Set stats from spider configuration self.stats.concurrent_requests = self.spider.concurrent_requests self.stats.concurrent_requests_per_domain = self.spider.concurrent_requests_per_domain self.stats.download_delay = self.spider.download_delay + await self.spider.on_start(resuming=resuming) try: diff --git a/scrapling/spiders/result.py b/scrapling/spiders/result.py index 08a7658..b374152 100644 --- a/scrapling/spiders/result.py +++ b/scrapling/spiders/result.py @@ -47,6 +47,7 @@ class CrawlStats: concurrent_requests_per_domain: int = 0 failed_requests_count: int = 0 offsite_requests_count: int = 0 + robots_disallowed_count: int = 0 response_bytes: int = 0 items_scraped: int = 0 items_dropped: int = 0 @@ -95,6 +96,7 @@ class CrawlStats: "sessions_requests_count": self.sessions_requests_count, "failed_requests_count": self.failed_requests_count, "offsite_requests_count": self.offsite_requests_count, + "robots_disallowed_count": self.robots_disallowed_count, "blocked_requests_count": self.blocked_requests_count, "response_status_count": self.response_status_count, "response_bytes": self.response_bytes, diff --git a/scrapling/spiders/spider.py b/scrapling/spiders/spider.py index 4f38912..6aaa24f 100644 --- a/scrapling/spiders/spider.py +++ b/scrapling/spiders/spider.py @@ -72,6 +72,9 @@ class Spider(ABC): start_urls: list[str] = [] allowed_domains: Set[str] = set() + # Robots.txt compliance + robots_txt_obey: bool = False + # Concurrency settings concurrent_requests: int = 4 concurrent_requests_per_domain: int = 0 From 132f33c84611c10d8c2fb2082fb63266bf1cc809 Mon Sep 17 00:00:00 2001 From: Abdullah <52079299+AbdullahY36@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:08:34 +0200 Subject: [PATCH 07/54] test(spiders): add comprehensive test suite for robots.txt compliance --- tests/spiders/test_engine.py | 2 + tests/spiders/test_robotstxt.py | 615 ++++++++++++++++++++++++++++++++ 2 files changed, 617 insertions(+) create mode 100644 tests/spiders/test_robotstxt.py diff --git a/tests/spiders/test_engine.py b/tests/spiders/test_engine.py index b7bfd0f..e362036 100644 --- a/tests/spiders/test_engine.py +++ b/tests/spiders/test_engine.py @@ -83,6 +83,7 @@ class MockSpider: is_blocked_fn=None, on_scraped_item_fn=None, retry_blocked_request_fn=None, + robots_txt_obey: bool = False, ): self.concurrent_requests = concurrent_requests self.concurrent_requests_per_domain = concurrent_requests_per_domain @@ -93,6 +94,7 @@ class MockSpider: self.fp_include_headers = fp_include_headers self.fp_keep_fragments = fp_keep_fragments self.name = "test_spider" + self.robots_txt_obey = robots_txt_obey # Tracking lists self.on_start_calls: list[dict] = [] diff --git a/tests/spiders/test_robotstxt.py b/tests/spiders/test_robotstxt.py new file mode 100644 index 0000000..5a447c0 --- /dev/null +++ b/tests/spiders/test_robotstxt.py @@ -0,0 +1,615 @@ +"""Tests for RobotsTxtManager.""" + +import asyncio + +import pytest + +from scrapling.spiders.robotstxt import RobotsTxtManager +from scrapling.core._types import List, Optional + + +# --------------------------------------------------------------------------- +# Fixtures and helpers +# --------------------------------------------------------------------------- + + +class MockResponse: + """Minimal response stub matching the shape _get_parser expects.""" + + def __init__(self, status: int = 200, body: bytes = b"", encoding: str = "utf-8"): + self.status = status + self.body = body + self.encoding = encoding + + +def make_fetch_fn(status: int = 200, content: str = "", encoding: str = "utf-8"): + """Return an async fetch callable that returns a fixed response. + + Attaches a `.calls` list so tests can assert how many times it was invoked + and with which arguments. + """ + calls: List[tuple] = [] + + async def _fetch(url: str, sid: str) -> MockResponse: + calls.append((url, sid)) + return MockResponse(status=status, body=content.encode(encoding), encoding=encoding) + + _fetch.calls = calls # type: ignore[attr-defined] + return _fetch + + +# --------------------------------------------------------------------------- +# Shared robots.txt fixtures +# --------------------------------------------------------------------------- + +ROBOTS_BASIC = """\ +User-agent: * +Disallow: /admin/ +Crawl-delay: 2 +""" + +ROBOTS_WITH_RATE = """\ +User-agent: * +Request-rate: 1/10 +Disallow: /private/ +""" + +ROBOTS_WITH_SITEMAP = """\ +User-agent: * +Disallow: + +Sitemap: https://example.com/sitemap.xml +Sitemap: https://example.com/sitemap2.xml +""" + +ROBOTS_ALLOW_OVERRIDE = """\ +User-agent: * +Disallow: /secret/ +Allow: /secret/public.html +""" + +ROBOTS_DISALLOW_ALL = """\ +User-agent: * +Disallow: / +""" + + +# --------------------------------------------------------------------------- +# Tests: can_fetch +# --------------------------------------------------------------------------- + + +class TestCanFetch: + @pytest.mark.asyncio + async def test_allowed_url_returns_true(self): + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_BASIC)) + + assert await mgr.can_fetch("https://example.com/products", "s1") is True + + @pytest.mark.asyncio + async def test_disallowed_url_returns_false(self): + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_BASIC)) + + assert await mgr.can_fetch("https://example.com/admin/", "s1") is False + + @pytest.mark.asyncio + async def test_disallowed_subpath_returns_false(self): + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_BASIC)) + + assert await mgr.can_fetch("https://example.com/admin/users", "s1") is False + + @pytest.mark.asyncio + async def test_root_url_is_allowed(self): + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_BASIC)) + + assert await mgr.can_fetch("https://example.com/", "s1") is True + + @pytest.mark.asyncio + async def test_allow_directive_overrides_disallow(self): + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_ALLOW_OVERRIDE)) + + assert await mgr.can_fetch("https://example.com/secret/public.html", "s1") is True + assert await mgr.can_fetch("https://example.com/secret/private.html", "s1") is False + + @pytest.mark.asyncio + async def test_disallow_all_blocks_every_path(self): + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_DISALLOW_ALL)) + + assert await mgr.can_fetch("https://example.com/", "s1") is False + assert await mgr.can_fetch("https://example.com/page", "s1") is False + assert await mgr.can_fetch("https://example.com/a/b/c", "s1") is False + + @pytest.mark.asyncio + async def test_empty_robots_allows_everything(self): + mgr = RobotsTxtManager(make_fetch_fn(content="")) + + assert await mgr.can_fetch("https://example.com/anything", "s1") is True + assert await mgr.can_fetch("https://example.com/admin/secret", "s1") is True + + @pytest.mark.asyncio + async def test_non_200_response_allows_everything(self): + for status in [403, 404, 500, 503]: + mgr = RobotsTxtManager(make_fetch_fn(status=status)) + result = await mgr.can_fetch("https://example.com/page", "s1") + assert result is True, f"Expected True for HTTP {status}" + + @pytest.mark.asyncio + async def test_fetch_error_allows_everything(self): + async def failing_fetch(url: str, sid: str) -> MockResponse: + raise ConnectionError("network failure") + + mgr = RobotsTxtManager(failing_fetch) + + assert await mgr.can_fetch("https://example.com/page", "s1") is True + + @pytest.mark.asyncio + async def test_wildcard_path_pattern(self): + content = "User-agent: *\nDisallow: /*.pdf$" + mgr = RobotsTxtManager(make_fetch_fn(content=content)) + + assert await mgr.can_fetch("https://example.com/report.pdf", "s1") is False + assert await mgr.can_fetch("https://example.com/report.html", "s1") is True + + @pytest.mark.asyncio + async def test_returns_bool(self): + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_BASIC)) + result = await mgr.can_fetch("https://example.com/", "s1") + assert isinstance(result, bool) + + +# --------------------------------------------------------------------------- +# Tests: get_crawl_delay +# --------------------------------------------------------------------------- + + +class TestGetCrawlDelay: + @pytest.mark.asyncio + async def test_returns_float_when_set(self): + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_BASIC)) + + delay = await mgr.get_crawl_delay("https://example.com/", "s1") + + assert delay == 2.0 + assert isinstance(delay, float) + + @pytest.mark.asyncio + async def test_returns_none_when_not_set(self): + content = "User-agent: *\nDisallow: /admin/" + mgr = RobotsTxtManager(make_fetch_fn(content=content)) + + assert await mgr.get_crawl_delay("https://example.com/", "s1") is None + + @pytest.mark.asyncio + async def test_returns_none_for_empty_robots(self): + mgr = RobotsTxtManager(make_fetch_fn(content="")) + + assert await mgr.get_crawl_delay("https://example.com/", "s1") is None + + @pytest.mark.asyncio + async def test_returns_none_on_fetch_error(self): + async def failing_fetch(url: str, sid: str) -> MockResponse: + raise ConnectionError("network failure") + + mgr = RobotsTxtManager(failing_fetch) + + assert await mgr.get_crawl_delay("https://example.com/", "s1") is None + + @pytest.mark.asyncio + async def test_returns_none_for_non_200_response(self): + mgr = RobotsTxtManager(make_fetch_fn(status=404)) + + assert await mgr.get_crawl_delay("https://example.com/", "s1") is None + + @pytest.mark.asyncio + async def test_fractional_delay(self): + content = "User-agent: *\nCrawl-delay: 0.5" + mgr = RobotsTxtManager(make_fetch_fn(content=content)) + + delay = await mgr.get_crawl_delay("https://example.com/", "s1") + + assert delay == 0.5 + + @pytest.mark.asyncio + async def test_url_path_does_not_affect_result(self): + """Any URL on the same domain should return the same delay.""" + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_BASIC)) + + d1 = await mgr.get_crawl_delay("https://example.com/", "s1") + d2 = await mgr.get_crawl_delay("https://example.com/deep/path/page.html", "s1") + + assert d1 == d2 + + +# --------------------------------------------------------------------------- +# Tests: get_request_rate +# --------------------------------------------------------------------------- + + +class TestGetRequestRate: + @pytest.mark.asyncio + async def test_returns_tuple_when_set(self): + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_WITH_RATE)) + + rate = await mgr.get_request_rate("https://example.com/", "s1") + + assert rate is not None + assert isinstance(rate, tuple) + assert len(rate) == 2 + + @pytest.mark.asyncio + async def test_tuple_contains_integers(self): + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_WITH_RATE)) + + rate = await mgr.get_request_rate("https://example.com/", "s1") + + assert rate is not None + requests, seconds = rate + assert isinstance(requests, int) + assert isinstance(seconds, int) + + @pytest.mark.asyncio + async def test_returns_none_when_not_set(self): + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_BASIC)) + + assert await mgr.get_request_rate("https://example.com/", "s1") is None + + @pytest.mark.asyncio + async def test_returns_none_for_empty_robots(self): + mgr = RobotsTxtManager(make_fetch_fn(content="")) + + assert await mgr.get_request_rate("https://example.com/", "s1") is None + + @pytest.mark.asyncio + async def test_returns_none_on_fetch_error(self): + async def failing_fetch(url: str, sid: str) -> MockResponse: + raise ConnectionError("network failure") + + mgr = RobotsTxtManager(failing_fetch) + + assert await mgr.get_request_rate("https://example.com/", "s1") is None + + @pytest.mark.asyncio + async def test_returns_none_for_non_200_response(self): + mgr = RobotsTxtManager(make_fetch_fn(status=404)) + + assert await mgr.get_request_rate("https://example.com/", "s1") is None + + +# --------------------------------------------------------------------------- +# Tests: get_sitemaps +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Tests: caching behaviour +# --------------------------------------------------------------------------- + + +class TestCachingBehaviour: + @pytest.mark.asyncio + async def test_second_call_same_domain_uses_cache(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://example.com/page1", "s1") + await mgr.can_fetch("https://example.com/page2", "s1") + + assert len(fetch_fn.calls) == 1 + + @pytest.mark.asyncio + async def test_all_methods_share_cache(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://example.com/", "s1") + await mgr.get_crawl_delay("https://example.com/", "s1") + await mgr.get_request_rate("https://example.com/", "s1") + + assert len(fetch_fn.calls) == 1 + + @pytest.mark.asyncio + async def test_different_sids_use_separate_cache_entries(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://example.com/", "s1") + await mgr.can_fetch("https://example.com/", "s2") + + assert len(fetch_fn.calls) == 2 + + @pytest.mark.asyncio + async def test_different_domains_use_separate_cache_entries(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://example.com/", "s1") + await mgr.can_fetch("https://other.com/", "s1") + + assert len(fetch_fn.calls) == 2 + + @pytest.mark.asyncio + async def test_cache_keyed_by_domain_not_path(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://example.com/a/b/c", "s1") + await mgr.can_fetch("https://example.com/x/y/z", "s1") + await mgr.can_fetch("https://example.com/admin/", "s1") + + assert len(fetch_fn.calls) == 1 + + @pytest.mark.asyncio + async def test_sid_is_passed_to_fetch_fn(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://example.com/", "my_session") + + _, received_sid = fetch_fn.calls[0] + assert received_sid == "my_session" + + +# --------------------------------------------------------------------------- +# Tests: robots.txt URL construction +# --------------------------------------------------------------------------- + + +class TestRobotsTxtUrlConstruction: + @pytest.mark.asyncio + async def test_http_scheme_preserved(self): + fetch_fn = make_fetch_fn(content="") + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("http://example.com/page", "s1") + + fetched_url, _ = fetch_fn.calls[0] + assert fetched_url == "http://example.com/robots.txt" + + @pytest.mark.asyncio + async def test_https_scheme_preserved(self): + fetch_fn = make_fetch_fn(content="") + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://example.com/page", "s1") + + fetched_url, _ = fetch_fn.calls[0] + assert fetched_url == "https://example.com/robots.txt" + + @pytest.mark.asyncio + async def test_fetched_at_domain_root_regardless_of_request_path(self): + fetch_fn = make_fetch_fn(content="") + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://example.com/deep/nested/path/page.html", "s1") + + fetched_url, _ = fetch_fn.calls[0] + assert fetched_url == "https://example.com/robots.txt" + + @pytest.mark.asyncio + async def test_port_included_in_url(self): + fetch_fn = make_fetch_fn(content="") + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("http://example.com:8080/page", "s1") + + fetched_url, _ = fetch_fn.calls[0] + assert fetched_url == "http://example.com:8080/robots.txt" + + @pytest.mark.asyncio + async def test_different_ports_treated_as_different_domains(self): + fetch_fn = make_fetch_fn(content="") + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("http://example.com:8000/page", "s1") + await mgr.can_fetch("http://example.com:9000/page", "s1") + + assert len(fetch_fn.calls) == 2 + urls = [call[0] for call in fetch_fn.calls] + assert "http://example.com:8000/robots.txt" in urls + assert "http://example.com:9000/robots.txt" in urls + + +# --------------------------------------------------------------------------- +# Tests: encoding +# --------------------------------------------------------------------------- + + +class TestEncoding: + @pytest.mark.asyncio + async def test_non_utf8_body_decoded_with_response_encoding(self): + content = "User-agent: *\nDisallow: /admin/\nCrawl-delay: 3" + body = content.encode("latin-1") + + async def fetch_fn(url: str, sid: str) -> MockResponse: + return MockResponse(status=200, body=body, encoding="latin-1") + + mgr = RobotsTxtManager(fetch_fn) + delay = await mgr.get_crawl_delay("https://example.com/", "s1") + + assert delay == 3.0 + + @pytest.mark.asyncio + async def test_bytes_body_decoded_correctly(self): + content = "User-agent: *\nDisallow: /private/" + body = content.encode("utf-8") + + async def fetch_fn(url: str, sid: str) -> MockResponse: + return MockResponse(status=200, body=body, encoding="utf-8") + + mgr = RobotsTxtManager(fetch_fn) + + assert await mgr.can_fetch("https://example.com/private/", "s1") is False + assert await mgr.can_fetch("https://example.com/public/", "s1") is True + + +# --------------------------------------------------------------------------- +# Tests: clear_cache +# --------------------------------------------------------------------------- + + +class TestClearCache: + @pytest.mark.asyncio + async def test_clear_all_forces_refetch(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://example.com/", "s1") + mgr.clear_cache() + await mgr.can_fetch("https://example.com/", "s1") + + assert len(fetch_fn.calls) == 2 + + @pytest.mark.asyncio + async def test_clear_by_domain_only_invalidates_that_domain(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://example.com/", "s1") + await mgr.can_fetch("https://other.com/", "s1") + assert len(fetch_fn.calls) == 2 + + mgr.clear_cache(domain="example.com") + + await mgr.can_fetch("https://example.com/", "s1") # refetched + await mgr.can_fetch("https://other.com/", "s1") # still cached + + assert len(fetch_fn.calls) == 3 + + @pytest.mark.asyncio + async def test_clear_by_sid_only_invalidates_that_sid(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://example.com/", "s1") + await mgr.can_fetch("https://example.com/", "s2") + assert len(fetch_fn.calls) == 2 + + mgr.clear_cache(sid="s1") + + await mgr.can_fetch("https://example.com/", "s1") # refetched + await mgr.can_fetch("https://example.com/", "s2") # still cached + + assert len(fetch_fn.calls) == 3 + + @pytest.mark.asyncio + async def test_clear_by_domain_and_sid_targets_exact_entry(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://example.com/", "s1") + await mgr.can_fetch("https://example.com/", "s2") + assert len(fetch_fn.calls) == 2 + + mgr.clear_cache(domain="example.com", sid="s1") + + await mgr.can_fetch("https://example.com/", "s1") # refetched + await mgr.can_fetch("https://example.com/", "s2") # still cached + + assert len(fetch_fn.calls) == 3 + + def test_clear_nonexistent_domain_does_not_raise(self): + mgr = RobotsTxtManager(make_fetch_fn()) + mgr.clear_cache(domain="nevervisited.com") # should not raise + + def test_clear_empty_cache_does_not_raise(self): + mgr = RobotsTxtManager(make_fetch_fn()) + mgr.clear_cache() # should not raise + + @pytest.mark.asyncio + async def test_clear_all_empties_cache_completely(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.can_fetch("https://a.com/", "s1") + await mgr.can_fetch("https://b.com/", "s1") + await mgr.can_fetch("https://c.com/", "s1") + assert len(fetch_fn.calls) == 3 + + mgr.clear_cache() + + await mgr.can_fetch("https://a.com/", "s1") + await mgr.can_fetch("https://b.com/", "s1") + await mgr.can_fetch("https://c.com/", "s1") + + assert len(fetch_fn.calls) == 6 + + +# --------------------------------------------------------------------------- +# Tests: concurrent access (double-checked locking) +# --------------------------------------------------------------------------- + + +class TestConcurrency: + @pytest.mark.asyncio + async def test_concurrent_calls_same_domain_same_sid_deduplicated(self): + """Multiple concurrent tasks for the same domain+sid trigger only one robots.txt fetch.""" + fetch_count = 0 + + async def slow_fetch(url: str, sid: str) -> MockResponse: + nonlocal fetch_count + fetch_count += 1 + await asyncio.sleep(0.02) # simulate network latency + return MockResponse(status=200, body=ROBOTS_BASIC.encode(), encoding="utf-8") + + mgr = RobotsTxtManager(slow_fetch) + + results = await asyncio.gather(*[ + mgr.can_fetch(f"https://example.com/page{i}", "s1") + for i in range(8) + ]) + + # Concurrent calls for the same domain+sid are deduplicated to a single fetch + assert fetch_count == 1 + assert all(isinstance(r, bool) for r in results) + + @pytest.mark.asyncio + async def test_concurrent_calls_different_domains_fetch_independently(self): + fetch_count = 0 + + async def slow_fetch(url: str, sid: str) -> MockResponse: + nonlocal fetch_count + fetch_count += 1 + await asyncio.sleep(0.01) + return MockResponse(status=200, body=b"", encoding="utf-8") + + mgr = RobotsTxtManager(slow_fetch) + + await asyncio.gather( + mgr.can_fetch("https://alpha.com/", "s1"), + mgr.can_fetch("https://beta.com/", "s1"), + mgr.can_fetch("https://gamma.com/", "s1"), + ) + + assert fetch_count == 3 + + @pytest.mark.asyncio + async def test_concurrent_calls_consistent_results(self): + """All concurrent callers should see the same allow/disallow result.""" + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_BASIC)) + + results = await asyncio.gather(*[ + mgr.can_fetch("https://example.com/admin/", "s1") + for _ in range(6) + ]) + + assert all(r is False for r in results) + + @pytest.mark.asyncio + async def test_different_sids_concurrent_fetch_independently(self): + fetch_count = 0 + + async def slow_fetch(url: str, sid: str) -> MockResponse: + nonlocal fetch_count + fetch_count += 1 + await asyncio.sleep(0.01) + return MockResponse(status=200, body=b"", encoding="utf-8") + + mgr = RobotsTxtManager(slow_fetch) + + await asyncio.gather( + mgr.can_fetch("https://example.com/", "s1"), + mgr.can_fetch("https://example.com/", "s2"), + mgr.can_fetch("https://example.com/", "s3"), + ) + + assert fetch_count == 3 From 9dcfe47da77cbe48a633ee91888be56cee0f1b45 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Fri, 3 Apr 2026 17:18:15 +0200 Subject: [PATCH 08/54] build: pump up deps --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bb69194..a805818 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ classifiers = [ dependencies = [ "lxml>=6.0.2", "cssselect>=1.4.0", - "orjson>=3.11.7", + "orjson>=3.11.8", "tld>=0.13.2", "w3lib>=2.4.1", "typing_extensions", @@ -72,11 +72,11 @@ dependencies = [ [project.optional-dependencies] fetchers = [ "click>=8.3.0", - "curl_cffi>=0.14.0", + "curl_cffi>=0.15.0", "playwright==1.58.0", "patchright==1.58.2", "browserforge>=1.2.4", - "apify-fingerprint-datapoints>=0.11.0", + "apify-fingerprint-datapoints>=0.12.0", "msgspec>=0.20.0", "anyio>=4.12.1" ] From eaf7ec2cc06f07a50ae711c3b3f60e24ca0f1fc6 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Fri, 3 Apr 2026 17:26:52 +0200 Subject: [PATCH 09/54] fix(requests): handle missing type hint returned by curl-cffi --- scrapling/engines/static.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scrapling/engines/static.py b/scrapling/engines/static.py index 1f4b09b..a962ccf 100644 --- a/scrapling/engines/static.py +++ b/scrapling/engines/static.py @@ -250,6 +250,7 @@ class _SyncSessionLogic(_ConfigurationLogic): request_args = self._merge_request_args(stealth=stealth, proxy=proxy, **kwargs) try: response = session.request(method, **request_args) + assert response is not None result = ResponseFactory.from_http_request(response, selector_config, meta={"proxy": proxy}) return result except CurlError as e: # pragma: no cover From 07129ce4b1645f73df25943eb88c215c05e1f14d Mon Sep 17 00:00:00 2001 From: Abdullah <52079299+AbdullahY36@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:51:00 +0200 Subject: [PATCH 10/54] feat(deps): move protego to fetchers optional dependency protego is only used by the spider framework for robots.txt compliance. Moving it from core dependencies to the optional 'fetchers' group reduces the dependency footprint for users who don't need the spider framework. for pyproject.toml file --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 41fb14c..4333d7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,8 +66,7 @@ dependencies = [ "orjson>=3.11.8", "tld>=0.13.2", "w3lib>=2.4.1", - "typing_extensions", - "protego>=0.4.0", + "typing_extensions" ] [project.optional-dependencies] @@ -79,7 +78,8 @@ fetchers = [ "browserforge>=1.2.4", "apify-fingerprint-datapoints>=0.12.0", "msgspec>=0.20.0", - "anyio>=4.12.1" + "anyio>=4.12.1", + "protego>=0.4.0", ] ai = [ "mcp>=1.26.0", From e2b293f41c289a496195b547df89482a23a44739 Mon Sep 17 00:00:00 2001 From: Abdullah <52079299+AbdullahY36@users.noreply.github.com> Date: Sat, 4 Apr 2026 03:00:15 +0200 Subject: [PATCH 11/54] refactor(spiders): simplify robots.txt cache to domain-only key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit robots.txt is a domain-level document and does not vary by session. Keying the cache by (domain, sid) was both wasteful and incorrect — it caused redundant fetches when the same domain was accessed by different sessions. - Cache is now keyed by domain string only; all sessions share one entry - Removed asyncio.Event inflight-deduplication mechanism (superseded by the prefetch approach added in the next commit) - clear_cache() loses the `sid` parameter (breaking change); clearing a domain now evicts the single shared entry for all sessions - Updated tests to reflect shared-cache semantics Files: scrapling/spiders/robotstxt.py, tests/spiders/test_robotstxt.py --- scrapling/spiders/robotstxt.py | 108 +++++++++++++------------- tests/spiders/test_robotstxt.py | 131 ++++++++++++++++++-------------- 2 files changed, 128 insertions(+), 111 deletions(-) diff --git a/scrapling/spiders/robotstxt.py b/scrapling/spiders/robotstxt.py index 4e8612f..c64c66e 100644 --- a/scrapling/spiders/robotstxt.py +++ b/scrapling/spiders/robotstxt.py @@ -1,6 +1,6 @@ -from asyncio import Event from urllib.parse import urlparse +from anyio import create_task_group from protego import Protego from scrapling.core._types import Dict, Optional, Callable, Awaitable @@ -21,56 +21,40 @@ class RobotsTxtManager: - Allow/Disallow directives (including wildcards and $ anchors) - Crawl-delay directives - Deduplicates concurrent robots.txt fetches for the same domain — if multiple - requests for the same domain arrive before the first fetch completes, they - all wait for that single fetch instead of triggering redundant requests. + robots.txt is a domain-level document and does not vary by session, so the + cache is keyed by domain only. The ``sid`` parameter on public methods + controls which session is used for the initial fetch if the domain is not + yet cached, but all sessions share the same parsed result afterwards. """ def __init__(self, fetch_fn: Callable[[str, str], Awaitable]): self._fetch_fn = fetch_fn - self._cache: Dict[tuple[str, str], Protego] = {} - self._inflight: Dict[tuple[str, str], Event] = {} + self._cache: Dict[str, Protego] = {} async def _get_parser(self, url: str, sid: str) -> Protego: parsed = urlparse(url) domain = parsed.netloc + + if domain in self._cache: + return self._cache[domain] + scheme = parsed.scheme or "https" - cache_key = (domain, sid) - - # Return cached parser if available - if cache_key in self._cache: - return self._cache[cache_key] - - # If a fetch is already in-flight for this domain, wait for it to complete - if cache_key in self._inflight: - await self._inflight[cache_key].wait() - return self._cache[cache_key] - - # Mark fetch as in-flight to deduplicate concurrent requests - event = Event() - self._inflight[cache_key] = event + robots_url = f"{scheme}://{domain}/robots.txt" + content = "" + try: + response = await self._fetch_fn(robots_url, sid) + if response.status == 200: + content = response.body.decode(response.encoding, errors="replace") + except Exception as e: + log.warning(f"Failed to fetch robots.txt for {domain}: {e}") try: - robots_url = f"{scheme}://{domain}/robots.txt" - content = "" - try: - response = await self._fetch_fn(robots_url, sid) - if response.status == 200: - content = response.body.decode(response.encoding, errors="replace") - except Exception as e: - log.warning(f"Failed to fetch robots.txt for {domain}: {e}") - - try: - parser = Protego.parse(content) - except Exception as e: - log.warning(f"Failed to parse robots.txt for {domain}: {e}") - parser = Protego.parse("") - - self._cache[cache_key] = parser - finally: - event.set() - del self._inflight[cache_key] + parser = Protego.parse(content) + except Exception as e: + log.warning(f"Failed to parse robots.txt for {domain}: {e}") + parser = Protego.parse("") + self._cache[domain] = parser return parser async def can_fetch(self, url: str, sid: str) -> bool: @@ -88,7 +72,7 @@ class RobotsTxtManager: Args: url: The full URL to check - sid: Session ID for fetching robots.txt + sid: Session ID for fetching robots.txt if not yet cached Returns: True if the URL can be fetched, False otherwise @@ -104,7 +88,7 @@ class RobotsTxtManager: Args: url: Any URL on the domain to check - sid: Session ID for fetching robots.txt + sid: Session ID for fetching robots.txt if not yet cached Returns: The crawl delay in seconds, or None if not specified @@ -121,7 +105,7 @@ class RobotsTxtManager: Args: url: Any URL on the domain to check - sid: Session ID for fetching robots.txt + sid: Session ID for fetching robots.txt if not yet cached Returns: A tuple of (requests, seconds) if specified, or None if not specified @@ -137,7 +121,7 @@ class RobotsTxtManager: Args: url: Any URL on the domain to check - sid: Session ID for fetching robots.txt + sid: Session ID for fetching robots.txt if not yet cached Returns: A tuple of (crawl_delay, request_rate) where crawl_delay is in seconds @@ -151,19 +135,35 @@ class RobotsTxtManager: (rate.requests, rate.seconds) if rate is not None else None, ) - def clear_cache(self, domain: Optional[str] = None, sid: Optional[str] = None) -> None: - """Clear the robots.txt cache. + async def prefetch(self, urls: list[str], sid: str) -> None: + """Pre-warm the robots.txt cache for a list of seed URLs concurrently. + + Callers are responsible for deduplicating URLs by domain before calling + this method — passing multiple URLs for the same domain will trigger + redundant fetches since no inflight deduplication exists here. Args: - domain: If specified, only clear cache for this domain - sid: If specified, only clear cache for this session ID - If both are None, clears the entire cache + urls: Seed URLs whose domains should be pre-fetched (one per domain). + sid: Session ID to use for the robots.txt fetch requests. """ - if domain is None and sid is None: + if not urls: + return + log.debug(f"Pre-fetching robots.txt for {len(urls)} domain(s)") + async with create_task_group() as tg: + for url in urls: + tg.start_soon(self._get_parser, url, sid) + + def clear_cache(self, domain: Optional[str] = None) -> None: + """Clear the robots.txt cache. + + Note: the ``sid`` parameter was removed — the cache is now keyed by + domain only, so clearing a domain evicts all sessions at once. + + Args: + domain: If specified, only clear cache for this domain. + If None, clears the entire cache. + """ + if domain is None: self._cache.clear() else: - keys_to_remove = [ - key for key in self._cache if (domain is None or key[0] == domain) and (sid is None or key[1] == sid) - ] - for key in keys_to_remove: - del self._cache[key] + self._cache.pop(domain, None) diff --git a/tests/spiders/test_robotstxt.py b/tests/spiders/test_robotstxt.py index 5a447c0..efb8328 100644 --- a/tests/spiders/test_robotstxt.py +++ b/tests/spiders/test_robotstxt.py @@ -5,7 +5,6 @@ import asyncio import pytest from scrapling.spiders.robotstxt import RobotsTxtManager -from scrapling.core._types import List, Optional # --------------------------------------------------------------------------- @@ -28,7 +27,7 @@ def make_fetch_fn(status: int = 200, content: str = "", encoding: str = "utf-8") Attaches a `.calls` list so tests can assert how many times it was invoked and with which arguments. """ - calls: List[tuple] = [] + calls: list[tuple] = [] async def _fetch(url: str, sid: str) -> MockResponse: calls.append((url, sid)) @@ -275,11 +274,6 @@ class TestGetRequestRate: assert await mgr.get_request_rate("https://example.com/", "s1") is None -# --------------------------------------------------------------------------- -# Tests: get_sitemaps -# --------------------------------------------------------------------------- - - # --------------------------------------------------------------------------- # Tests: caching behaviour # --------------------------------------------------------------------------- @@ -308,14 +302,15 @@ class TestCachingBehaviour: assert len(fetch_fn.calls) == 1 @pytest.mark.asyncio - async def test_different_sids_use_separate_cache_entries(self): + async def test_different_sids_share_cache_entry(self): + """robots.txt is domain-level — different sessions share the same cached parser.""" fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) mgr = RobotsTxtManager(fetch_fn) await mgr.can_fetch("https://example.com/", "s1") await mgr.can_fetch("https://example.com/", "s2") - assert len(fetch_fn.calls) == 2 + assert len(fetch_fn.calls) == 1 @pytest.mark.asyncio async def test_different_domains_use_separate_cache_entries(self): @@ -476,37 +471,21 @@ class TestClearCache: assert len(fetch_fn.calls) == 3 @pytest.mark.asyncio - async def test_clear_by_sid_only_invalidates_that_sid(self): + async def test_clear_by_domain_invalidates_all_sessions(self): + """Clearing a domain evicts the single shared cache entry for all sessions.""" fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) mgr = RobotsTxtManager(fetch_fn) await mgr.can_fetch("https://example.com/", "s1") - await mgr.can_fetch("https://example.com/", "s2") + assert len(fetch_fn.calls) == 1 + + mgr.clear_cache(domain="example.com") + + await mgr.can_fetch("https://example.com/", "s1") # refetched — cache was cleared + await mgr.can_fetch("https://example.com/", "s2") # hits the newly warm cache, no fetch + assert len(fetch_fn.calls) == 2 - mgr.clear_cache(sid="s1") - - await mgr.can_fetch("https://example.com/", "s1") # refetched - await mgr.can_fetch("https://example.com/", "s2") # still cached - - assert len(fetch_fn.calls) == 3 - - @pytest.mark.asyncio - async def test_clear_by_domain_and_sid_targets_exact_entry(self): - fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) - mgr = RobotsTxtManager(fetch_fn) - - await mgr.can_fetch("https://example.com/", "s1") - await mgr.can_fetch("https://example.com/", "s2") - assert len(fetch_fn.calls) == 2 - - mgr.clear_cache(domain="example.com", sid="s1") - - await mgr.can_fetch("https://example.com/", "s1") # refetched - await mgr.can_fetch("https://example.com/", "s2") # still cached - - assert len(fetch_fn.calls) == 3 - def test_clear_nonexistent_domain_does_not_raise(self): mgr = RobotsTxtManager(make_fetch_fn()) mgr.clear_cache(domain="nevervisited.com") # should not raise @@ -535,32 +514,30 @@ class TestClearCache: # --------------------------------------------------------------------------- -# Tests: concurrent access (double-checked locking) +# Tests: concurrent access # --------------------------------------------------------------------------- -class TestConcurrency: +class TestCacheAndConcurrency: @pytest.mark.asyncio - async def test_concurrent_calls_same_domain_same_sid_deduplicated(self): - """Multiple concurrent tasks for the same domain+sid trigger only one robots.txt fetch.""" + async def test_cached_domain_not_refetched(self): + """Once a domain is cached, subsequent calls return the cached parser without fetching.""" fetch_count = 0 - async def slow_fetch(url: str, sid: str) -> MockResponse: + async def counting_fetch(url: str, sid: str) -> MockResponse: nonlocal fetch_count fetch_count += 1 - await asyncio.sleep(0.02) # simulate network latency return MockResponse(status=200, body=ROBOTS_BASIC.encode(), encoding="utf-8") - mgr = RobotsTxtManager(slow_fetch) + mgr = RobotsTxtManager(counting_fetch) - results = await asyncio.gather(*[ - mgr.can_fetch(f"https://example.com/page{i}", "s1") - for i in range(8) - ]) + # First call fetches and caches + await mgr.can_fetch("https://example.com/page1", "s1") + # Subsequent calls hit the cache + for i in range(7): + await mgr.can_fetch(f"https://example.com/page{i + 2}", "s1") - # Concurrent calls for the same domain+sid are deduplicated to a single fetch assert fetch_count == 1 - assert all(isinstance(r, bool) for r in results) @pytest.mark.asyncio async def test_concurrent_calls_different_domains_fetch_independently(self): @@ -595,21 +572,61 @@ class TestConcurrency: assert all(r is False for r in results) @pytest.mark.asyncio - async def test_different_sids_concurrent_fetch_independently(self): + async def test_different_sids_share_cache_after_first_fetch(self): + """After the first fetch, all sessions share the cached parser regardless of sid.""" fetch_count = 0 - async def slow_fetch(url: str, sid: str) -> MockResponse: + async def counting_fetch(url: str, sid: str) -> MockResponse: nonlocal fetch_count fetch_count += 1 - await asyncio.sleep(0.01) return MockResponse(status=200, body=b"", encoding="utf-8") - mgr = RobotsTxtManager(slow_fetch) + mgr = RobotsTxtManager(counting_fetch) - await asyncio.gather( - mgr.can_fetch("https://example.com/", "s1"), - mgr.can_fetch("https://example.com/", "s2"), - mgr.can_fetch("https://example.com/", "s3"), - ) + # First call fetches and caches + await mgr.can_fetch("https://example.com/", "s1") + # s2 and s3 hit the cache — no additional fetches + await mgr.can_fetch("https://example.com/", "s2") + await mgr.can_fetch("https://example.com/", "s3") - assert fetch_count == 3 + assert fetch_count == 1 + + +# --------------------------------------------------------------------------- +# Tests: prefetch +# --------------------------------------------------------------------------- + + +class TestPrefetch: + @pytest.mark.asyncio + async def test_prefetch_fetches_all_domains(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.prefetch(["https://a.com/", "https://b.com/", "https://c.com/"], "s1") + + assert len(fetch_fn.calls) == 3 + fetched = {url for url, _ in fetch_fn.calls} + assert fetched == {"https://a.com/robots.txt", "https://b.com/robots.txt", "https://c.com/robots.txt"} + + @pytest.mark.asyncio + async def test_prefetch_warms_cache_for_subsequent_calls(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.prefetch(["https://example.com/"], "s1") + assert len(fetch_fn.calls) == 1 + + # Any subsequent call for the same domain hits the cache + await mgr.can_fetch("https://example.com/products", "s1") + await mgr.can_fetch("https://example.com/products", "s2") + assert len(fetch_fn.calls) == 1 + + @pytest.mark.asyncio + async def test_prefetch_empty_list_is_noop(self): + fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) + mgr = RobotsTxtManager(fetch_fn) + + await mgr.prefetch([], "s1") + + assert len(fetch_fn.calls) == 0 From a86e9709ea0442fcf7b1c9b0459ac0716514d8a6 Mon Sep 17 00:00:00 2001 From: Abdullah <52079299+AbdullahY36@users.noreply.github.com> Date: Sat, 4 Apr 2026 03:00:15 +0200 Subject: [PATCH 12/54] feat(spiders): pre-warm robots.txt cache before crawl loop starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously robots.txt was fetched lazily on the first request per domain, causing early concurrent requests to each stall waiting for the same network fetch. The cache is now warmed before the crawl loop starts, making all subsequent robots.txt lookups a local read. - RobotsTxtManager gains a prefetch(urls, sid) method that fetches all domains concurrently via a task group - CrawlerEngine._prefetch_robots_txt() is called after on_start(): uses allowed_domains if configured, otherwise falls back to unique domains extracted from start_urls - Mid-crawl domain discovery (not covered by prefetch) still fetches lazily; two concurrent callbacks on the same new domain can each trigger a fetch — accepted tradeoff, documented in _get_domain_delay Files: scrapling/spiders/robotstxt.py, scrapling/spiders/engine.py, tests/spiders/test_engine.py --- scrapling/spiders/engine.py | 28 +++++++++++- tests/spiders/test_engine.py | 86 +++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/scrapling/spiders/engine.py b/scrapling/spiders/engine.py index cf1715e..163101b 100644 --- a/scrapling/spiders/engine.py +++ b/scrapling/spiders/engine.py @@ -97,7 +97,10 @@ class CrawlerEngine: if domain in self._domain_delays: return self._domain_delays[domain] - # Fetch both robots.txt directives in a single parser lookup + # For domains covered by _prefetch_robots_txt this is a local parser read. + # Domains discovered mid-crawl (not in start_urls/allowed_domains) will fetch here. + # Two concurrent callbacks hitting the same new domain can each trigger a fetch; + # the second write is a no-op in effect (same content), but the extra request is accepted. c_delay, r_rate = await robots_manager._get_delay_directives(request.url, request.sid) delay = self.spider.download_delay @@ -287,6 +290,27 @@ class CrawlerEngine: return True + async def _prefetch_robots_txt(self) -> None: + """Pre-warm the robots.txt cache before the crawl loop starts. + + Uses allowed_domains if configured, otherwise falls back to unique domains + extracted from start_urls via Request.domain. Both paths use https. + """ + if not self._robots_manager: + return + + if self._allowed_domains: + domains = self._allowed_domains + elif self.spider.start_urls: + # Deduplicate by domain so we spawn exactly one task per domain + domains = {Request(url).domain for url in self.spider.start_urls} + else: + return + + seed_urls = [f"https://{domain}/" for domain in domains] + + await self._robots_manager.prefetch(seed_urls, self.session_manager.default_session_id) + async def crawl(self) -> CrawlStats: """Run the spider and return CrawlStats.""" self._running = True @@ -310,6 +334,8 @@ class CrawlerEngine: await self.spider.on_start(resuming=resuming) + await self._prefetch_robots_txt() + try: if not resuming: async for request in self.spider.start_requests(): diff --git a/tests/spiders/test_engine.py b/tests/spiders/test_engine.py index e362036..e7382f8 100644 --- a/tests/spiders/test_engine.py +++ b/tests/spiders/test_engine.py @@ -8,6 +8,7 @@ import pytest from scrapling.spiders.engine import CrawlerEngine, _dump from scrapling.spiders.request import Request +from scrapling.spiders.robotstxt import RobotsTxtManager from scrapling.spiders.session import SessionManager from scrapling.spiders.result import CrawlStats, ItemList from scrapling.spiders.checkpoint import CheckpointData @@ -22,10 +23,11 @@ from scrapling.core._types import Any, Dict, Set, AsyncGenerator class MockResponse: """Minimal Response stand-in.""" - def __init__(self, status: int = 200, body: bytes = b"ok", url: str = "https://example.com"): + def __init__(self, status: int = 200, body: bytes = b"ok", url: str = "https://example.com", encoding: str = "utf-8"): self.status = status self.body = body self.url = url + self.encoding = encoding self.request: Any = None self.meta: Dict[str, Any] = {} @@ -84,6 +86,7 @@ class MockSpider: on_scraped_item_fn=None, retry_blocked_request_fn=None, robots_txt_obey: bool = False, + start_urls: list[str] | None = None, ): self.concurrent_requests = concurrent_requests self.concurrent_requests_per_domain = concurrent_requests_per_domain @@ -95,6 +98,7 @@ class MockSpider: self.fp_keep_fragments = fp_keep_fragments self.name = "test_spider" self.robots_txt_obey = robots_txt_obey + self.start_urls = start_urls or [] # Tracking lists self.on_start_calls: list[dict] = [] @@ -914,3 +918,83 @@ class TestPauseDuringCrawl: await engine.crawl() assert engine.paused is False + + +# --------------------------------------------------------------------------- +# Tests: _prefetch_robots_txt +# --------------------------------------------------------------------------- + + +class TestPrefetchRobotsTxt: + """_prefetch_robots_txt warms the robots.txt cache before the crawl loop.""" + + @staticmethod + def _make_counting_fetch(): + """Return (fetch_fn, calls_list) where calls_list records every (url, sid) pair.""" + calls: list[tuple[str, str]] = [] + + async def _fetch(url: str, sid: str): + calls.append((url, sid)) + return MockResponse(status=200, body=b"", url=url) + + return _fetch, calls + + @pytest.mark.asyncio + async def test_prefetch_uses_allowed_domains_when_set(self): + fetch_fn, calls = self._make_counting_fetch() + spider = MockSpider(allowed_domains={"a.com", "b.com"}, robots_txt_obey=True) + engine = _make_engine(spider=spider) + engine._robots_manager = RobotsTxtManager(fetch_fn) + + await engine._prefetch_robots_txt() + + fetched_domains = {Request(url).domain for url, _ in calls} + assert fetched_domains == {"a.com", "b.com"} + + @pytest.mark.asyncio + async def test_prefetch_falls_back_to_start_urls_when_no_allowed_domains(self): + fetch_fn, calls = self._make_counting_fetch() + spider = MockSpider(robots_txt_obey=True, start_urls=["https://example.com/page1"]) + engine = _make_engine(spider=spider) + engine._robots_manager = RobotsTxtManager(fetch_fn) + + await engine._prefetch_robots_txt() + + assert len(calls) == 1 + assert calls[0][0] == "https://example.com/robots.txt" + + @pytest.mark.asyncio + async def test_prefetch_noop_when_robots_disabled(self): + fetch_fn, calls = self._make_counting_fetch() + spider = MockSpider(robots_txt_obey=False) + engine = _make_engine(spider=spider) + + assert engine._robots_manager is None + + await engine._prefetch_robots_txt() + + assert calls == [] + + @pytest.mark.asyncio + async def test_prefetch_noop_when_start_urls_empty(self): + fetch_fn, calls = self._make_counting_fetch() + spider = MockSpider(robots_txt_obey=True, start_urls=[]) + engine = _make_engine(spider=spider) + engine._robots_manager = RobotsTxtManager(fetch_fn) + + await engine._prefetch_robots_txt() + + assert calls == [] + + @pytest.mark.asyncio + async def test_prefetch_deduplicates_same_domain_in_start_urls(self): + fetch_fn, calls = self._make_counting_fetch() + spider = MockSpider(robots_txt_obey=True, start_urls=["https://example.com/a", "https://example.com/b"]) + engine = _make_engine(spider=spider) + engine._robots_manager = RobotsTxtManager(fetch_fn) + + await engine._prefetch_robots_txt() + + # set of Request.domain values deduplicates to one task per domain + assert len(calls) == 1 + assert calls[0][0] == "https://example.com/robots.txt" From a134fdb8cce853f66713e115b81d195ec6e7b0c3 Mon Sep 17 00:00:00 2001 From: Abdullah <52079299+AbdullahY36@users.noreply.github.com> Date: Sat, 4 Apr 2026 03:10:17 +0200 Subject: [PATCH 13/54] feat(spiders): enable robots.txt compliance by default robots_txt_obey now defaults to True. Spiders must explicitly opt out with robots_txt_obey = False rather than opt in, making ethical crawling the default behaviour. File: scrapling/spiders/spider.py --- scrapling/spiders/spider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapling/spiders/spider.py b/scrapling/spiders/spider.py index 6aaa24f..52afcbd 100644 --- a/scrapling/spiders/spider.py +++ b/scrapling/spiders/spider.py @@ -73,7 +73,7 @@ class Spider(ABC): allowed_domains: Set[str] = set() # Robots.txt compliance - robots_txt_obey: bool = False + robots_txt_obey: bool = True # Concurrency settings concurrent_requests: int = 4 From fcf930c5689c545b6d5dd5abac139934e71e29e6 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sat, 4 Apr 2026 13:03:57 +0200 Subject: [PATCH 14/54] docs: Adding a new sponsor --- README.md | 1 + docs/README_AR.md | 1 + docs/README_CN.md | 1 + docs/README_DE.md | 1 + docs/README_ES.md | 1 + docs/README_FR.md | 1 + docs/README_JP.md | 1 + docs/README_KR.md | 1 + docs/README_RU.md | 1 + images/crawleo.png | Bin 0 -> 27953 bytes 10 files changed, 9 insertions(+) create mode 100644 images/crawleo.png diff --git a/README.md b/README.md index 5a549f6..d577a00 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,7 @@ MySpider().start() + diff --git a/docs/README_AR.md b/docs/README_AR.md index d394f3d..01b6d4b 100644 --- a/docs/README_AR.md +++ b/docs/README_AR.md @@ -191,6 +191,7 @@ MySpider().start() + diff --git a/docs/README_CN.md b/docs/README_CN.md index bb5f0bc..61cd255 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -191,6 +191,7 @@ MySpider().start() + diff --git a/docs/README_DE.md b/docs/README_DE.md index 9002b43..1548260 100644 --- a/docs/README_DE.md +++ b/docs/README_DE.md @@ -191,6 +191,7 @@ MySpider().start() + diff --git a/docs/README_ES.md b/docs/README_ES.md index f2862c1..746da96 100644 --- a/docs/README_ES.md +++ b/docs/README_ES.md @@ -191,6 +191,7 @@ MySpider().start() + diff --git a/docs/README_FR.md b/docs/README_FR.md index 6e3479a..ec2fd97 100644 --- a/docs/README_FR.md +++ b/docs/README_FR.md @@ -191,6 +191,7 @@ MySpider().start() + diff --git a/docs/README_JP.md b/docs/README_JP.md index fd604a3..7b6632a 100644 --- a/docs/README_JP.md +++ b/docs/README_JP.md @@ -191,6 +191,7 @@ MySpider().start() + diff --git a/docs/README_KR.md b/docs/README_KR.md index 37e1cb9..1e53fe6 100644 --- a/docs/README_KR.md +++ b/docs/README_KR.md @@ -191,6 +191,7 @@ MySpider().start() + diff --git a/docs/README_RU.md b/docs/README_RU.md index d1331f8..7233bd5 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -194,6 +194,7 @@ MySpider().start() + diff --git a/images/crawleo.png b/images/crawleo.png new file mode 100644 index 0000000000000000000000000000000000000000..7549f8523f7999536fd65208847f4dad7f487354 GIT binary patch literal 27953 zcmbrlWmH|u)+UU*y9GAx?(Po3-E9NG-Q6L$d+^}y?rtHt2Li#}t@kiNQ|VC%!N8#Ptktz$wG|ck%pB~POw1ijEtov*96{7zU;@IPjwWU{7OntO3rlNz zLF$XnUTT1~xgfO`ry`4@qqv2YwT!p3g_`$gbu(`pGhTCQVIc$oPd*R>I}2A6fTx|U zy$heGAobsT`9S`^qM4~dM9$_wK2-^+f02Mfg49;7u8w@n%pM*dOdjk^4$hX$th~Iu z%q(ooY;24m3Pu+%dsh=rMtc`>5C;Un-#jENT+E!U9bK&*>;ZrAG%a}}hfMgaVa zwWzDBv$g5Jx!b!infx8&_E#1rP@|bm%w3pSnOOd+762gd4?Ul#xyxU{e+p5vaQZv& zPsrB#?~)u%oLwx`y&NqBsZ}jp9Ne7EEdJ*3SEhe45qGvQakVfPVq;4rc#&`2KMuL}jGdC9Ne?WqJOIVED&zbFpy#FG3Ab zX$yBFRu5ebhyNlpbvCg#|1V0hzbI9{INI{4{1fl@@A~~+lmD)qq^GN_xe%*`2{$jR zDL11z7Z(R3hpD*wjALPnQ2?BV`KW!pX+T#>L6Y%E8Lb z%gMv??_&Q>{BL@72RAdTzxr8-?cZeo6Zbc*0P|l%Z)^R}i2WV-*O2`?$3Nrw58;2v z%>REmGB^9T3^}?v+x{(|=4Q+mwib34_O5?P5bM7r$lQ$2)!Nn8;(z2v)YkRCLtVCDNt?P}y|IAEoHHGDO4r3~a{1hD=e z+W9Zm0?hwW>i%Rt)jy9h$oVVux9b7DeFz3-NhT{Hs_vO{+U=F|Nh4F}y{qilezUdpHf`%it}9<| z=qZCNfFY66b|SK{Z6|QxYuv=wXue75VJAY%Aq%2(=07xaU&$&FB1Oz&pv3OG8_OQL z9k#nGX1oFda@5=$sL^mBdGeofZx2iDH`h*h9Rx3KdM;?aJvM8hBSjk)Z%lG>J5TpwVp8EfE>1F+6D`1Gqe#JeL0=qINLD zWPZ`|BK>hJq{L++1P}nk+4z1;yM#zH{3Bm^Dyg8Ng1%bv zs#2CaAyCK{O(t-PHh4(LCZ?L{g>RVQu*AwwFu-kX$18NA#?@gQy zt_t@QUEp0$8B}@_4CoD#V{8E?dZD)HWMsqF(?VV84ibgN25<*SxPA|ZN$pE(nnxp- zxKzrH($mNO)S`}-Z%>!4l>tl)<~z`23%Q|y0w2^wNthFFNt1*>4U&*T zZTagEWRcOS$HRO3!_BPCf?O8PXJKIiI?RD3?r}1`4~`VRTd@boq)qOdN5dNH5ZFKb z<;?96=-Po`ni9b}=!w4us#T|g8AOhBqDjG^N%5iqaNaQpfqBMo{S{yq?D_D~;FO|h z3P`3@Ftn!Nk^o0A8SnrD7gXDJVV1c;D9okuE2r&60K;C7b;JWgb}+^>#>+t%J^-4EWK=9D z3JeEOQvS^p4h}3@6g(I{ST=|=3sM1-b!CbICiKHt1Lh4Z($CkX3KGE_o%IEn*R%=~ znHfeFqm3aCB!jdPWm$-Z)*i&2O3Fw{@{dTG8X#NG`(op_L$IZ9JR%t|dTs58bI?ns zZ}Se0^{3%U6%0No!!3RBP-26~aE?HygZpJDTdWjb-Ds)D@frF`h7%!|?J-leeV+UM zyP^2P5EfWsKL^?)C&3RexKl#?SZz~iN<0NLq;G-N!GQslP})Jz48Jcvd*p(hK+_e0 z1*3WQux21oh}b$}3%oR=1H;iMQZOW7V$OLQx;_1^9eRNhQlLt~E3+?yna*P!m_5lMgU5XoJt1c>A(4rO2;1Vvxq9D`Ug zWdCJ4;*?8J942INKLu2Z|0M#LF}%DLi#Ws%7^CeGIXMkg9dHZ@Zg((ShZQPOQZ5P2 zT!9l^9s(WnsM(je5Gn{h7;l2i1lN-t`m`O)W?S~&%VkHPj&)|ZWmy}XD>pDUh(!xG zsGq(KNisHAzWA4BtfEP}3-!%=PXkxC?R`V(q`Y`)jq?)8+g(1&M||mIk4Vu`BHu38 zyK0sxr3_1mh>*n`g$ULY+iAAl?_?1TUn9SUZ}?m`j*wNc9HQ&S@W7iK4IyQ17c?9e ziclylhDOD3Fl#QdZ{scZQ1BF3^kg__4H;6&m*9+%R zC<=~=$H9RFk5J-9a6raI>}ljNju79Clc&pSzN47$TUb4$V|>j=cE}bkBBq|EFZb`# z2C1+afB{Vgf@s7BaJYrv;C-p9=JvPD*uSQxB(g@2{b?fK;7yk{`gvkt@)an82@wk5 zhDH`c9D;R3W4!jW=-2S*ywizh_mAqRjUB>(rrKibbR>YCVTO7?x7l?G>f3j01%M`0!8eGOa#F2*^oHw#@2e&C ztq8$LB-CIN3s?XEX(_Fu3et&4A_gSYu1%;!+U^8HBouA1WO1Z68akFW5$0HeQ;DEK zL#Raz86?pdZg>P%3?cNdeMBsCG@B_po3vT|1g>!pX^sUr9#_(0RR!HKeQP(G)?G#P zLiY20KmJ~Q;%bkssrUKAyMo1(7Jar0l4Ci`XV`dCXA)G!m@v9`Q*b*t0I4TK@K?tX z=p#tuVJOod4+|dtq45G#OF1dc(Xe}1V%SJrEMc+Q-WLQX`sO#t6Pyhy%D6gDI0lK7 zaOUt0(1_M7XXAYWh}*{W`5uT6m_ZB;XTf+{ReVbGf>R{0r@_8SB0)`4-}>}n(M4Tk z{hUje?<^sNMpn5mki?YyeLVcvPtMS@cyz9AAdC;*iU{H6=4ruNBCB$)&UF?(FH-|n z`49T8*`@DS0$c?h9T0Dsz1^;bos6-N^R{b2^9KTp#O5&q{Dv^5pvjb@oW z%Qsd^V(edpjjIq}T0{Dk&9$Y23}2{ZD`x=-w?}M2(4i3M#Y!wwBa+5ts}QWA(8CH0 zP$4?&W>&}{eM4z94cpP8Q&~EgjmpYMCWv*CEdavcNZG7zbkt)j+DLAN5Se-cM|Z)@$N6uaA9w7tX>gKf z*H!#NiD$K6e=5@3k@ni)aN1YvYpbn2|LGwC=0u5`W$R7g-cmmZ_I$Ji?GxsH!HAX8 zs@7ecn@gAm=6fAv1_cEzH{rjrFlOWh$KD?Sw1Yz1Ig5LPwL^@os0v?#NC>bXl&2&H z@LUwdGN48AcXWG@2C9UiUlOh5#WQz{GBcoUB2)<-5T}L|7-VcH($4uPL`JG9A!Lqn z(L~bfs(xEs(8oPRiQ&Ma(G1db9E~t?8h{eJAhVg=y0CUP#vXU#AFbyQi`^Ps)Vfjv|C^3*?!*T`#t}*IONg zxt4CCvz`9L?5ol}Lp_Zxam;!85-b8)QE-Bk$jfY_+v;*@V`Hcx3ocjo%mPGvWAH=9 z{HYO}48=*ugl7O37k*y$AMkY~HVl4-HaX zcn+Os_Hv3Em<|Qp^=D*3=~x0V^qNS&owoK~Pmt3SBAIO4jHP}LcosjGhX@M_v^@21 zZg+f{$0eNLH?p1W`t~0D{nN+qv;cprS?! z;=E4jJd*^+<75v*Ovz8N_$22(J!(H+g}0POKbK%&Ca(y>UnoLItwiT8IZJBDh6ufYD#?`Xu>i^6+hTJGf)XLQW;swl4US!# z4i=1^{+{i8KRXh*v^qfTk=*}@&L68W;Lzlik&LYnk`_2!i#*9yV9{8i4Bf7KVo1=O z2Jvmsat$2{(L(gkXwV0BM!#IWBn}pR0|Nv*eF1~&lpI=PfWch;R$Yh;kHPlM1Q!syjQi^(hEmkhs0?H>|jDaTLdNJj7jmw0a{N7!e2O2pM3qe z{aw*!ve7kds&xfw&o}Bc2v&AL4dMHug;IjDzhO=)STv@`$^Mxwuav`x%)f|V;cEP8 z9#SwL;RqfnIc*dI>Nj=nv%17{f4Uf64wz`wD+>>FUSa)qnkH`sgGmOf%{gfXsjI%) ziMJe$#y+jCLRDng=i>W(yb)Ur^z3LXG83C=CZ1SRmFjQwm{Q&6v8yi>L}aF=zqVuc zJH?PxSQtbrPaer#6hta$gPC)JYbc0Ge2W+jf5zo6y=rOHA4R{(Fsd()H5v?_Ah>ai zHWl)wSy=DO(uUNk%9UWDL-%IgMd$6v(U$yV)q~ri53RG)bC|dh5M*Bgu%B!~XCrG0 z_?L+;_%LjRp@qwT=H&$fHC6Fxa7!WzWDr;}=xIN>n1r)csf#Qiz**I*RFWDG2am~k z_B4l!hyZyKzDMB)hs}%QMQ5f22CFJeu-F!8(@M%EU=(LCEG>fBI+uqa{+h%vhn+b} z$<~?U#tSTBo3#wc+$)YZe6&CUZ&QJ?MVnIzC*GeaVV8| z8`q0l`#lT_C(Xpo24op^3Xp=M^D%3y#gE@P~Y0ypV;%C6zuHm z*fL+wJ$g@gW?1j}u@G|ImUJ%sE_~?e=;DOe1|v|n-Pi429xha2$hs9Z%)vnWr3!B zKbUe7zmEr-VyuaB%;P{?)RV5-BDI^AWTYI-DMZWCsr`}M5z7dT4JU_7`!olc;frX* zK$|1z&aZUqV^GWv?M7vzvJ)QzO$5QZDDn7Gb`x!#aeEnN=V^)uR)}M-6RfeQtsHb$ zRJ$gFX^g195YEidhugAe8DVHGmcTeb5A3qht%O%72lP#XjZ>M znXd0VPMMCTVrgk9D?2_Ah6oNvdfs(2<8^aj;B(eY&_CMhGQ&ISlyjIL;D2|#D4)ha zS~x#HUzYE`=C*l!e0);d>%ZDyA(1i-B=I?|-+n#d2Ggw4cG`4XmLyAzii$FNJJu2u z6dbj60g-Kgyg%^s@iiR@mU`taFDGq2)kk$;;kD#pQqcC(kU-VVk>VEIL@k_py4kF;WiE%eE@ej-cbb0%=n>;MQCq6ON3YgzcfR^HoMr(}I;EyZ`>1qpmSv&C4xDQHQy7yp zzJD;CeTRd?sH?84D<@H1U9G67=sm8@&|*H0(Oo+ngDYL6LOL`yHg;tNQ|J419T$h< zhs4ngPbyY0SD{gPd~yOBGK+KrO&y(b52x283k!?GTn|GUtZQr|@*HrHc`aax4K&uv zLRC{wDd0|FI9g{CGD(Lu+D0pNhJ2?`=_o+%H|<-wz;K>=jcT5Ho~c$n^=k4&>Z#m4 zMQ4&hxqi7o`BbQCo@&^qqd(D~Xue4JtLu6w!N8$davfgGx-3PmKKGm3cGSi_=8B+= z+U-+ZV|=VirgL28@YGlh_zB8*$#lr@)Iv{y;b`ns#<27f5r+gq|9;$2@U>p9-})2N zKd<}KQAM&TP6nw2<<0p-*x@2T{o2MvKw{s^kS>*gm{`d7@$$m|fgdcJ%dX?;AbWCh zGDgvugP{BMdeU+>Pf=ptN}aaZdZF~8_v0;5rnsP>puOG8*SD*&v9YB^vz9V6LpZ=+ zM@NS#J)7M!F0b9=V(a|eUQs2tc`|`VT#0lf=6`1 zK)o3fiDz!TJ{8FN!<*59wO$Fd0ISQ;R-!i$N1@aX4BaVwB?rC?i}8;{)2H$0NK5@z zDNsY07MFes=QaK$G1MskM1JNhTSDOGcp~auOtO7e_@O<3t2p|yc3D}Zts2$B!^YI2 z(1p+=Dq?|WWb`%W_-fRo>eGP6Gzj%4mb*Q~wn|-7r5TIwiHF2T&v|7zjDDz}h__$Y zzO-;A`u&Qc?zyXW_{8gMZftC9Y6>CZ;Bdj+a~12zqptuaCnvWCde+xD^T2G^TkJeN z+sez!OG|+uz3Fp*nhEMk8Y~3$xOSJrrNzbH&h1M}nu3-O=bM=dq-BzWO^*q2#DdQy z%zm_R;YLow*y)De*7b;ZxAk`?%O4BEZ^?T+66WjojIA4m;C`<f2C2QanaGqGT0xPzzY z#zKh4OrIC$;|PYYF%@uF1-Se2Qt$P;i2g^dvaSmTlK1rthDdJjomjw;Yfa8^LBQ7> z@(1-0wfGsK!FKw_WEGXmU@aqNe(d^Cr~8@r>(%I|vV=-Iouvkg$(oMiitFp^Uzu5~ zgX#Pz!~!eJ%gaB1V#SZ*GH74kE+{b@bToNRt~pz>WgZ*=!7>#N!f*}Ya6IQV4Gnut zhoi5qoVr(N%W|#@J0k3L-vEWC$orDB8rkgkoUE8ez~RzI$yt@54uo)J8UVMZevpD< z;CL7&fC>eP>lL7gW?rA611D)4dq4}97a9HCK7BE}1hv)2j`HzZ%xygR7r#%v zFTp5W-q73T3SSU^9N=R@1X}={poj*WCu1-J66T)5X<6lUPrs`g++%UCzX#I|Rdi{U zy_UD~u3Oa$WMtml7UcJ?1)pI?aLw@Sebsa5Zf~vFPF>6=KuoEmLbrtvGNeFjKuAagh_yth@r_>fz2TbhCU|>RC1h z#};)l=EE~}^ejHq>H+=Ht4LmEL=t4AebIu1Fx*sa7l#oQ23uyy0us8-OLD~*ACips zSk4Gw#RK?@*FWW+rXnAga|^!i*HGB=Z)U57%M(5xN;?`c*Vv1S!`EyFzR`|p8Rx)9 z>eAFFq>a&2=(l~H;Q^+AAUCy=2?lAwCBzDQ63)8YLs6QkLtESYB}%fOrGoPJ{2FKt z#DDg+`&G27!To9&cQ%{*LJ}I*g-JppW%4(d-C8j?N5K2*%?y8SLqk%g$N5G(ND~s; zw~=B*G3YcDcXWVOCK&VYZ!ZxM5lUS)>n)WP6>+c@bg6P>3sa{ZwO*HKC}ZrU#|A7G z;6a7d2(qcgt|R5f*X2=Yy3p)J%`Db}O7&!-J<5IxSW;|__|Z|#{XlK8Ml@5n;W{XG z88moE8?FVp@n4BT5NQ(KUk+8;HTZ}8O_N1$zGigUtqv_Rw$l-^%coRYeb$$Rmby;S zDi#UALz_i{N91Xac1h}KhwbK_iu{%Ag6=i`VrT`GqZ{TkI}peDxNB_{XYxSt>=GGv zIsQ2%0u7y~5k`+HAz9lnc9KBp)av@af3y>mUq6+)aIw+8&HGBLY&gs5S8D0TI;LB{ zHbd$B#`^lpiP6XN4F3ga<-&i_aZ=OM(*qbNOy2;ht(_e}U{+SvNlj->VPSA4x5HMK z`vJuZ`e8*n;HnPzATWFV6e67pYxx7F|q2!N&tp8ZzPM!_e3ci*=Yql>dM=x^P9 znE7i_N3bs@xJF>}^p62j56V-~1x@F>@qbDVA0fT!O=%dVA6?AnYgOxiCWV3KR-pC6n^Zy2tST^GGg_0WnKudqFDXV&emD3=D9hQUCA166B?>4|Q;te0M4gc{QqIrT zJLstuGi4unqC$CL^nCN^^k-r9oDLxWt$ylotOUH#nE2=DP}c&*%Iz!aV^ z5bU_b9ycz%8cDZdi=blo({l8rW}y`^eBX(=fJLc-=Q1=M; z`oVO*VnLsK-|OUjB*)tI{e5!}kB1nsCn%(s(COKHU~izRR|nWG(@W425g@JVW4;p zURQel6ksilB(!h4r3n0ic}bNGwdg~LmdkAA9)`ixm^H}GjOrkAe_n0g8O=-saO zW$h{h?jTgGAUZm_qM`!y1$7(XCq^B5wbY4Ki`_byYPq_W*7D`$WsO1SEzZC$OW^qU zcq}1z55v22yFMrh6z_hv*6j0eUi0_!{{Frw-LrEYch>3OKa-#!>V*zwA>lxj2&Dn-d^KB zq~=z^SUT!0LQ&z*I>gY*JL){77WbPHZeF#`UC4f7C6k)6utlHqU?_SwF`5Xo+Zt@` zY;r7D$25$-&H&wb!d`p|IM2?J{QPjG88^IK<*sKf5B9_x=e)r3SCF7Lcwo6_h8aY zyv`l?rfB*HOt@?c4c;6a_ou=JPo(UYrrISS_BWR--b|??{|YZH(B4FkNDWRNzJ6?}TT@ z1ll1fL;SdK%~6S^&vrTi-)MusLIaN0HxSTQT)k&L?y(U65p-`H<6Qz9fT*-f63KJ7^6wKS%S#{DxLqkJ%2+mQ>*7R;F zm)|4q>sP2Kl9&54LK2b(0f)hGG#nxQTEGbgU40U6^Q)2_>0s2kM)SjQZskjnTp|f6f*KFV_{;#q2I`l ze{2PQf^5gNy~m5+8CobXdBg*X*|BL44R#z0jqMj6mMuNfme#amc7E};B%VJp5?2?? zmWTAv+d=!+Qb`eNhfXwCS)P^GI_z~0kj*gBU zu9N(GkgXE%;s1%xiOBL~sXFh_`dyHZuL3-G8ffIX8x|-6JMVU%e6a-586aG+&kEc~ zVx9*F`hxaLK=UJW*QHoBDsj;ui;I!bwjjvb@x5Mv*Gqiv-?wzkuaxh8~R)0aSqsG-^^ zeKT4>g3yTQj&F5KM|j#JUkLbNAjp_PGQH=xX6pH}u-ik_T}|3pIPs)z3DhDp#7SDm zT%vB3C=~l$JVDPjc&k`|H)`1SHFU7;P@yShM+U)zsNW9uGF6hN7v*qyuhlQZ^=evB zTO&prZkm!OOZ2Ch5y2q-s|nnPAKYNO7C}+ssXd9Ot>)d424$?=xS+U_px0#?!g1kD zydlTSh1_83yJ!}FnZxHL#f|#k3xh|4%-H7Jprt(LvVJ4T=(ge@h#8LjGnPnxvRs?b z>vB*i83R_O*UBVfNtgP(aVYe#VirGaS`X6CKIiSy^fuCQMD0hV**FZ^A`+00kf_41 zj(s67&(A&AskKe5t^9E}`^idlB`739ul=b0j-c%gkljA4-DIanT?Cp&9esWEX`Agg zJ3PAfK3V4bxijGKAJhrX>h=1+{iS#+w51)LJdZ=ZDgeTxD;?jg+F5txsC|!}PQC>< z=wTAvvS(sps*rNkw8zXGVJQyZ;N|)b{3Z%-}v&gMYGu9I} z(QS5?`dKr~#~s{Bt?;Xuzve&#*(xs#q>XNbGL2tKqrf2cCYa;0I zgv#$$V-uOo33%d@#!UY(W|rhd zM~xYsqDiz@QsiXrKm#000q##a!Xr5~wjni|gwm^$$wYmW3^O2LHOt13CzWJ+{O8Cy>@{0=;hllyY!{4#S0?ZMtbEz#RT;`w&wXFD+m#N9 zz*+AXx%CdI_u|hPi?_}V=P8s%i^sLTjnt5(wS`ahBC$ut2lbeudjq!lMSbZIh%w5f zWEeSdaq(6f8`{!#yBb&arj+P5lB+M?V(TN4@t=L}(8t|&H`*7%35%Xue=2m9dF}sr zmT;3f$Q&5@qe8 z_^^~VF&yYXz!lkdaE2-|@HFJsaFg($3b-M;LU#wx^+-2mntlqrV3|N!G{OAuYw5Af zkxDbMmRNJSu^XFO?OtTp^*ZE}l?{hW2mbR0KXU6oOl-nF`&%SYX+^D&d@H_uT#_aa zJchFu{3Kr~@^yx7s=9CStqq*r6tvwZbSu!a9%ixNxU53)`8_hW6pSm8P&eRq z`8kzqSQxYK>-^OG;9j;tW=zmN|$)Arj z94siX=YVZB27!$BgmI`(qz^IRt{9QTyIS<#-pOcl*7nbwkbIo8GNEf_=7HsXjo4$R z{Yip86s|H_(dd!daAcCfq*NPY(>{kiWxL-Mub-lRNJmUY+$1ohYXCLXEyl{EA+jzY z*Qi-OP1mXv4X{&4xN0J|rkYCeo*#vedfJc!(|3$;e}&BMwm`Odfiu|+WgU6?!d-Y0 z_$1Yu@!HJ}#cd7M>>b)fveTFAgC#!|xJ8^Z9{f$P&!iQCpU3$FTGwMzwuqHlqcW`cNmu)%GO5DSA=k-P7PY7{=} z;9bq=HZl*V_mX?UKm4N9XB{Xd&Bxk(c8^#ECo_$JCbc4w%uJ# z^%SjRBu&s58Xyak#*itU#T{mL-zm9@uvlM@)x;y-hQM#8G=pfV6KIgq06Laj;)iP| zQ+@$elu#E0rVxlYN#7Rgqp&hrF^U<-%-;#`hKwYY*oUas%BpkK)oNvxM zSVw`L=HVhN-iqjls0B99*6X%% zPrf15(lHz|5#`yc#k7?dT&gTM_;7Q~DeOeU>h4FOyq_>4AP^<_N}@5G;aA(Lj}rYWX{rjQSli(`K@RGN6O71Ddfd{VeqB6m~~AF^D$EISyRB=lm5 zsD@8j&K!ScoZSjl?R!=OYsB0wX$A5o}116o7~oZ{ZKCXh9>St-GYeG z0;fk-Y!rb|NA99p#@6uA`-?KiLG_0JL!EL1x6)~2V<6N>WI)aTx}>q(=xrDt%(Om3 z5e*_(mIWTjb_-c-L`NUF6szR(aUgj{IUba=l8%DjDWSSt2jv@rlo+mbkKY$zIXRpA zOC$6fp5@Z+FX}({gSRvIU2R0$I?)bk#-h+ORiX|!@wfRLCg-!ZAQT?PZK-jVjbkhY zA60_R#|7~5jDcd?`*{%$zN4uQ1BkKjtTli^TN5WRCnT!}k< zismLM$a|c0>>V)lqG`cR2S0Pe3Ww}+W2#{*78ZAhax{MCBFA?=8?g9y08)sDr_9lV zAybL^7TLRM8Js&8ZbjlVun$M@7zV`OQXCykAioTPY_?dZTl34+YHi_X+}ovG3H$H$ zh3vMHg1D zGLIq)qcA2Deb^DJ{;GsvO3A91@r99%*-G?Mny=aD$dNJ=cY{FA%v|WlmzXQF0$MbU zwd27v!=;jV2L!v!9kohNYf7VqaGws)pmsNgG28SN4a8by1-^zDqlUZ(X# zC^@tb6rw>F)f2#mCqJ2#f6l5h3D@!3Wa?uL8cdHNKR!G=G0_XA1Mdrnez^`Oot_H+ zlFU?ZaRb3@1O_j|mHA4MK)eUThn(svTi6uK{C(j`rWT7;eOb0WI*}RPo zO|(}$E;FdnrdaY=1i{W}i%m3w{Wku_+$M)wUp>OfjN0gd$bL13y<(nt)FRf?epI$M zbJWJBw&e;0l)cQYx=)w`*Pry0i}^DNCngop8-33B$s)rYEe8l?D$}PhcvoW$j|1Tg zseYEO5Z-kt8Jf4W)eE1Ja+E?wX5&dY%HsE>oPOcL2qTHS5|nwAWb=O}PDyGB99EuC zf>NP?N)C=UP9_lV!I18$F!hd~nK26o)0JOrO#@;uy_=}**A(3ckTh*AAN#e5``60v z3;LQAbkq^!Dvb_8wsjJcSdT7NxAi_WBO%_C#fC->i~nBKtU{t#8TTG{%2cG|eo&2a zrnd58b}=`dtoH%fc)uZZR+Tqb>695`lBXV#d_MJ@hoq~gw~L0_7Ce%1CoAOmgMjTW zF5b4F!JB@Au0m#vE37ZbV~wApg$*$|as~qyN<)B&-EWrDZ_P`$3%}x28U32b3O3=C zlqp{o9T|x^rn1^Kv0^^)1yBErMsq0cU3=%C^UuAFlbedMVA~zY72FHo+|^4>GQqW? z_?^NaQWNK95xy*$sRNA$unZkoO@@BeCv!-u`6%P95n2ds*tl;WFn@MX=3XrKkIA;a zVt5%TwNX}T#TLJ(kZeM`8lj~8^L_0gH_qCzKA%Dw@>vO|-Y zYuKc!eyn0wl9Qp7M-&63OEK(?{H(a>?Wwguu$+_a%8Mii5^opo(3qTbTn z(Ee85JGmy0x+Y<@wHr0enGCJ`Y$lOAM#vOVoDyRC(nMi?Hq@5& z`h1_Nh)tE?|7>k@V$~$JB=RtJUJXknh3SUO$C7nX42$xWo%N#Scv~7^({W^ zdf1cftfgc(?>bdY>?MixfUFjpY%DTiTY-V$%D2zu`%1Q0sCQ}u7BI92BqReLM3P4L zvz;swyIG}ws>1HDQ;oWgqZ|473U}dxijzX3u1lkS;K9 zYtl_%%=m^UCrS$r4nr$qBoA|&t(E$dyeG*t&~9Cq#RDkr=Vr=LqCG68F}+A%l1}*f zoIu8QVb?WAY{9fKv`*-@zO~kSvOIXGF<|4id3KAA2z)rb$L3D2R!B+CkA7wR5cTY= z{cii>SZE_)4_An#yZqqtJ&$nv4cpVaA0_6;cN)Z)Bu5T)O_gK5t$j~A=3lIp`em}L zdrghFey1)M)1|o|T~GJa#NPJ6Q>1Ki%bdo?(prBSRgC8=MvrZ`b#cZq$OKW-FU%{+WXF_pndZy#}2^nG+0|+fK){E`__7iFxm3 z%MKR9bkc@#g_^fXoRxf*vwwv6>h9xbb(&V(*vXHW0{3>0SS!CARE_+mPwQ)>TqrDY zv7E8rYP~@Nqv`(vR6<5Z0{>oKo7-u8zU_Z-Tu+)xV&3QB$NG0yp6VG`Oww(pWjtF+pHK3W6Pw+H zK4#+%*p&JZN*xvWJ3ne*u7dtuLPkVdcywiLh*MLUx9($Qu)Jl?mF+^LG|`S6Yvi}A z(OAk&H!K$UP{nt$6hZPf{d_3ovWP9e&d*`r)w&%XuZ;J=)Scwppm1)Y}?cS7T- zB-p#RM!%%*z7=i7BLzj`A8Q`oDIx0<1(YVmCmGngyF0C( zk6-Hnjt_dZ9mZ1YNU7uvIwcB~Jzj5+~m@$xrpCS&K+D?fw zrD=jcp5`y!A73nMU&cC@Ul)(B8?Je8ERm7pLTS@@@qvd!)t;BE2LKV+43#m6t~zEl z8{C;O%qa;ph-YkCp$MF3K?8qC_)pK6K;Mtxcp5Oh%h9!P2P;AhDDd}6tLO5>F=n5_ zhdarSQpTGg{HN{PP(wV}5Msh6VnV?1>16DI<7eVRol;Dss`X{Jr!Gn^vE^Fd$rmMl zODpHotIbaT>)SQd-0qI`$xJ(_LNBC(8DCnoIbd5JV?-nlnOh(N=R1>TD?@3P21p|IJD6GYX4I1WI5a-2A z^SjSvayAxroie%+cvGFdqs+Ux7rYiDw`SP8m zG&qqXLuYjaH+1f38mlc3h{6hA4iSPZX{w>|+_AJ9r2l6fwse|doJ2+k^>)-&RhUn^ zqf}@cCz<48Z1YlVN~MaN@sb}m+d79so!!#sK6Q%sEzw|IjrTBGEp56w*ATNd%TV{D zAG`VVYN`T;TiQbNC_>W!uf^`SvSgdNyPp}{F22S>I!CM4$V7>JOZ3ka4H?`Yq-5`{ z`CCt?OLipf*K(dJqmv)Y%;&2y7a_xpfo+iSXse8{Ly?1Xj9UO<^{*^nvf4eGS@SAAXJEQ*SH{P)>0CC^TuYt&Nn){AS}+$nQJIB?Q& zlC#N6^Bb&WelUYJjJzY6Mf;I*Mt(C0oLl&28_s?_ndjd~==P1vO5t+i<+*c1a@q z4*Ji4&iih>frsNoqQmR@gY%I9u7OHg0`CmfLT|_0JF5Oijdm3SXJY%q3~X)t`9xi0 z!@!K44|{UE^7s88lT1X!c%Mi<8F}4bYcpI|8f_p-8s>%VL`m>vj~}?qoEvloczlpJ zufEp#@XT7@C^Hho@7_5`+LNj%K2D{Ltq&IF^acLx)yArUXJl&Cy|=u;dvI^eUoPuw zNn|MP!qkc1r6(X?jh|gx3ueoadWgNF>FgS9 ze@!>Z>AQ4;Bk#{$#kaqH(>pw^&Tb0$@TM_ADiizY+&lAOOKA)PL#uyC-W)2{=AIw? z43EwB_JQg9GV=8RbQ^%{C-<}w%ks1=ccpXt)I;xGQOFA_uh-~!ySYjg#li2@$@+Be zwE}Z$N@;PTC{GLVw?RE_`wO8Txw1ePdn$Tr7rGK$HR4VkPu{h5vkNm+A-CaFnh0NkFUx;U zcJ20^AuTj{5UFh}nzP(mS<}lPqGD=2Y?0cwQyH4&<{W4&?O|16uiY2VR{Ka)d7Cq% zsB17`E00wPuc4x@K>Gb|vGg2vwJXbfDi@8wyDtGRCR$QV897@mE6j$K{uQn6i*AjC z(I%(B=9iqxAih{Ku*=WeQJtyHXjsC=Cs|(Sq)=(q_)cM@}lwnhG!%FFM`<1oumsG5m~wCgiv%9N;OekD>Gucw>G zQk#!TM31pBwZ)nOE8G8bKDB0Dkj7iz=wZ9w=<;G{hpf6a)^o}iznP^>AE?j8&Bw!& zsUUj&_8`#aG`N&^-XlXHz{Zm@YwnWC0QYed(DVVU1uSXh`8Ni`Y{y43Xk=i-CM z!SL|UjtB)A6*WetI^XP33K70r{AW);3ERH?~i#vLQ3Qc+nJFSW>$Qc5Yq;V?6& zQYqJSc4z*WQX>mXFD|vJ%F4=wASqQUnVOVCJJP=gC~nm@hZiukR>cgYOs15SlBFdqAw*fcObC(5X7l-+eu$TsmseF)2@;dZ$%*j^DOETU zE-8r-D4oeTPX1u_`62hf>#04zidJULo)`b5MG@oP0e*rJ8M|w=5xFz{lYZwVfQdfDdiq(gcF_Al(wI3Opc6H-Wb0q(b1$~bV z#TS<6T1wm>A1Uj}g@_~yi66i*Btw5WX^$^bPByt^ZLWKFto0OG+mepPi7Y6Ed@euI zlRC5oO2u0iscDT#I|^o%&t*oA$ZR5B*O^Zz0cPptbRME)0BHs>E2-FX`VKuFFP~9f z(U#Aa6GM3p7?NqsNrQEcEhp32G!T3RgP6w>Y3R_5RQ=hV0#EXM3|-XE`v-&o#!e51JCZc|KGPx*1e;!mTw z2@1`Qy>-;v;c^OKo0T%}F*o%^#6(qgt}>Jzb7KrDwY^hHhySYf(7dvVu;7E~=;!yg z{rmpr&vzcJ3AhuU!(#`)xZ6`av#=V&L!P0!vQHabxsd5l2&C(?_CpT^oQ=D^NjW@a zvtSR-n0wS$u}zcg0I(3YC`2F$SF!Zp5>ZexBnm^_9Y286@iSl7R%W*wMEW=uKC(ri zMH3NFW;Jv~-?}^dD`vJ8f;V%SYe1lk3TI`gI+VS+eSKD0Gh>r6AzhFKKCQy}aBJ_U zceM@Y#AVaR%53+Uk>~^cC6g{w(F-Sv!F#OmL$6)oJm#hruCG!mpC!(yU|-~C2S}+@ zR%gNlol3e{xCl07^0z41g`ZDAB)z;u&R-HihvI?}FiReDkikX(xc^^!-yJ4Lb>(}` zt*XwGbB>xEq#1=71rz}xBq1^)gJpv;HhA&d_1cfM{div13!m3G;kDPsI6O>VC4`Vb z1PSFZn$e6jL6c`RIrMa_s(bg3irq63k`c1`9`}=ax~r>hSKa#EQ|J87x#xh)j5&j7 zx)5c55&Q~IqMpyRX%p6z-6%*wDyiL=c#k0X6pmF}ETRUt$>0O<>O-&Y1E2hJq1SZ< z8Vcxga_I~irv-TGItFSnS7ynbWm|SmJjU1%SCYIO)HEJy-2h;+N9Izk6S?-$IKiJ_ z@%t&ie>~yE?yM<^D{@WQc)DXCw%KESd%(5jTtxk(RivOiaTx$peKtyrATxMp0Nt@- zY`C<`R1d{z2jfN1Cx~c5JDRtL&Jz__7xT;wJ9hdZ)6W$#+sbn+P?z@Bdnp5%Sf><=a5I8eNz;SP<(Lb)_P?SZF#w2Qwc10O7L5grTL>>Sr z4FU~@^=THyGp0o}e{0^Ne+-)dM$Z5|aWGBuQ=TRw^=hFD(?-Jt#IPx0{s|s!pcb?Q zrahxY=F?ZE@Mu?Vs0hZ!u*;l2yq|S*a+w@jH8h)4XI%qP(Tu7K5Li54NUh6xX5?O1 z7q|id0**=c=X>S?3;Jc5GLc|_AYBqWf(Tt+>!>IwW)xH4t4)0s$_QEtV11(sc|GL! zQU?bCT}j1xr3-ViGseazn@*h^Y2Pmgr_R$4GRADKq}3a~l9Ckv!M>BpiILqs{b%bp zOQJ{9Q!yr?ln4<7*!WO`7@Pt?BwTXF!lI%|D$~Y>{e%5Sz%cm@1Ly@mY5VI z9VO}x`nMdhkw}KZ4 zQO=yP;lc3}-EVuOVE{nn3Zs|g=gk`E8#>Xy(?=%^W!!Xb$}>FthJ-1Smk;p-geakq zpjM3~T!B{oCYp(+>m>Xv`)Zyts;+C(2Sa(}0&oa2!)39(>DKWBW3gAq_1u=y>z4UH z^`-`?T2dAfwX{j73wQ`I3ibEX73qs^h|Z*vJMMjG(``#B1*?TST;K8ZkDWb@yLnrT z#U3dIJ;A9#n=2Uz55NfFMZa6HMx>@BRX& z{omg{^4xd-()RXoCl{+dLJ=8E(Gi}rSiuEK)*?pTbUJIQ=-8sHXLMX8Gy?u zzX@6+xg1SnR!u-1yP#9iCQh0FA|Ow!yonNKnvm8tIb0mw`$YE9NG@=18t)>aoASRp zo3(E=hC^A_@M`>$W>czlW=-m|p%rRIj}`-aQKLY3TW;yXFWtKIqr)vPzP!D-aMm|B zty!{o(Z`%{?W(0E1!;ZfMz?H#-x(Qq!?p9swmH@t83f7eMfdy{+cz5qaq#k^&KZqcCA}CZ|{evL{Ywe?cB}(eK5jh zuUT01=4<;4i&AQ8ipIx1+qb+gN&F2P77q@N$Hqpy^VZR2E6dX}6Z(5b-gtd`Q`uNi<?Fxf60}?eCK;^0 z>KceHu$5^51c2z}iIhwRriTjxV5={t#~;BXiy~sX&-GMu@gI9LWVKIC{F`5cvpN} z+?pGzuUlI!i}ac`)wkWXq_w%fvMP7k(xpyYGSh?zGT;bVxV+@1I~OirU6GTQ^w>Y& z?4IzsBJ5xN)(sYczy9X=`Gx7%+%WgXyB1~UB(J}tCLtwy_00=zxNC9O*@0_roOjd4 z)oEEtw{BWiJ~vkkP=ch_bK+?C*8bs6%2>b~Y(LjnS)2FB_iuEMd*>`FeC&Jox*SnR zFeNko;U{kbL`vZsKe|n@ae;vNt!M7f%*d*}w)}=oORaWc)ot@{xo7S2r7Q0Jr?nj| z{b{)gtJklL7qTysB)RCuqWFA;UL<;m9D)jNL@+R|avh++8V~_^#we)C7S-|GzgrY;I|*D9$J?Ol6G8GF6`FRx4jonz7^E zlP~`~mSV zuYBnD2cP@#richzSNCvjO+jgC#s?oX_V$d_)pZsZr~7=tlP5bXDzjWJ`{9GF1%)Xb z$EBpk3Id;WbN`v99-g!D#33^ufJ&cogz!Ai zb6i84Ff zM+PQe{>9G1+1c^QQ8v5HB3K+QTY6rCEXgPLHvi$LTT-&(64GMo_P0)r`{phy>1gV! z+uy>WML@g60+-9bh`>kkISi>uK5F6+bvcb@eZ$xdk*7e3vFeaA20AUR2wk^qIW(C> zsmPcJ>N++=6YRkZVFRnW=8Rxwp3lopU#L+j(0aC0l;q`$=X7^pUtC=L_@;HgfAu53 zUvxRFHbS*8RrD$3=Ih_YJwKtQrv1<-z*QWgx#kuJN<9^tfGeST4yX&guF zj>O5aHoGfjVQq!qAFM3TMgo9rZgoL(OFyF^21Qwx6jNc}zSHez2a*z_9(Z8I*qG<| zv9qgIl}}Fk{`|)e@4J7Q)h4|3rwJ6u*-X8wR@(0f{DBIwkI zGLlsCc>^}5;BZDnM!O_QmL!?yxl@PRvx`#ZEh`-xnv!LD>PXwzkh`n-TyOgjB1$rq zWtmb21c&>^Ui!&9yuhu#t2VPBrTbgmC9#zaZAW_or`Tn{G+Icclgm^sv=Dv6WCAKt zMa8>+}tF$+dFV>r1?xcrOe~;9XiKu9n8WhNJDSNPBzp zxvsvpU0V+46{W1(SaauBS0<;&Qc6RUxSNI>$YgNfy{!kM;+)_A`B!h=wCuxebz_5* zNO0HdhdY#$119?Ey&L~S>Mv!_+Do2 zRmBCFJ-x$qM_=JMYxVqFXvnmFF)nt05X)dkD)i*hDLCXXCDWapyF=M;Cg^aq2!qVoKnvjYKd zuyA&^VBt^IHH{99my{NJ+}^Iv?t-FxN?B`5Yi>byR&M(E=)~cp2kjPDMS1zYgCDg{ z9j0&@QpcuQR~9($B_;-%XbOhO!(#XjiRNeF5F_Jlcumna3_z>6Q>%w7wP|Hpl(j

>ZbGvbe0TAH1^__h1P5?-U=j_w8I=JDCpeI|F)9KL zE?fJ^zkcVQ?>+O%o*jRb0%J@L65gTulPN<4N%U~M9f_66eo68G1CF;7&c@^br9obB zQpymS7<3~dFGMJdJE&6e6hO&iEXzKQb0Tt-`efNhh=mXv05BFrv;YIj{IVS2h>ar- z22_@(IL@v-9!1Fw06cLb3XF*o^>AqAh%G2jGDa=Di%}#5gOXeEad5=SqXU3R)FU%d z;G9Y+Y{p~+9ub1F2apQrAYh>&Nz5;xoiPSTgVdvsoP{`$@qi#Q4`nisHlEoSQV~1? z*cc;-z)(iYA*xx508j)k0gf;p5!EDNh!O+{a2&HzkVNnx@&edJ=DAGardZ;9IMuok zfa!cQF{#c30Fe-rC!ClZhf(o1yt?pFB!sBm7Erw3jL9lwHNh-1QI;jjR3R-de1YN5oqqgyfO58!}>QhRuQYzuyv_LPlG*q$)6#fP~s1?0b>Cp4I#KM9frbk~)KgxU>`kSzyFmDfw2Dzm6 zS08wVTO^mwgH1F3P;+;KhF2A7n%~=CKS0}!Ftt(2n7*{ci0aTDFR0EM-3zF4bo%r$ z$!auafr;ygsH zU1V?5kNs5V_o*8JAzCQV&IAqVtD#nV)i`O?cs4W~D@3!~7yxBT47*C+QDCYoTpvaj z4pGoHnEq)Dit=p;xIvp@X3%^FLxZ6)XzIyN3v&yTsk#`O@$Rb10O646LIE@h67mx; z6I`Z$JfEbWv6C4Ey!pNUB+*RfM;Ub{&xdZfyrpQhAz>G674+uE~Tjozh>S3``f*Y+AHu*qKZ}@O-;n zP<{Vwo0W@kaEZE9HhBlpprs8=qwZl*fUdYo)Mz`ZsnyE0X{CTvH9GWmfQCoaZ=xk^ z(qt6^u#n1y=0g=-Tk{|)TRzpWU0~eJYJh&fRRGP@37ZzdY@i%~w?)Nf%&uLsDmE(e zot>7Uj-x^Eh|QIlm|hVdpW^d+`g@uuN1A0xS>8gd)|kZPvV{0FM(Mf!_Tk}1QBs3= z%p#Z>Nny`>eW4EoBN%o1L}k;Wt}ifdFl~C9W&9Nv*cip-^Jqhm!&i5oM>d;KZs@u^ zQy5w`%pX+=t3=Lfe*nTDpc{XnKMT~&dgVgYkJ7?DW*i?3CqOQ>ed4$?#*pKM_%z@I z<&+Rmo*I?hA-Q7Bh!5lAfHB?{SyXe=H#Xfer#M64xs?k`e(}4_^@n!WF1_L5``2cr z#d&;zw|CXO_~MKGo%<-2E!OA-3-5U3;SD)CNsO`MN6$Y0{7Vg|w#ae-5#yo??ABOW z4i1jhF<^E}Jcm|7kWmJ}eA0-Gi$>rGl7Q^?OA`(*)`}4l_&xHFOvUNKSeY4SdIUff zLl&^PxOf2Y(@7tl)JEM5VgLwvV*E`ojR4U?o|>*tS0oTaUEiS}b%-0gA(s(T4uA&e z5H#wJX|yp_7igIi66gxIMlfsdRq4(Em{$5t9XLaGxuR1a5y4{5T6ydJ_ujjB`PGY- zt;{H??&v)i96N`c0Kf<#z*&G%Kn^(pISx4<7(>DnP5>q|z^IgxZ+`uuYZezbH1~~+ zdn$_3;^SiL&&vPu54Yy$B)+-*h|6hPe_d5)Z{T#}NlBcFi!c7>IR*dze{ zdhvWQjWIyRqV3iPS5|L3a(aBqN2!!nGB+i+*k-k!>mP`6*rKAN`@6fQCMVLf3q(SW8mejhkV{ZPF7-WR>DUIT7LPTFD#f} zQ(3xc(Snj~o0I(BbFwUsk9$Q?>g^j11jJxanws(fl>oqMiLR_Hc12i+N82Nv=?{GA zo+ZmmWtr}KzhT*R<;@Mf9LGKQ`05vay1i|5uS~`3*Q~q$8*6rNJ~DqraePwr&z>D| zxm>A&0=LQG+LrPXHb>gnKQOJZ`O6qG5WV-q8(X=%Y=pug{2>4Kb`lGM|u zTJz-M#;zkG4HTwSM@L0<_jPuTH>O7B=cLaH1Olx+4c*>TRCD-Er)xgTh7K;t&c*8) zl0ege(d-nQL5)`SM$jTid0>=+>iAaE@~a+c%1oU3G@Jhd6IX||f?71Ts%2*p0hp0p z_X?GClNo&6*x55YF*fOW;MzqsMVTX$-YxqYzOuH~;}3d#!KZ$=;~(x=9_h3tM7xH^ zJ&dxnr05_0CVT52yS<~mZ|pdjlNP&rY1vit3r;on{r05~q9YwVPx{V{c_+ID2eN&> zU|d{;#p(nApLclsj>8M)=dTQpKFy#)eL}cQDeKc<`e$ z1ASu~Zm(6uWqMZpl2v8f{<43|j#tP0oe_LWYZUSyn;vV^80e zkQV#XZ@)w(`sF8Ynws!>-GS$yeD#j6Uz?mA=XBZLd*jH@UwC$mc3+v>5!Mnc6&B(k zA#oZ2hB~2I*s7#78IIciD$EI0^BXDDol>WeQB$}DQaz)L%?y zP1uE8IrT%}$ir)DzjxO)wIw+go*Ni*fB*Myt*n}LvaR>qKYJ}E!hY54JgY@Gex~>N z*FQ>(asAs1Z}$$4mE@k2g` zov!T|v2|uP+E`6U&@8G|5zaHl7%HV17%+lb;R_QXtzjvpOaI);OeCd%Un)dePegttg1UV z-hQI4Gd(TF=L@#9^hHHFDP`F?i55!)M;!Tivj`y_9c?o8PIx*3!nh_d00|y=ziD`I za^sg*ooZ-0bmmQ`C3a42aewE?TQBe5aNm+uH_vT4+D>K47##iJOh;4S)wj<_jtq8> zDm_S|Jk&p8cUl=`0l$AjM8;TRTI|$>&pqVzc>)|q1PdnyB{3-PdF@E=*`Y1J{V2a8 zeaZScxn=1e9Nzhv--ghJfDSID%=Mt1L=!{wD&ZMs@jL@h7iu`F*dvTl1~OHIvS#^F z-6#a(fB2aguOS;vF&_Gsu>JI7Rif|;h-m~yka;IZ`ws4yY^y_HgmB)Wp52EVS5(b< z>fsyh7XCm(hc>2+AObLgOvza?+SmNX&Lb|n^-H%dzJ2X{tH5pEdAz-~{^iXFQj((n z^T%Jg=k~?N>N0F(}==>4qd{sW&*mn0((iuYKa_+ZV1ZYdqY3 z_`N2MC&ks7)ys;Q3^u3L?zALk#1evtKnO|@Xgb|A&@=Yv|GDFd=k6++lg$_-L|I@2 z6xjZq4au2t#q+XfFDalhTXthrN>&`D%;yP6RJ?))J91!6vdpWBcq~>Z&~lR-kOiHA zHm&hCd0Rk}*(Meb0jo1_({GSyRnW66W#t~ste~c8ab;YdC7*7S@})Pt^Ir7v?XSjELlqk*ZAQ!!Rjcl zS)LRZ)6&}Be{Qg_pkQ*!9|%Zk2~jPb1KBCDUY~es@0fJoVk9ht9nCr)Ru@K>)A`(Q^tmBqqglv<{?a#(AfFjV%XC^5(_FM4dg; zdvL5vRmhvRa+$b)aN5K?22V`0Rv?}f{aq18+i*_>;MQsFO@}Nbpa7T^Z_vBtjYjT%2Z+)kp(?e z76DLj#H1HrZL>MsPrl`!8UV%+2`9J+u`?#gqMOnn$Jq$ykR>-`65;FwtpJQtpDYIf zAS30f%#8ezp@G(}cO}^e00>0jU5qk9h!Q6zQ6G<1U`Pm&m|vnn0qq1V3?vD>j4>W; z99k%oMDTH7;n5196hB19C?E?kFNcBvb_P_2AO{vu0=pRnKneUDSP1Yk1V!fOLEzEO zfJ)5wS*FlM1kQqyahCE#VQ#dLZy^z;=upr}y3mkOou|`tCYcC3eIGzXsV z8}1JskxcWc7}w17KVbH8gXu^X@<|^w^$`{nk+vM|ua4QfEoy1`AAOy`S%fT~70g*BwfND8q6A;xD-XTG9x|B6( zzNSZ(_HZ;^An4fE0-X#vPb2X@hwX0>%JcWu5CAP0<>YhYgt^f|iG@TUQwmg39mi&C z&lHJa0%a&q7qUN~R*Ow{seq|x6=OUFMi@mz1{4_4JSLjPsjInWNQcB@d{Ry?)YI4) zj{qYTCS&X}m&N)UwEvIMi~z`^)5fL52-VSiiIpVqC?J4_W@13oq$fa#^fft3Ot3aB zMM2OVC?QU7<5=DAp)SFqK#3}6O~#XAz39GmzY0 zw9iFDD@lkJ=0*#%om?h|0stkNFGBP7sV@c!6>g51C2`39hU7N$7)&FTnPw@5?54p| z2Q;W9lz}mw+ty+_RM$7v8Ht&FETM&u)qIRbEcMJb*%v_moHhcsILlRu)>Q&V5+npb zL9?a@>(-t*?9(i9b?oZgoWA;1+dmYt)75TWCjkIG7K7K~G$=y?dMR2kV5kuwCJ26_ zj(|qQk)~1`iiWm_-r*GrPWqxq^}DunWZkeL0|n%NgZVi2&e@g@pc`H4BMxkaQg%CTVKOX*5DT&Dcd`p(GuEnv~gO9qOegbip6G i#4to1*WhKWyZj#r%!^anUvQxS0000 Date: Sat, 4 Apr 2026 21:06:35 +0200 Subject: [PATCH 15/54] style(spiders robots feat): Adjustments for maintainability --- scrapling/spiders/engine.py | 7 ++++--- scrapling/spiders/robotstxt.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scrapling/spiders/engine.py b/scrapling/spiders/engine.py index 163101b..f395a70 100644 --- a/scrapling/spiders/engine.py +++ b/scrapling/spiders/engine.py @@ -7,9 +7,9 @@ from anyio import Path as AsyncPath from anyio import create_task_group, CapacityLimiter, create_memory_object_stream, EndOfStream from scrapling.core.utils import log -from scrapling.spiders.request import Request from scrapling.spiders.scheduler import Scheduler from scrapling.spiders.session import SessionManager +from scrapling.spiders.request import Request, Response from scrapling.spiders.robotstxt import RobotsTxtManager from scrapling.spiders.result import CrawlStats, ItemList from scrapling.spiders.checkpoint import CheckpointManager, CheckpointData @@ -44,7 +44,7 @@ class CrawlerEngine: if self.spider.robots_txt_obey: - async def _fetch_robots(url: str, sid: str): + async def _fetch_robots(url: str, sid: str) -> Response: return await self.session_manager.fetch(Request(url, sid=sid)) self._robots_manager: Optional[RobotsTxtManager] = RobotsTxtManager(_fetch_robots) @@ -101,7 +101,7 @@ class CrawlerEngine: # Domains discovered mid-crawl (not in start_urls/allowed_domains) will fetch here. # Two concurrent callbacks hitting the same new domain can each trigger a fetch; # the second write is a no-op in effect (same content), but the extra request is accepted. - c_delay, r_rate = await robots_manager._get_delay_directives(request.url, request.sid) + c_delay, r_rate = await robots_manager.get_delay_directives(request.url, request.sid) delay = self.spider.download_delay robots_enforced_delay = False @@ -133,6 +133,7 @@ class CrawlerEngine: """Get or create a per-domain concurrency limiter if enabled, otherwise use the global limiter.""" if self.spider.concurrent_requests_per_domain: self._domain_limiters.setdefault(domain, CapacityLimiter(self.spider.concurrent_requests_per_domain)) + # robots.txt-created limiters always apply even with `concurrent_requests_per_domain = 0` (if enabled) return self._domain_limiters.get(domain, self._global_limiter) def _normalize_request(self, request: Request) -> None: diff --git a/scrapling/spiders/robotstxt.py b/scrapling/spiders/robotstxt.py index c64c66e..867698b 100644 --- a/scrapling/spiders/robotstxt.py +++ b/scrapling/spiders/robotstxt.py @@ -116,7 +116,7 @@ class RobotsTxtManager: return (rate.requests, rate.seconds) return None - async def _get_delay_directives(self, url: str, sid: str) -> tuple[Optional[float], Optional[tuple[int, int]]]: + async def get_delay_directives(self, url: str, sid: str) -> tuple[Optional[float], Optional[tuple[int, int]]]: """Return both crawl-delay and request-rate in a single parser lookup. Args: From af83a11aa7873dc8342edf1279a2cb00aaf677a9 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 00:09:17 +0200 Subject: [PATCH 16/54] fix(spider robots): solve multiple issues with cache prefetch --- scrapling/spiders/engine.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/scrapling/spiders/engine.py b/scrapling/spiders/engine.py index f395a70..3b41fb5 100644 --- a/scrapling/spiders/engine.py +++ b/scrapling/spiders/engine.py @@ -1,6 +1,7 @@ import json import pprint from pathlib import Path +from urllib.parse import urlparse import anyio from anyio import Path as AsyncPath @@ -294,22 +295,28 @@ class CrawlerEngine: async def _prefetch_robots_txt(self) -> None: """Pre-warm the robots.txt cache before the crawl loop starts. - Uses allowed_domains if configured, otherwise falls back to unique domains - extracted from start_urls via Request.domain. Both paths use https. + Uses allowed_domains if configured (defaults to https since bare domains + have no scheme), otherwise falls back to unique domains extracted from + start_urls preserving the original scheme. """ if not self._robots_manager: return if self._allowed_domains: - domains = self._allowed_domains + # allowed_domains are bare strings like "example.com", no scheme available + seed_urls = [f"http://{domain}/" for domain in self._allowed_domains] elif self.spider.start_urls: - # Deduplicate by domain so we spawn exactly one task per domain - domains = {Request(url).domain for url in self.spider.start_urls} + # Deduplicate by netloc, preserving the scheme from the first URL per domain + seen: set[str] = set() + seed_urls = [] + for url in self.spider.start_urls: + parsed = urlparse(url) + if parsed.netloc not in seen: + seen.add(parsed.netloc) + seed_urls.append(f"{parsed.scheme}://{parsed.netloc}/") else: return - seed_urls = [f"https://{domain}/" for domain in domains] - await self._robots_manager.prefetch(seed_urls, self.session_manager.default_session_id) async def crawl(self) -> CrawlStats: From ec487d37e87f9a17d8df917cafd02e8fe26a3229 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 01:39:05 +0200 Subject: [PATCH 17/54] fix(spider): Make delay in robots file don't affect user's concurrency settings --- scrapling/spiders/engine.py | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/scrapling/spiders/engine.py b/scrapling/spiders/engine.py index 3b41fb5..3d75564 100644 --- a/scrapling/spiders/engine.py +++ b/scrapling/spiders/engine.py @@ -85,8 +85,6 @@ class CrawlerEngine: Takes the max of the spider's configured delay and any robots.txt directives (Crawl-delay / Request-rate). Result is cached per domain. - Also pre-creates a per-domain concurrency limiter of 1 when robots.txt - enforces any delay, before the caller acquires it via _rate_limiter(). """ robots_manager = self._robots_manager if robots_manager is None: @@ -94,48 +92,32 @@ class CrawlerEngine: domain = request.domain - # Return cached delay if available if domain in self._domain_delays: return self._domain_delays[domain] # For domains covered by _prefetch_robots_txt this is a local parser read. # Domains discovered mid-crawl (not in start_urls/allowed_domains) will fetch here. - # Two concurrent callbacks hitting the same new domain can each trigger a fetch; - # the second write is a no-op in effect (same content), but the extra request is accepted. c_delay, r_rate = await robots_manager.get_delay_directives(request.url, request.sid) delay = self.spider.download_delay - robots_enforced_delay = False if r_rate: req_count, period = r_rate if req_count > 0: delay = max(delay, period / req_count) - robots_enforced_delay = True if c_delay is not None: delay = max(delay, c_delay) - robots_enforced_delay = True self._domain_delays[domain] = delay - - # Enforce 1 concurrent request for this domain when robots.txt adds a delay - if robots_enforced_delay and delay > 0 and domain not in self._domain_limiters: - if self.spider.concurrent_requests_per_domain: - log.warning( - f"robots.txt for {domain} enforces a delay, overriding" - f" concurrent_requests_per_domain={self.spider.concurrent_requests_per_domain} with 1" - ) - self._domain_limiters[domain] = CapacityLimiter(1) - return delay def _rate_limiter(self, domain: str) -> CapacityLimiter: """Get or create a per-domain concurrency limiter if enabled, otherwise use the global limiter.""" if self.spider.concurrent_requests_per_domain: self._domain_limiters.setdefault(domain, CapacityLimiter(self.spider.concurrent_requests_per_domain)) - # robots.txt-created limiters always apply even with `concurrent_requests_per_domain = 0` (if enabled) - return self._domain_limiters.get(domain, self._global_limiter) + return self._domain_limiters[domain] + return self._global_limiter def _normalize_request(self, request: Request) -> None: """Normalize request fields before enqueueing. @@ -154,8 +136,6 @@ class CrawlerEngine: self.stats.robots_disallowed_count += 1 log.debug(f"Request disallowed by robots.txt: {request.url}") return - # Must be called before _rate_limiter: may create CapacityLimiter(1) in _domain_limiters - # when robots.txt enforces a delay, which _rate_limiter then picks up. delay = await self._get_domain_delay(request) else: delay = self.spider.download_delay From 556a90f645f4aaecbc95de899f6cc6d2807e184a Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 01:43:06 +0200 Subject: [PATCH 18/54] refactor(spider): prefetch robots.txt from start_urls only Stop using allowed_domains for robots.txt prefetch since bare domain strings have no scheme info. Domains discovered mid-crawl via requests still fetch robots.txt lazily. --- scrapling/spiders/engine.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/scrapling/spiders/engine.py b/scrapling/spiders/engine.py index 3d75564..7729d62 100644 --- a/scrapling/spiders/engine.py +++ b/scrapling/spiders/engine.py @@ -275,27 +275,19 @@ class CrawlerEngine: async def _prefetch_robots_txt(self) -> None: """Pre-warm the robots.txt cache before the crawl loop starts. - Uses allowed_domains if configured (defaults to https since bare domains - have no scheme), otherwise falls back to unique domains extracted from - start_urls preserving the original scheme. + Extracts unique domains from start_urls, preserving the original scheme. """ - if not self._robots_manager: + if not self._robots_manager or not self.spider.start_urls: return - if self._allowed_domains: - # allowed_domains are bare strings like "example.com", no scheme available - seed_urls = [f"http://{domain}/" for domain in self._allowed_domains] - elif self.spider.start_urls: - # Deduplicate by netloc, preserving the scheme from the first URL per domain - seen: set[str] = set() - seed_urls = [] - for url in self.spider.start_urls: - parsed = urlparse(url) - if parsed.netloc not in seen: - seen.add(parsed.netloc) - seed_urls.append(f"{parsed.scheme}://{parsed.netloc}/") - else: - return + # Deduplicate by netloc, preserving the scheme from the first URL per domain + seen: set[str] = set() + seed_urls: list[str] = [] + for url in self.spider.start_urls: + parsed = urlparse(url) + if parsed.netloc not in seen: + seen.add(parsed.netloc) + seed_urls.append(f"{parsed.scheme}://{parsed.netloc}/") await self._robots_manager.prefetch(seed_urls, self.session_manager.default_session_id) From ea2dd7866b9cfe052279025de790892b15275096 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 01:55:17 +0200 Subject: [PATCH 19/54] refactor(spiders): Make Robots.txt compliance turned off by default Scrapy is turning it off by default --- scrapling/spiders/spider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapling/spiders/spider.py b/scrapling/spiders/spider.py index 52afcbd..6aaa24f 100644 --- a/scrapling/spiders/spider.py +++ b/scrapling/spiders/spider.py @@ -73,7 +73,7 @@ class Spider(ABC): allowed_domains: Set[str] = set() # Robots.txt compliance - robots_txt_obey: bool = True + robots_txt_obey: bool = False # Concurrency settings concurrent_requests: int = 4 From afaf68e7d59e7735e3c6e711f6460dab722a5161 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 02:32:33 +0200 Subject: [PATCH 20/54] fix(spider robots): removing dead code --- scrapling/spiders/engine.py | 2 +- scrapling/spiders/robotstxt.py | 52 -------- tests/spiders/test_engine.py | 14 +-- tests/spiders/test_robotstxt.py | 208 +++++++------------------------- 4 files changed, 43 insertions(+), 233 deletions(-) diff --git a/scrapling/spiders/engine.py b/scrapling/spiders/engine.py index 7729d62..1924298 100644 --- a/scrapling/spiders/engine.py +++ b/scrapling/spiders/engine.py @@ -96,7 +96,7 @@ class CrawlerEngine: return self._domain_delays[domain] # For domains covered by _prefetch_robots_txt this is a local parser read. - # Domains discovered mid-crawl (not in start_urls/allowed_domains) will fetch here. + # Domains discovered mid-crawl (not in start_urls) will fetch here. c_delay, r_rate = await robots_manager.get_delay_directives(request.url, request.sid) delay = self.spider.download_delay diff --git a/scrapling/spiders/robotstxt.py b/scrapling/spiders/robotstxt.py index 867698b..10dc7ab 100644 --- a/scrapling/spiders/robotstxt.py +++ b/scrapling/spiders/robotstxt.py @@ -61,7 +61,6 @@ class RobotsTxtManager: """Check if a URL can be fetched according to the domain's robots.txt. Handles: - - User-agent specific rules (e.g., User-agent: SpinarakBot) - Wildcard user-agent rules (User-agent: *) - Allow/Disallow directives with wildcards (e.g., /*.pdf$) - Allow directives that override Disallow (e.g., Allow: /admin/public-docs/) @@ -80,42 +79,6 @@ class RobotsTxtManager: parser = await self._get_parser(url, sid) return parser.can_fetch(url, "*") - async def get_crawl_delay(self, url: str, sid: str) -> Optional[float]: - """Get the crawl delay for this crawler. - - Uses the wildcard user-agent (*) to get the general crawl delay - that applies to all bots. - - Args: - url: Any URL on the domain to check - sid: Session ID for fetching robots.txt if not yet cached - - Returns: - The crawl delay in seconds, or None if not specified - """ - parser = await self._get_parser(url, sid) - delay = parser.crawl_delay("*") - return float(delay) if delay is not None else None - - async def get_request_rate(self, url: str, sid: str) -> Optional[tuple[int, int]]: - """Get the request rate for this crawler. - - Uses the wildcard user-agent (*) to get the general request rate - that applies to all bots. - - Args: - url: Any URL on the domain to check - sid: Session ID for fetching robots.txt if not yet cached - - Returns: - A tuple of (requests, seconds) if specified, or None if not specified - """ - parser = await self._get_parser(url, sid) - rate = parser.request_rate("*") - if rate is not None: - return (rate.requests, rate.seconds) - return None - async def get_delay_directives(self, url: str, sid: str) -> tuple[Optional[float], Optional[tuple[int, int]]]: """Return both crawl-delay and request-rate in a single parser lookup. @@ -152,18 +115,3 @@ class RobotsTxtManager: async with create_task_group() as tg: for url in urls: tg.start_soon(self._get_parser, url, sid) - - def clear_cache(self, domain: Optional[str] = None) -> None: - """Clear the robots.txt cache. - - Note: the ``sid`` parameter was removed — the cache is now keyed by - domain only, so clearing a domain evicts all sessions at once. - - Args: - domain: If specified, only clear cache for this domain. - If None, clears the entire cache. - """ - if domain is None: - self._cache.clear() - else: - self._cache.pop(domain, None) diff --git a/tests/spiders/test_engine.py b/tests/spiders/test_engine.py index e7382f8..74cf875 100644 --- a/tests/spiders/test_engine.py +++ b/tests/spiders/test_engine.py @@ -940,19 +940,7 @@ class TestPrefetchRobotsTxt: return _fetch, calls @pytest.mark.asyncio - async def test_prefetch_uses_allowed_domains_when_set(self): - fetch_fn, calls = self._make_counting_fetch() - spider = MockSpider(allowed_domains={"a.com", "b.com"}, robots_txt_obey=True) - engine = _make_engine(spider=spider) - engine._robots_manager = RobotsTxtManager(fetch_fn) - - await engine._prefetch_robots_txt() - - fetched_domains = {Request(url).domain for url, _ in calls} - assert fetched_domains == {"a.com", "b.com"} - - @pytest.mark.asyncio - async def test_prefetch_falls_back_to_start_urls_when_no_allowed_domains(self): + async def test_prefetch_uses_start_urls(self): fetch_fn, calls = self._make_counting_fetch() spider = MockSpider(robots_txt_obey=True, start_urls=["https://example.com/page1"]) engine = _make_engine(spider=spider) diff --git a/tests/spiders/test_robotstxt.py b/tests/spiders/test_robotstxt.py index efb8328..c4050c9 100644 --- a/tests/spiders/test_robotstxt.py +++ b/tests/spiders/test_robotstxt.py @@ -53,14 +53,6 @@ Request-rate: 1/10 Disallow: /private/ """ -ROBOTS_WITH_SITEMAP = """\ -User-agent: * -Disallow: - -Sitemap: https://example.com/sitemap.xml -Sitemap: https://example.com/sitemap2.xml -""" - ROBOTS_ALLOW_OVERRIDE = """\ User-agent: * Disallow: /secret/ @@ -157,121 +149,79 @@ class TestCanFetch: # --------------------------------------------------------------------------- -# Tests: get_crawl_delay +# Tests: get_delay_directives # --------------------------------------------------------------------------- -class TestGetCrawlDelay: +class TestGetDelayDirectives: @pytest.mark.asyncio - async def test_returns_float_when_set(self): + async def test_returns_crawl_delay_when_set(self): mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_BASIC)) - delay = await mgr.get_crawl_delay("https://example.com/", "s1") + c_delay, r_rate = await mgr.get_delay_directives("https://example.com/", "s1") - assert delay == 2.0 - assert isinstance(delay, float) + assert c_delay == 2.0 + assert isinstance(c_delay, float) + assert r_rate is None @pytest.mark.asyncio - async def test_returns_none_when_not_set(self): + async def test_returns_request_rate_when_set(self): + mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_WITH_RATE)) + + c_delay, r_rate = await mgr.get_delay_directives("https://example.com/", "s1") + + assert c_delay is None + assert r_rate is not None + assert r_rate == (1, 1) + + @pytest.mark.asyncio + async def test_returns_both_none_when_not_set(self): content = "User-agent: *\nDisallow: /admin/" mgr = RobotsTxtManager(make_fetch_fn(content=content)) - assert await mgr.get_crawl_delay("https://example.com/", "s1") is None + c_delay, r_rate = await mgr.get_delay_directives("https://example.com/", "s1") + + assert c_delay is None + assert r_rate is None @pytest.mark.asyncio - async def test_returns_none_for_empty_robots(self): + async def test_returns_both_none_for_empty_robots(self): mgr = RobotsTxtManager(make_fetch_fn(content="")) - assert await mgr.get_crawl_delay("https://example.com/", "s1") is None + c_delay, r_rate = await mgr.get_delay_directives("https://example.com/", "s1") + + assert c_delay is None + assert r_rate is None @pytest.mark.asyncio - async def test_returns_none_on_fetch_error(self): + async def test_returns_both_none_on_fetch_error(self): async def failing_fetch(url: str, sid: str) -> MockResponse: raise ConnectionError("network failure") mgr = RobotsTxtManager(failing_fetch) - assert await mgr.get_crawl_delay("https://example.com/", "s1") is None + c_delay, r_rate = await mgr.get_delay_directives("https://example.com/", "s1") + + assert c_delay is None + assert r_rate is None @pytest.mark.asyncio - async def test_returns_none_for_non_200_response(self): - mgr = RobotsTxtManager(make_fetch_fn(status=404)) - - assert await mgr.get_crawl_delay("https://example.com/", "s1") is None - - @pytest.mark.asyncio - async def test_fractional_delay(self): + async def test_fractional_crawl_delay(self): content = "User-agent: *\nCrawl-delay: 0.5" mgr = RobotsTxtManager(make_fetch_fn(content=content)) - delay = await mgr.get_crawl_delay("https://example.com/", "s1") + c_delay, _ = await mgr.get_delay_directives("https://example.com/", "s1") - assert delay == 0.5 + assert c_delay == 0.5 @pytest.mark.asyncio async def test_url_path_does_not_affect_result(self): - """Any URL on the same domain should return the same delay.""" mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_BASIC)) - d1 = await mgr.get_crawl_delay("https://example.com/", "s1") - d2 = await mgr.get_crawl_delay("https://example.com/deep/path/page.html", "s1") + r1 = await mgr.get_delay_directives("https://example.com/", "s1") + r2 = await mgr.get_delay_directives("https://example.com/deep/path/page.html", "s1") - assert d1 == d2 - - -# --------------------------------------------------------------------------- -# Tests: get_request_rate -# --------------------------------------------------------------------------- - - -class TestGetRequestRate: - @pytest.mark.asyncio - async def test_returns_tuple_when_set(self): - mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_WITH_RATE)) - - rate = await mgr.get_request_rate("https://example.com/", "s1") - - assert rate is not None - assert isinstance(rate, tuple) - assert len(rate) == 2 - - @pytest.mark.asyncio - async def test_tuple_contains_integers(self): - mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_WITH_RATE)) - - rate = await mgr.get_request_rate("https://example.com/", "s1") - - assert rate is not None - requests, seconds = rate - assert isinstance(requests, int) - assert isinstance(seconds, int) - - @pytest.mark.asyncio - async def test_returns_none_when_not_set(self): - mgr = RobotsTxtManager(make_fetch_fn(content=ROBOTS_BASIC)) - - assert await mgr.get_request_rate("https://example.com/", "s1") is None - - @pytest.mark.asyncio - async def test_returns_none_for_empty_robots(self): - mgr = RobotsTxtManager(make_fetch_fn(content="")) - - assert await mgr.get_request_rate("https://example.com/", "s1") is None - - @pytest.mark.asyncio - async def test_returns_none_on_fetch_error(self): - async def failing_fetch(url: str, sid: str) -> MockResponse: - raise ConnectionError("network failure") - - mgr = RobotsTxtManager(failing_fetch) - - assert await mgr.get_request_rate("https://example.com/", "s1") is None - - @pytest.mark.asyncio - async def test_returns_none_for_non_200_response(self): - mgr = RobotsTxtManager(make_fetch_fn(status=404)) - - assert await mgr.get_request_rate("https://example.com/", "s1") is None + assert r1 == r2 # --------------------------------------------------------------------------- @@ -296,8 +246,7 @@ class TestCachingBehaviour: mgr = RobotsTxtManager(fetch_fn) await mgr.can_fetch("https://example.com/", "s1") - await mgr.get_crawl_delay("https://example.com/", "s1") - await mgr.get_request_rate("https://example.com/", "s1") + await mgr.get_delay_directives("https://example.com/", "s1") assert len(fetch_fn.calls) == 1 @@ -419,9 +368,9 @@ class TestEncoding: return MockResponse(status=200, body=body, encoding="latin-1") mgr = RobotsTxtManager(fetch_fn) - delay = await mgr.get_crawl_delay("https://example.com/", "s1") + c_delay, _ = await mgr.get_delay_directives("https://example.com/", "s1") - assert delay == 3.0 + assert c_delay == 3.0 @pytest.mark.asyncio async def test_bytes_body_decoded_correctly(self): @@ -437,81 +386,6 @@ class TestEncoding: assert await mgr.can_fetch("https://example.com/public/", "s1") is True -# --------------------------------------------------------------------------- -# Tests: clear_cache -# --------------------------------------------------------------------------- - - -class TestClearCache: - @pytest.mark.asyncio - async def test_clear_all_forces_refetch(self): - fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) - mgr = RobotsTxtManager(fetch_fn) - - await mgr.can_fetch("https://example.com/", "s1") - mgr.clear_cache() - await mgr.can_fetch("https://example.com/", "s1") - - assert len(fetch_fn.calls) == 2 - - @pytest.mark.asyncio - async def test_clear_by_domain_only_invalidates_that_domain(self): - fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) - mgr = RobotsTxtManager(fetch_fn) - - await mgr.can_fetch("https://example.com/", "s1") - await mgr.can_fetch("https://other.com/", "s1") - assert len(fetch_fn.calls) == 2 - - mgr.clear_cache(domain="example.com") - - await mgr.can_fetch("https://example.com/", "s1") # refetched - await mgr.can_fetch("https://other.com/", "s1") # still cached - - assert len(fetch_fn.calls) == 3 - - @pytest.mark.asyncio - async def test_clear_by_domain_invalidates_all_sessions(self): - """Clearing a domain evicts the single shared cache entry for all sessions.""" - fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) - mgr = RobotsTxtManager(fetch_fn) - - await mgr.can_fetch("https://example.com/", "s1") - assert len(fetch_fn.calls) == 1 - - mgr.clear_cache(domain="example.com") - - await mgr.can_fetch("https://example.com/", "s1") # refetched — cache was cleared - await mgr.can_fetch("https://example.com/", "s2") # hits the newly warm cache, no fetch - - assert len(fetch_fn.calls) == 2 - - def test_clear_nonexistent_domain_does_not_raise(self): - mgr = RobotsTxtManager(make_fetch_fn()) - mgr.clear_cache(domain="nevervisited.com") # should not raise - - def test_clear_empty_cache_does_not_raise(self): - mgr = RobotsTxtManager(make_fetch_fn()) - mgr.clear_cache() # should not raise - - @pytest.mark.asyncio - async def test_clear_all_empties_cache_completely(self): - fetch_fn = make_fetch_fn(content=ROBOTS_BASIC) - mgr = RobotsTxtManager(fetch_fn) - - await mgr.can_fetch("https://a.com/", "s1") - await mgr.can_fetch("https://b.com/", "s1") - await mgr.can_fetch("https://c.com/", "s1") - assert len(fetch_fn.calls) == 3 - - mgr.clear_cache() - - await mgr.can_fetch("https://a.com/", "s1") - await mgr.can_fetch("https://b.com/", "s1") - await mgr.can_fetch("https://c.com/", "s1") - - assert len(fetch_fn.calls) == 6 - # --------------------------------------------------------------------------- # Tests: concurrent access From 911d3af6324ce0902702207456c9ff5f3c3be6ca Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 02:33:15 +0200 Subject: [PATCH 21/54] build: pumping up protego minimum version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4333d7e..af8f37a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ fetchers = [ "apify-fingerprint-datapoints>=0.12.0", "msgspec>=0.20.0", "anyio>=4.12.1", - "protego>=0.4.0", + "protego>=0.6.0", ] ai = [ "mcp>=1.26.0", From 070338cf248d7bdbf5f70ee599cd5e7490bb07d9 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 02:36:15 +0200 Subject: [PATCH 22/54] fix(spider): only allocate _domain_delays when robots_txt_obey is enabled --- scrapling/spiders/engine.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scrapling/spiders/engine.py b/scrapling/spiders/engine.py index 1924298..f1baf76 100644 --- a/scrapling/spiders/engine.py +++ b/scrapling/spiders/engine.py @@ -54,9 +54,11 @@ class CrawlerEngine: self._global_limiter = CapacityLimiter(spider.concurrent_requests) self._domain_limiters: dict[str, CapacityLimiter] = {} - self._domain_delays: dict[str, float] = {} self._allowed_domains: set[str] = spider.allowed_domains or set() + if self.spider.robots_txt_obey: + self._domain_delays: dict[str, float] = {} + self._active_tasks: int = 0 self._running: bool = False self._items: ItemList = ItemList() @@ -300,7 +302,8 @@ class CrawlerEngine: self._force_stop = False self.stats = CrawlStats(start_time=anyio.current_time()) self._domain_limiters.clear() - self._domain_delays.clear() + if self._robots_manager: + self._domain_delays.clear() # Check for existing checkpoint resuming = (await self._restore_from_checkpoint()) if self._checkpoint_system_enabled else False From 9383bec14ea13c5351ab434ce6d16d50f88988a6 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 03:43:21 +0200 Subject: [PATCH 23/54] style(spider): removing excessive docstrings and unifying the style with the rest of the repo --- scrapling/spiders/engine.py | 4 +-- scrapling/spiders/robotstxt.py | 54 +++++----------------------------- 2 files changed, 8 insertions(+), 50 deletions(-) diff --git a/scrapling/spiders/engine.py b/scrapling/spiders/engine.py index f1baf76..deac28f 100644 --- a/scrapling/spiders/engine.py +++ b/scrapling/spiders/engine.py @@ -136,7 +136,7 @@ class CrawlerEngine: can_fetch = await self._robots_manager.can_fetch(request.url, request.sid) if not can_fetch: self.stats.robots_disallowed_count += 1 - log.debug(f"Request disallowed by robots.txt: {request.url}") + log.info(f"Request disallowed by robots.txt: {request.url}") return delay = await self._get_domain_delay(request) else: @@ -310,11 +310,9 @@ class CrawlerEngine: self._last_checkpoint_time = anyio.current_time() async with self.session_manager: - # Set stats from spider configuration self.stats.concurrent_requests = self.spider.concurrent_requests self.stats.concurrent_requests_per_domain = self.spider.concurrent_requests_per_domain self.stats.download_delay = self.spider.download_delay - await self.spider.on_start(resuming=resuming) await self._prefetch_robots_txt() diff --git a/scrapling/spiders/robotstxt.py b/scrapling/spiders/robotstxt.py index 10dc7ab..18f4263 100644 --- a/scrapling/spiders/robotstxt.py +++ b/scrapling/spiders/robotstxt.py @@ -8,24 +8,7 @@ from scrapling.core.utils import log class RobotsTxtManager: - """Manages fetching, parsing, and caching of robots.txt files. - - Accepts a fetch callable ``(url: str, sid: str) -> Awaitable[Response]`` - so it stays decoupled from any specific session or transport layer. - - All public methods accept only ``(url, sid)`` — domain and scheme are - derived internally from the URL so callers don't pass redundant data. - - Handles all standard robots.txt directives including: - - User-agent specific rules - - Allow/Disallow directives (including wildcards and $ anchors) - - Crawl-delay directives - - robots.txt is a domain-level document and does not vary by session, so the - cache is keyed by domain only. The ``sid`` parameter on public methods - controls which session is used for the initial fetch if the domain is not - yet cached, but all sessions share the same parsed result afterwards. - """ + """Manages fetching, parsing, and caching of robots.txt files.""" def __init__(self, fetch_fn: Callable[[str, str], Awaitable]): self._fetch_fn = fetch_fn @@ -60,21 +43,8 @@ class RobotsTxtManager: async def can_fetch(self, url: str, sid: str) -> bool: """Check if a URL can be fetched according to the domain's robots.txt. - Handles: - - Wildcard user-agent rules (User-agent: *) - - Allow/Disallow directives with wildcards (e.g., /*.pdf$) - - Allow directives that override Disallow (e.g., Allow: /admin/public-docs/) - - Uses the wildcard user-agent (*) which matches standard robots.txt directives - that apply to all bots. This is the conservative approach — if a URL is - disallowed for all bots, we respect that. - - Args: - url: The full URL to check - sid: Session ID for fetching robots.txt if not yet cached - - Returns: - True if the URL can be fetched, False otherwise + :param url: The full URL to check + :param sid: Session ID for fetching robots.txt if not yet cached """ parser = await self._get_parser(url, sid) return parser.can_fetch(url, "*") @@ -82,13 +52,8 @@ class RobotsTxtManager: async def get_delay_directives(self, url: str, sid: str) -> tuple[Optional[float], Optional[tuple[int, int]]]: """Return both crawl-delay and request-rate in a single parser lookup. - Args: - url: Any URL on the domain to check - sid: Session ID for fetching robots.txt if not yet cached - - Returns: - A tuple of (crawl_delay, request_rate) where crawl_delay is in seconds - or None, and request_rate is (requests, seconds) or None. + :param url: Any URL on the domain to check + :param sid: Session ID for fetching robots.txt if not yet cached """ parser = await self._get_parser(url, sid) c_delay = parser.crawl_delay("*") @@ -101,13 +66,8 @@ class RobotsTxtManager: async def prefetch(self, urls: list[str], sid: str) -> None: """Pre-warm the robots.txt cache for a list of seed URLs concurrently. - Callers are responsible for deduplicating URLs by domain before calling - this method — passing multiple URLs for the same domain will trigger - redundant fetches since no inflight deduplication exists here. - - Args: - urls: Seed URLs whose domains should be pre-fetched (one per domain). - sid: Session ID to use for the robots.txt fetch requests. + :param urls: Seed URLs whose domains should be pre-fetched (one per domain). + :param sid: Session ID to use for the robots.txt fetch requests. """ if not urls: return From c54081ff1a4d5d3a0111dafd6435602e3c9bd9f7 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 05:26:34 +0200 Subject: [PATCH 24/54] docs(agent): update skill with the latest changes --- agent-skill/Scrapling-Skill.zip | Bin 79160 -> 79799 bytes agent-skill/Scrapling-Skill/SKILL.md | 5 ++-- .../references/spiders/advanced.md | 2 ++ .../references/spiders/architecture.md | 3 ++- .../references/spiders/getting-started.md | 25 ++++++++++++++++++ 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/agent-skill/Scrapling-Skill.zip b/agent-skill/Scrapling-Skill.zip index c89b5d7f7eacb36b382512f54825ce58c9c17374..b03d956754082a4dc96681054135ab4cc3b6c73a 100644 GIT binary patch delta 17125 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 Date: Sun, 5 Apr 2026 05:27:07 +0200 Subject: [PATCH 25/54] docs: updating pages with the new feature --- README.md | 1 + docs/index.md | 1 + docs/spiders/advanced.md | 2 ++ docs/spiders/architecture.md | 3 ++- docs/spiders/getting-started.md | 25 +++++++++++++++++++++++++ 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d577a00..d6b6e41 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ MySpider().start() - 💾 **Pause & Resume**: Checkpoint-based crawl persistence. Press Ctrl+C for a graceful shutdown; restart to resume from where you left off. - 📡 **Streaming Mode**: Stream scraped items as they arrive via `async for item in spider.stream()` with real-time stats - ideal for UI, pipelines, and long-running crawls. - 🛡️ **Blocked Request Detection**: Automatic detection and retry of blocked requests with customizable logic. +- 🤖 **Robots.txt Compliance**: Optional `robots_txt_obey` flag that respects `Disallow`, `Crawl-delay`, and `Request-rate` directives with per-domain caching. - 📦 **Built-in Export**: Export results through hooks and your own pipeline or the built-in JSON/JSONL with `result.items.to_json()` / `result.items.to_jsonl()` respectively. ### Advanced Websites Fetching with Session Support diff --git a/docs/index.md b/docs/index.md index 01d9921..9ff2d04 100644 --- a/docs/index.md +++ b/docs/index.md @@ -99,6 +99,7 @@ MySpider().start() - 💾 **Pause & Resume**: Checkpoint-based crawl persistence. Press Ctrl+C for a graceful shutdown; restart to resume from where you left off. - 📡 **Streaming Mode**: Stream scraped items as they arrive via `async for item in spider.stream()` with real-time stats - ideal for UI, pipelines, and long-running crawls. - 🛡️ **Blocked Request Detection**: Automatic detection and retry of blocked requests with customizable logic. +- 🤖 **Robots.txt Compliance**: Optional `robots_txt_obey` flag that respects `Disallow`, `Crawl-delay`, and `Request-rate` directives with per-domain caching. - 📦 **Built-in Export**: Export results through hooks and your own pipeline or the built-in JSON/JSONL with `result.items.to_json()` / `result.items.to_jsonl()` respectively. ### Advanced Websites Fetching with Session Support diff --git a/docs/spiders/advanced.md b/docs/spiders/advanced.md index c35c2b1..9b1f5b9 100644 --- a/docs/spiders/advanced.md +++ b/docs/spiders/advanced.md @@ -17,6 +17,7 @@ The spider system uses three class attributes to control how aggressively it cra | `concurrent_requests` | `4` | Maximum number of requests being processed at the same time | | `concurrent_requests_per_domain` | `0` | Maximum concurrent requests per domain (0 = no per-domain limit) | | `download_delay` | `0.0` | Seconds to wait before each request | +| `robots_txt_obey` | `False` | Respect robots.txt rules (Disallow, Crawl-delay, Request-rate) | ```python class PoliteSpider(Spider): @@ -234,6 +235,7 @@ print(f"Requests: {stats.requests_count}") print(f"Failed: {stats.failed_requests_count}") print(f"Blocked: {stats.blocked_requests_count}") print(f"Offsite filtered: {stats.offsite_requests_count}") +print(f"Robots.txt disallowed: {stats.robots_disallowed_count}") print(f"Items scraped: {stats.items_scraped}") print(f"Items dropped: {stats.items_dropped}") print(f"Response bytes: {stats.response_bytes}") diff --git a/docs/spiders/architecture.md b/docs/spiders/architecture.md index 4ccfad2..82d388f 100644 --- a/docs/spiders/architecture.md +++ b/docs/spiders/architecture.md @@ -19,7 +19,7 @@ Here's what happens step by step when you run a spider without many details: 1. The **Spider** produces the first batch of `Request` objects. By default, it creates one request for each URL in `start_urls`, but you can override `start_requests()` for custom logic. 2. The **Scheduler** receives requests and places them in a priority queue, and creates fingerprints for them. Higher-priority requests are dequeued first. -3. The **Crawler Engine** asks the **Scheduler** to dequeue the next request, respecting concurrency limits (global and per-domain) and download delays. Once the **Crawler Engine** receives the request, it passes it to the **Session Manager**, which routes it to the correct session based on the request's `sid` (session ID). +3. The **Crawler Engine** asks the **Scheduler** to dequeue the next request, respecting concurrency limits (global and per-domain) and download delays. If `robots_txt_obey` is enabled, the engine checks the domain's robots.txt rules before proceeding -- disallowed requests are dropped silently. Once the **Crawler Engine** receives the request, it passes it to the **Session Manager**, which routes it to the correct session based on the request's `sid` (session ID). 4. The **session** fetches the page and returns a [Response](../fetching/choosing.md#response-object) object to the **Crawler Engine**. The engine records statistics and checks for blocked responses. If the response is blocked, the engine retries the request up to `max_blocked_retries` times. Of course, the blocking detection and the retry logic for blocked requests can be customized. 5. The **Crawler Engine** passes the [Response](../fetching/choosing.md#response-object) to the request's callback. The callback either yields a dictionary, which gets treated as a scraped item, or a follow-up request, which gets sent to the scheduler for queuing. 6. The cycle repeats from step 2 until the scheduler is empty and no tasks are active, or the spider is paused. @@ -91,6 +91,7 @@ If you're coming from Scrapy, here's how Scrapling's spider system maps: | Blocked detection | Through custom middlewares | Built-in `is_blocked()` + `retry_blocked_request()` hooks | | Concurrency | `CONCURRENT_REQUESTS` setting | `concurrent_requests` class attribute | | Domain filtering | `allowed_domains` | `allowed_domains` | +| Robots.txt | `ROBOTSTXT_OBEY` setting | `robots_txt_obey` class attribute | | Pause/Resume | `JOBDIR` setting | `crawldir` constructor argument | | Export | Feed exports | `result.items.to_json()` / `to_jsonl()` or custom through hooks | | Running | `scrapy crawl spider_name` | `MySpider().start()` | diff --git a/docs/spiders/getting-started.md b/docs/spiders/getting-started.md index e619268..9847c98 100644 --- a/docs/spiders/getting-started.md +++ b/docs/spiders/getting-started.md @@ -149,6 +149,31 @@ Subdomains are matched automatically, so setting `allowed_domains = {"example.co When a request is filtered out, it's counted in `stats.offsite_requests_count` so you can see how many were dropped. +## Robots.txt Compliance + +Set `robots_txt_obey = True` to make the spider respect robots.txt rules before crawling any domain: + +```python +class PoliteSpider(Spider): + name = "polite" + start_urls = ["https://example.com"] + robots_txt_obey = True + + async def parse(self, response: Response): + for link in response.css("a::attr(href)").getall(): + yield response.follow(link, callback=self.parse) +``` + +When enabled, the spider will: + +1. **Pre-fetch robots.txt** for all domains in `start_urls` before the crawl begins (concurrently). +2. **Check every request** against the domain's robots.txt `Disallow` rules. Disallowed requests are silently dropped and counted in `stats.robots_disallowed_count`. +3. **Respect `Crawl-delay` and `Request-rate` directives** by taking the maximum of the directive and your configured `download_delay`. This means robots.txt delays never reduce your configured delay, only increase it when needed. + +Robots.txt files are fetched using the spider's default session and cached per domain for the entire crawl. Domains discovered mid-crawl (not in `start_urls`) have their robots.txt fetched on the first request to that domain. + +**Note:** `robots_txt_obey` is turned off by default to avoid surprising behavior. If you enable it, it does not affect your concurrency settings (`concurrent_requests`, `concurrent_requests_per_domain`) -- only the delay between requests is adjusted. + ## What's Next Now that you have the basics, you can explore: From 087634ab67883edbb3d80a4f6a949c5c525a1e94 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 05:28:57 +0200 Subject: [PATCH 26/54] docs: updated the translated files --- docs/README_AR.md | 1 + docs/README_CN.md | 1 + docs/README_DE.md | 1 + docs/README_ES.md | 1 + docs/README_FR.md | 1 + docs/README_JP.md | 1 + docs/README_KR.md | 1 + docs/README_RU.md | 1 + 8 files changed, 8 insertions(+) diff --git a/docs/README_AR.md b/docs/README_AR.md index 01b6d4b..7cc7193 100644 --- a/docs/README_AR.md +++ b/docs/README_AR.md @@ -209,6 +209,7 @@ MySpider().start() - 💾 **إيقاف واستئناف**: استمرارية الزحف القائمة على Checkpoint. اضغط Ctrl+C للإيقاف بسلاسة؛ أعد التشغيل للاستئناف من حيث توقفت. - 📡 **وضع Streaming**: بث العناصر المستخرجة فور وصولها عبر `async for item in spider.stream()` مع إحصائيات فورية - مثالي لواجهات المستخدم وخطوط الأنابيب وعمليات الزحف الطويلة. - 🛡️ **كشف الطلبات المحظورة**: كشف تلقائي وإعادة محاولة للطلبات المحظورة مع منطق قابل للتخصيص. +- 🤖 **الامتثال لـ robots.txt**: خيار `robots_txt_obey` الاختياري الذي يحترم توجيهات `Disallow` و `Crawl-delay` و `Request-rate` مع التخزين المؤقت لكل نطاق. - 📦 **تصدير مدمج**: صدّر النتائج عبر الخطافات وخط الأنابيب الخاص بك أو JSON/JSONL المدمج مع `result.items.to_json()` / `result.items.to_jsonl()` على التوالي. ### جلب متقدم للمواقع مع دعم الجلسات diff --git a/docs/README_CN.md b/docs/README_CN.md index 61cd255..bfc0b6f 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -209,6 +209,7 @@ MySpider().start() - 💾 **暂停与恢复**:基于 Checkpoint 的爬取持久化。按 Ctrl+C 优雅关闭;重启后从上次停止的地方继续。 - 📡 **Streaming 模式**:通过 `async for item in spider.stream()` 以实时统计 Streaming 抓取的数据--非常适合 UI、管道和长时间运行的爬取。 - 🛡️ **被阻止请求检测**:自动检测并重试被阻止的请求,支持自定义逻辑。 +- 🤖 **robots.txt 合规**:可选的 `robots_txt_obey` 标志,支持 `Disallow`、`Crawl-delay` 和 `Request-rate` 指令,并按域名缓存。 - 📦 **内置导出**:通过钩子和您自己的管道导出结果,或使用内置的 JSON/JSONL,分别通过 `result.items.to_json()`/`result.items.to_jsonl()`。 ### 支持 Session 的高级网站获取 diff --git a/docs/README_DE.md b/docs/README_DE.md index 1548260..fbbd7e9 100644 --- a/docs/README_DE.md +++ b/docs/README_DE.md @@ -209,6 +209,7 @@ MySpider().start() - 💾 **Pause & Resume**: Checkpoint-basierte Crawl-Persistenz. Drücken Sie Strg+C für ein kontrolliertes Herunterfahren; starten Sie neu, um dort fortzufahren, wo Sie aufgehört haben. - 📡 **Streaming-Modus**: Gescrapte Elemente in Echtzeit streamen über `async for item in spider.stream()` mit Echtzeit-Statistiken -- ideal für UI, Pipelines und lang laufende Crawls. - 🛡️ **Erkennung blockierter Anfragen**: Automatische Erkennung und Wiederholung blockierter Anfragen mit anpassbarer Logik. +- 🤖 **robots.txt-Konformität**: Optionales `robots_txt_obey`-Flag, das `Disallow`-, `Crawl-delay`- und `Request-rate`-Direktiven mit domainbasiertem Caching respektiert. - 📦 **Integrierter Export**: Ergebnisse über Hooks und Ihre eigene Pipeline oder den integrierten JSON/JSONL-Export mit `result.items.to_json()` / `result.items.to_jsonl()` exportieren. ### Erweitertes Website-Abrufen mit Session-Unterstützung diff --git a/docs/README_ES.md b/docs/README_ES.md index 746da96..fba23a0 100644 --- a/docs/README_ES.md +++ b/docs/README_ES.md @@ -209,6 +209,7 @@ MySpider().start() - 💾 **Pause & Resume**: Persistencia de rastreo basada en Checkpoint. Presiona Ctrl+C para un cierre ordenado; reinicia para continuar desde donde lo dejaste. - 📡 **Modo Streaming**: Transmite elementos extraídos a medida que llegan con `async for item in spider.stream()` con estadísticas en tiempo real - ideal para UI, pipelines y rastreos de larga duración. - 🛡️ **Detección de Solicitudes Bloqueadas**: Detección automática y reintento de solicitudes bloqueadas con lógica personalizable. +- 🤖 **Cumplimiento de robots.txt**: Flag opcional `robots_txt_obey` que respeta las directivas `Disallow`, `Crawl-delay` y `Request-rate` con caché por dominio. - 📦 **Exportación Integrada**: Exporta resultados a través de hooks y tu propio pipeline o el JSON/JSONL integrado con `result.items.to_json()` / `result.items.to_jsonl()` respectivamente. ### Obtención Avanzada de Sitios Web con Soporte de Session diff --git a/docs/README_FR.md b/docs/README_FR.md index ec2fd97..d001bbb 100644 --- a/docs/README_FR.md +++ b/docs/README_FR.md @@ -209,6 +209,7 @@ MySpider().start() - 💾 **Pause & Reprise** : Persistance du crawl basée sur des checkpoints. Appuyez sur Ctrl+C pour un arrêt gracieux ; redémarrez pour reprendre là où vous vous étiez arrêté. - 📡 **Mode streaming** : Diffusez les éléments scrapés en temps réel via `async for item in spider.stream()` avec des statistiques en temps réel - idéal pour les UI, pipelines et crawls de longue durée. - 🛡️ **Détection des requêtes bloquées** : Détection automatique et réessai des requêtes bloquées avec une logique personnalisable. +- 🤖 **Conformité robots.txt** : Flag optionnel `robots_txt_obey` qui respecte les directives `Disallow`, `Crawl-delay` et `Request-rate` avec mise en cache par domaine. - 📦 **Export intégré** : Exportez les résultats via des hooks et votre propre pipeline ou l'export JSON/JSONL intégré avec `result.items.to_json()` / `result.items.to_jsonl()` respectivement. ### Récupération avancée de sites web avec support de sessions diff --git a/docs/README_JP.md b/docs/README_JP.md index 7b6632a..f5c6d37 100644 --- a/docs/README_JP.md +++ b/docs/README_JP.md @@ -209,6 +209,7 @@ MySpider().start() - 💾 **Pause & Resume**:Checkpoint ベースのクロール永続化。Ctrl+C で正常にシャットダウン;再起動すると中断したところから再開。 - 📡 **Streaming モード**:`async for item in spider.stream()` でリアルタイム統計とともにスクレイプされたアイテムを Streaming で受信 - UI、パイプライン、長時間実行クロールに最適。 - 🛡️ **ブロックされたリクエストの検出**:カスタマイズ可能なロジックによるブロックされたリクエストの自動検出とリトライ。 +- 🤖 **robots.txt 準拠**:オプションの `robots_txt_obey` フラグで `Disallow`、`Crawl-delay`、`Request-rate` ディレクティブをドメインごとのキャッシュで遵守。 - 📦 **組み込みエクスポート**:フックや独自のパイプライン、または組み込みの JSON/JSONL で結果をエクスポート。それぞれ`result.items.to_json()` / `result.items.to_jsonl()`を使用。 ### Session サポート付き高度なウェブサイト取得 diff --git a/docs/README_KR.md b/docs/README_KR.md index 1e53fe6..020a3ef 100644 --- a/docs/README_KR.md +++ b/docs/README_KR.md @@ -209,6 +209,7 @@ MySpider().start() - 💾 **일시정지 & 재개**: 체크포인트 기반의 크롤링 영속화. Ctrl+C로 정상 종료하고, 재시작하면 중단된 지점부터 이어갑니다. - 📡 **스트리밍 모드**: `async for item in spider.stream()`으로 스크레이핑된 아이템을 실시간 통계와 함께 스트리밍으로 수신 - UI, 파이프라인, 장시간 크롤링에 적합합니다. - 🛡️ **차단된 요청 감지**: 커스텀 로직을 통한 차단된 요청의 자동 감지 및 재시도를 지원합니다. +- 🤖 **robots.txt 준수**: 선택적 `robots_txt_obey` 플래그로 `Disallow`, `Crawl-delay`, `Request-rate` 지시문을 도메인별 캐싱과 함께 준수합니다. - 📦 **내장 내보내기**: 훅이나 자체 파이프라인, 또는 내장 JSON/JSONL로 결과를 내보냅니다. 각각 `result.items.to_json()` / `result.items.to_jsonl()`을 사용합니다. ### 세션을 지원하는 고급 웹사이트 가져오기 diff --git a/docs/README_RU.md b/docs/README_RU.md index 7233bd5..fdef497 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -212,6 +212,7 @@ MySpider().start() - 💾 **Pause & Resume**: Persistence обхода на основе Checkpoint'ов. Нажмите Ctrl+C для мягкой остановки; перезапустите, чтобы продолжить с того места, где вы остановились. - 📡 **Режим Streaming**: Стримьте извлечённые элементы по мере их поступления через `async for item in spider.stream()` со статистикой в реальном времени - идеально для UI, конвейеров и длительных обходов. - 🛡️ **Обнаружение заблокированных запросов**: Автоматическое обнаружение и повторная отправка заблокированных запросов с настраиваемой логикой. +- 🤖 **Соответствие robots.txt**: Опциональный флаг `robots_txt_obey`, который учитывает директивы `Disallow`, `Crawl-delay` и `Request-rate` с кэшированием по доменам. - 📦 **Встроенный экспорт**: Экспортируйте результаты через хуки и собственный конвейер или встроенный JSON/JSONL с `result.items.to_json()` / `result.items.to_jsonl()` соответственно. ### Продвинутая загрузка сайтов с поддержкой Session From e7f9adb40a98550a65d92739c67b3353d990fc4f Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 18:41:50 +0200 Subject: [PATCH 27/54] feat(security): default follow_redirects to "safe" for SSRF protection curl_cffi v0.15.0 introduced CurlFollow.SAFE, which follows redirects but rejects those targeting internal/private IPs (loopback, private networks, link-local). This is now the default for all HTTP fetchers, the MCP server, and the shell curl converter. Added FollowRedirects type alias supporting all curl_cffi redirect modes: bool, "safe", "all", "obeycode", "firstonly". --- scrapling/core/_shell_signatures.py | 3 ++- scrapling/core/_types.py | 1 + scrapling/core/ai.py | 9 +++++---- scrapling/core/shell.py | 2 +- scrapling/engines/_browsers/_types.py | 3 ++- scrapling/engines/static.py | 23 ++++++++++++----------- 6 files changed, 23 insertions(+), 18 deletions(-) diff --git a/scrapling/core/_shell_signatures.py b/scrapling/core/_shell_signatures.py index d9a42fc..9778318 100644 --- a/scrapling/core/_shell_signatures.py +++ b/scrapling/core/_shell_signatures.py @@ -8,6 +8,7 @@ from scrapling.core._types import ( Optional, SetCookieParam, SelectorWaitStates, + FollowRedirects, ) # Parameter definitions for shell function signatures (defined once at module level) @@ -26,7 +27,7 @@ _REQUESTS_PARAMS = { "headers": Any, "retries": Optional[int], "retry_delay": Optional[int], - "follow_redirects": Optional[bool], + "follow_redirects": Optional[FollowRedirects], "max_redirects": Optional[int], "verify": Optional[bool], "cert": Optional[str | Tuple[str, str]], diff --git a/scrapling/core/_types.py b/scrapling/core/_types.py index 25ddb5b..fc8a075 100644 --- a/scrapling/core/_types.py +++ b/scrapling/core/_types.py @@ -40,6 +40,7 @@ SelectorWaitStates = Literal["attached", "detached", "hidden", "visible"] PageLoadStates = Literal["commit", "domcontentloaded", "load", "networkidle"] extraction_types = Literal["text", "html", "markdown"] StrOrBytes = Union[str, bytes] +FollowRedirects = Union[bool, Literal["safe", "all", "obeycode", "firstonly"]] # Copied from `playwright._impl._api_structures.SetCookieParam` diff --git a/scrapling/core/ai.py b/scrapling/core/ai.py index 208f3e7..9b33821 100644 --- a/scrapling/core/ai.py +++ b/scrapling/core/ai.py @@ -27,6 +27,7 @@ from scrapling.core._types import ( SetCookieParam, extraction_types, SelectorWaitStates, + FollowRedirects, ) SessionType = Literal["dynamic", "stealthy"] @@ -262,7 +263,7 @@ class ScraplingMCPServer: headers: Optional[Mapping[str, Optional[str]]] = None, cookies: Optional[Dict[str, str]] = None, timeout: Optional[int | float] = 30, - follow_redirects: bool = True, + follow_redirects: FollowRedirects = "safe", max_redirects: int = 30, retries: Optional[int] = 3, retry_delay: Optional[int] = 1, @@ -289,7 +290,7 @@ class ScraplingMCPServer: :param headers: Headers to include in the request. :param cookies: Cookies to use in the request. :param timeout: Number of seconds to wait before timing out. - :param follow_redirects: Whether to follow redirects. Defaults to True. + :param follow_redirects: Whether to follow redirects. Defaults to "safe", which follows redirects but rejects those targeting internal/private IPs (SSRF protection). Pass True to follow all redirects without restriction. :param max_redirects: Maximum number of redirects. Default 30, use -1 for unlimited. :param retries: Number of retry attempts. Defaults to 3. :param retry_delay: Number of seconds to wait between retry attempts. Defaults to 1 second. @@ -335,7 +336,7 @@ class ScraplingMCPServer: headers: Optional[Mapping[str, Optional[str]]] = None, cookies: Optional[Dict[str, str]] = None, timeout: Optional[int | float] = 30, - follow_redirects: bool = True, + follow_redirects: FollowRedirects = "safe", max_redirects: int = 30, retries: Optional[int] = 3, retry_delay: Optional[int] = 1, @@ -362,7 +363,7 @@ class ScraplingMCPServer: :param headers: Headers to include in the request. :param cookies: Cookies to use in the request. :param timeout: Number of seconds to wait before timing out. - :param follow_redirects: Whether to follow redirects. Defaults to True. + :param follow_redirects: Whether to follow redirects. Defaults to "safe", which follows redirects but rejects those targeting internal/private IPs (SSRF protection). Pass True to follow all redirects without restriction. :param max_redirects: Maximum number of redirects. Default 30, use -1 for unlimited. :param retries: Number of retry attempts. Defaults to 3. :param retry_delay: Number of seconds to wait between retry attempts. Defaults to 1 second. diff --git a/scrapling/core/shell.py b/scrapling/core/shell.py index 14a25fe..cb805c6 100644 --- a/scrapling/core/shell.py +++ b/scrapling/core/shell.py @@ -294,7 +294,7 @@ class CurlParser: headers=headers, cookies=cookies, proxy=proxies, - follow_redirects=True, # Scrapling default is True + follow_redirects="safe", # Follows redirects but rejects those to internal/private IPs ) def convert2fetcher(self, curl_command: Request | str) -> Optional[Response]: diff --git a/scrapling/engines/_browsers/_types.py b/scrapling/engines/_browsers/_types.py index 6ab40b9..2741407 100644 --- a/scrapling/engines/_browsers/_types.py +++ b/scrapling/engines/_browsers/_types.py @@ -19,6 +19,7 @@ from scrapling.core._types import ( TypeAlias, SetCookieParam, SelectorWaitStates, + FollowRedirects, ) from scrapling.engines.toolbelt.proxy_rotation import ProxyRotator @@ -39,7 +40,7 @@ class RequestsSession(TypedDict, total=False): headers: Optional[Mapping[str, Optional[str]]] retries: Optional[int] retry_delay: Optional[int] - follow_redirects: Optional[bool] + follow_redirects: Optional[FollowRedirects] max_redirects: Optional[int] verify: Optional[bool] cert: Optional[str | Tuple[str, str]] diff --git a/scrapling/engines/static.py b/scrapling/engines/static.py index a962ccf..fae9f83 100644 --- a/scrapling/engines/static.py +++ b/scrapling/engines/static.py @@ -20,6 +20,7 @@ from scrapling.core._types import ( Optional, Awaitable, SUPPORTED_HTTP_METHODS, + FollowRedirects, ) from .toolbelt.custom import Response @@ -77,7 +78,7 @@ class _ConfigurationLogic(ABC): self._default_headers = kwargs.get("headers") or {} self._default_retries = kwargs.get("retries", 3) self._default_retry_delay = kwargs.get("retry_delay", 1) - self._default_follow_redirects = kwargs.get("follow_redirects", True) + self._default_follow_redirects = kwargs.get("follow_redirects", "safe") self._default_max_redirects = kwargs.get("max_redirects", 30) self._default_verify = kwargs.get("verify", True) self._default_cert = kwargs.get("cert") or None @@ -285,7 +286,7 @@ class _SyncSessionLogic(_ConfigurationLogic): - headers: Headers to include in the request. - cookies: Cookies to use in the request. - timeout: Number of seconds to wait before timing out. - - follow_redirects: Whether to follow redirects. Defaults to True. + - follow_redirects: Whether to follow redirects. Defaults to "safe" (rejects redirects to internal/private IPs). - max_redirects: Maximum number of redirects. Default 30, use -1 for unlimited. - retries: Number of retry attempts. Defaults to 3. - retry_delay: Number of seconds to wait between retry attempts. Defaults to 1 second. @@ -317,7 +318,7 @@ class _SyncSessionLogic(_ConfigurationLogic): - headers: Headers to include in the request. - cookies: Cookies to use in the request. - timeout: Number of seconds to wait before timing out. - - follow_redirects: Whether to follow redirects. Defaults to True. + - follow_redirects: Whether to follow redirects. Defaults to "safe" (rejects redirects to internal/private IPs). - max_redirects: Maximum number of redirects. Default 30, use -1 for unlimited. - retries: Number of retry attempts. Defaults to 3. - retry_delay: Number of seconds to wait between retry attempts. Defaults to 1 second. @@ -349,7 +350,7 @@ class _SyncSessionLogic(_ConfigurationLogic): - headers: Headers to include in the request. - cookies: Cookies to use in the request. - timeout: Number of seconds to wait before timing out. - - follow_redirects: Whether to follow redirects. Defaults to True. + - follow_redirects: Whether to follow redirects. Defaults to "safe" (rejects redirects to internal/private IPs). - max_redirects: Maximum number of redirects. Default 30, use -1 for unlimited. - retries: Number of retry attempts. Defaults to 3. - retry_delay: Number of seconds to wait between retry attempts. Defaults to 1 second. @@ -381,7 +382,7 @@ class _SyncSessionLogic(_ConfigurationLogic): - headers: Headers to include in the request. - cookies: Cookies to use in the request. - timeout: Number of seconds to wait before timing out. - - follow_redirects: Whether to follow redirects. Defaults to True. + - follow_redirects: Whether to follow redirects. Defaults to "safe" (rejects redirects to internal/private IPs). - max_redirects: Maximum number of redirects. Default 30, use -1 for unlimited. - retries: Number of retry attempts. Defaults to 3. - retry_delay: Number of seconds to wait between retry attempts. Defaults to 1 second. @@ -502,7 +503,7 @@ class _ASyncSessionLogic(_ConfigurationLogic): - headers: Headers to include in the request. - cookies: Cookies to use in the request. - timeout: Number of seconds to wait before timing out. - - follow_redirects: Whether to follow redirects. Defaults to True. + - follow_redirects: Whether to follow redirects. Defaults to "safe" (rejects redirects to internal/private IPs). - max_redirects: Maximum number of redirects. Default 30, use -1 for unlimited. - retries: Number of retry attempts. Defaults to 3. - retry_delay: Number of seconds to wait between retry attempts. Defaults to 1 second. @@ -534,7 +535,7 @@ class _ASyncSessionLogic(_ConfigurationLogic): - headers: Headers to include in the request. - cookies: Cookies to use in the request. - timeout: Number of seconds to wait before timing out. - - follow_redirects: Whether to follow redirects. Defaults to True. + - follow_redirects: Whether to follow redirects. Defaults to "safe" (rejects redirects to internal/private IPs). - max_redirects: Maximum number of redirects. Default 30, use -1 for unlimited. - retries: Number of retry attempts. Defaults to 3. - retry_delay: Number of seconds to wait between retry attempts. Defaults to 1 second. @@ -566,7 +567,7 @@ class _ASyncSessionLogic(_ConfigurationLogic): - headers: Headers to include in the request. - cookies: Cookies to use in the request. - timeout: Number of seconds to wait before timing out. - - follow_redirects: Whether to follow redirects. Defaults to True. + - follow_redirects: Whether to follow redirects. Defaults to "safe" (rejects redirects to internal/private IPs). - max_redirects: Maximum number of redirects. Default 30, use -1 for unlimited. - retries: Number of retry attempts. Defaults to 3. - retry_delay: Number of seconds to wait between retry attempts. Defaults to 1 second. @@ -598,7 +599,7 @@ class _ASyncSessionLogic(_ConfigurationLogic): - headers: Headers to include in the request. - cookies: Cookies to use in the request. - timeout: Number of seconds to wait before timing out. - - follow_redirects: Whether to follow redirects. Defaults to True. + - follow_redirects: Whether to follow redirects. Defaults to "safe" (rejects redirects to internal/private IPs). - max_redirects: Maximum number of redirects. Default 30, use -1 for unlimited. - retries: Number of retry attempts. Defaults to 3. - retry_delay: Number of seconds to wait between retry attempts. Defaults to 1 second. @@ -663,7 +664,7 @@ class FetcherSession: headers: Optional[Dict[str, str]] = None, retries: Optional[int] = 3, retry_delay: Optional[int] = 1, - follow_redirects: bool = True, + follow_redirects: FollowRedirects = "safe", max_redirects: int = 30, verify: bool = True, cert: Optional[str | Tuple[str, str]] = None, @@ -682,7 +683,7 @@ class FetcherSession: :param headers: Headers to include in the session with every request. :param retries: Number of retry attempts. Defaults to 3. :param retry_delay: Number of seconds to wait between retry attempts. Defaults to 1 second. - :param follow_redirects: Whether to follow redirects. Defaults to True. + :param follow_redirects: Whether to follow redirects. Defaults to "safe", which follows redirects but rejects those targeting internal/private IPs (SSRF protection). Pass True to follow all redirects without restriction. :param max_redirects: Maximum number of redirects. Default 30, use -1 for unlimited. :param verify: Whether to verify HTTPS certificates. Defaults to True. :param cert: Tuple of (cert, key) filenames for the client certificate. From f756e519f38be6d86a060b8da6f62dd4b10b9447 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 19:40:05 +0200 Subject: [PATCH 28/54] docs: update pages with the new changes --- docs/fetching/static.md | 18 +++++++++--------- docs/overview.md | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/fetching/static.md b/docs/fetching/static.md index 587ebd1..7d2a522 100644 --- a/docs/fetching/static.md +++ b/docs/fetching/static.md @@ -21,7 +21,7 @@ All methods for making requests here share some arguments, so let's discuss them - **url**: The targeted URL - **stealthy_headers**: If enabled (default), it creates and adds real browser headers. It also sets a Google referer header. -- **follow_redirects**: As the name implies, tell the fetcher to follow redirections. **Enabled by default** +- **follow_redirects**: Controls redirect behavior. **Defaults to `"safe"`**, which follows redirects but rejects those targeting internal/private IPs (SSRF protection). Pass `True` to follow all redirects without restriction, or `False` to disable redirects entirely. - **timeout**: The number of seconds to wait for each request to be finished. **Defaults to 30 seconds**. - **retries**: The number of retries that the fetcher will do for failed requests. **Defaults to three retries**. - **retry_delay**: Number of seconds to wait between retry attempts. **Defaults to 1 second**. @@ -57,7 +57,7 @@ Examples are the best way to explain this: >>> from scrapling.fetchers import Fetcher >>> # Basic GET >>> page = Fetcher.get('https://example.com') ->>> page = Fetcher.get('https://scrapling.requestcatcher.com/get', stealthy_headers=True, follow_redirects=True) +>>> page = Fetcher.get('https://scrapling.requestcatcher.com/get', stealthy_headers=True) >>> page = Fetcher.get('https://scrapling.requestcatcher.com/get', proxy='http://username:password@localhost:8030') >>> # With parameters >>> page = Fetcher.get('https://example.com/search', params={'q': 'query'}) @@ -76,7 +76,7 @@ And for asynchronous requests, it's a small adjustment >>> from scrapling.fetchers import AsyncFetcher >>> # Basic GET >>> page = await AsyncFetcher.get('https://example.com') ->>> page = await AsyncFetcher.get('https://scrapling.requestcatcher.com/get', stealthy_headers=True, follow_redirects=True) +>>> page = await AsyncFetcher.get('https://scrapling.requestcatcher.com/get', stealthy_headers=True) >>> page = await AsyncFetcher.get('https://scrapling.requestcatcher.com/get', proxy='http://username:password@localhost:8030') >>> # With parameters >>> page = await AsyncFetcher.get('https://example.com/search', params={'q': 'query'}) @@ -112,7 +112,7 @@ Needless to say, the `page` object in all cases is [Response](choosing.md#respon >>> from scrapling.fetchers import Fetcher >>> # Basic POST >>> page = Fetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, params={'q': 'query'}) ->>> page = Fetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, stealthy_headers=True, follow_redirects=True) +>>> page = Fetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, stealthy_headers=True) >>> page = Fetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, proxy='http://username:password@localhost:8030', impersonate="chrome") >>> # Another example of form-encoded data >>> page = Fetcher.post('https://example.com/submit', data={'username': 'user', 'password': 'pass'}, http3=True) @@ -124,7 +124,7 @@ And for asynchronous requests, it's a small adjustment >>> from scrapling.fetchers import AsyncFetcher >>> # Basic POST >>> page = await AsyncFetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}) ->>> page = await AsyncFetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, stealthy_headers=True, follow_redirects=True) +>>> page = await AsyncFetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, stealthy_headers=True) >>> page = await AsyncFetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, proxy='http://username:password@localhost:8030', impersonate="chrome") >>> # Another example of form-encoded data >>> page = await AsyncFetcher.post('https://example.com/submit', data={'username': 'user', 'password': 'pass'}, http3=True) @@ -136,7 +136,7 @@ And for asynchronous requests, it's a small adjustment >>> from scrapling.fetchers import Fetcher >>> # Basic PUT >>> page = Fetcher.put('https://example.com/update', data={'status': 'updated'}) ->>> page = Fetcher.put('https://example.com/update', data={'status': 'updated'}, stealthy_headers=True, follow_redirects=True, impersonate="chrome") +>>> page = Fetcher.put('https://example.com/update', data={'status': 'updated'}, stealthy_headers=True, impersonate="chrome") >>> page = Fetcher.put('https://example.com/update', data={'status': 'updated'}, proxy='http://username:password@localhost:8030') >>> # Another example of form-encoded data >>> page = Fetcher.put("https://scrapling.requestcatcher.com/put", data={'key': ['value1', 'value2']}) @@ -146,7 +146,7 @@ And for asynchronous requests, it's a small adjustment >>> from scrapling.fetchers import AsyncFetcher >>> # Basic PUT >>> page = await AsyncFetcher.put('https://example.com/update', data={'status': 'updated'}) ->>> page = await AsyncFetcher.put('https://example.com/update', data={'status': 'updated'}, stealthy_headers=True, follow_redirects=True, impersonate="chrome") +>>> page = await AsyncFetcher.put('https://example.com/update', data={'status': 'updated'}, stealthy_headers=True, impersonate="chrome") >>> page = await AsyncFetcher.put('https://example.com/update', data={'status': 'updated'}, proxy='http://username:password@localhost:8030') >>> # Another example of form-encoded data >>> page = await AsyncFetcher.put("https://scrapling.requestcatcher.com/put", data={'key': ['value1', 'value2']}) @@ -156,14 +156,14 @@ And for asynchronous requests, it's a small adjustment ```python >>> from scrapling.fetchers import Fetcher >>> page = Fetcher.delete('https://example.com/resource/123') ->>> page = Fetcher.delete('https://example.com/resource/123', stealthy_headers=True, follow_redirects=True, impersonate="chrome") +>>> page = Fetcher.delete('https://example.com/resource/123', stealthy_headers=True, impersonate="chrome") >>> page = Fetcher.delete('https://example.com/resource/123', proxy='http://username:password@localhost:8030') ``` And for asynchronous requests, it's a small adjustment ```python >>> from scrapling.fetchers import AsyncFetcher >>> page = await AsyncFetcher.delete('https://example.com/resource/123') ->>> page = await AsyncFetcher.delete('https://example.com/resource/123', stealthy_headers=True, follow_redirects=True, impersonate="chrome") +>>> page = await AsyncFetcher.delete('https://example.com/resource/123', stealthy_headers=True, impersonate="chrome") >>> page = await AsyncFetcher.delete('https://example.com/resource/123', proxy='http://username:password@localhost:8030') ``` diff --git a/docs/overview.md b/docs/overview.md index b0d1a4e..c62c7c3 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -264,7 +264,7 @@ page = Fetcher.get('https://scrapling.requestcatcher.com/get', impersonate="chro With that out of the way, here's how to do all HTTP methods: ```python >>> from scrapling.fetchers import Fetcher ->>> page = Fetcher.get('https://scrapling.requestcatcher.com/get', stealthy_headers=True, follow_redirects=True) +>>> page = Fetcher.get('https://scrapling.requestcatcher.com/get', stealthy_headers=True) >>> page = Fetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, proxy='http://username:password@localhost:8030') >>> page = Fetcher.put('https://scrapling.requestcatcher.com/put', data={'key': 'value'}) >>> page = Fetcher.delete('https://scrapling.requestcatcher.com/delete') @@ -272,7 +272,7 @@ With that out of the way, here's how to do all HTTP methods: For Async requests, you will replace the import like below: ```python >>> from scrapling.fetchers import AsyncFetcher ->>> page = await AsyncFetcher.get('https://scrapling.requestcatcher.com/get', stealthy_headers=True, follow_redirects=True) +>>> page = await AsyncFetcher.get('https://scrapling.requestcatcher.com/get', stealthy_headers=True) >>> page = await AsyncFetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, proxy='http://username:password@localhost:8030') >>> page = await AsyncFetcher.put('https://scrapling.requestcatcher.com/put', data={'key': 'value'}) >>> page = await AsyncFetcher.delete('https://scrapling.requestcatcher.com/delete') From d0a19a6fe1657d5c6558c3f56fdf64b0faff7914 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 19:41:10 +0200 Subject: [PATCH 29/54] build: pump version up --- pyproject.toml | 2 +- scrapling/__init__.py | 2 +- server.json | 4 ++-- setup.cfg | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index af8f37a..6ac5d9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "scrapling" # Static version instead of a dynamic version so we can get better layer caching while building docker, check the docker file to understand -version = "0.4.4" +version = "0.4.5" description = "Scrapling is an undetectable, powerful, flexible, high-performance Python library that makes Web Scraping easy and effortless as it should be!" readme = {file = "README.md", content-type = "text/markdown"} license = {file = "LICENSE"} diff --git a/scrapling/__init__.py b/scrapling/__init__.py index 5d20532..a121db4 100644 --- a/scrapling/__init__.py +++ b/scrapling/__init__.py @@ -1,5 +1,5 @@ __author__ = "Karim Shoair (karim.shoair@pm.me)" -__version__ = "0.4.4" +__version__ = "0.4.5" __copyright__ = "Copyright (c) 2024 Karim Shoair" from typing import Any, TYPE_CHECKING diff --git a/server.json b/server.json index 6ef6d9b..d1a95ad 100644 --- a/server.json +++ b/server.json @@ -14,12 +14,12 @@ "mimeType": "image/png" } ], - "version": "0.4.4", + "version": "0.4.5", "packages": [ { "registryType": "pypi", "identifier": "scrapling", - "version": "0.4.4", + "version": "0.4.5", "runtimeHint": "uvx", "packageArguments": [ { diff --git a/setup.cfg b/setup.cfg index 7dcdf68..48cddb3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = scrapling -version = 0.4.4 +version = 0.4.5 author = Karim Shoair author_email = karim.shoair@pm.me description = Scrapling is an undetectable, powerful, flexible, high-performance Python library that makes Web Scraping easy and effortless as it should be! From a61113fc2929addbdc293b7fd70848557fe48f05 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 5 Apr 2026 19:42:00 +0200 Subject: [PATCH 30/54] docs(agent): update skill with the latest changes --- agent-skill/Scrapling-Skill.zip | Bin 79799 -> 80521 bytes agent-skill/Scrapling-Skill/SKILL.md | 6 +++--- .../Scrapling-Skill/examples/README.md | 2 +- .../references/fetching/static.md | 18 +++++++++--------- .../Scrapling-Skill/references/mcp-server.md | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/agent-skill/Scrapling-Skill.zip b/agent-skill/Scrapling-Skill.zip index b03d956754082a4dc96681054135ab4cc3b6c73a..8daa704beeec310c297074e243fa9197cb9cbdb5 100644 GIT binary patch delta 17624 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 delta 17143 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 Y4De=0.4.4"` +`pip install "scrapling[all]>=0.4.5"` Then do this to download all the browsers' dependencies: @@ -104,7 +104,7 @@ Those options are shared between the 4 HTTP request commands: | --proxy | TEXT | Proxy URL in format "http://username:password@host:port" | | -s, --css-selector | TEXT | CSS selector to extract specific content from the page. It returns all matches. | | -p, --params | TEXT | Query parameters in format "key=value" (can be used multiple times) | -| --follow-redirects / --no-follow-redirects | None | Whether to follow redirects (default: True) | +| --follow-redirects / --no-follow-redirects | None | Whether to follow redirects (default: "safe", rejects redirects to internal/private IPs) | | --verify / --no-verify | None | Whether to verify SSL certificates (default: True) | | --impersonate | TEXT | Browser to impersonate. Can be a single browser (e.g., Chrome) or a comma-separated list for random selection (e.g., Chrome, Firefox, Safari). | | --stealthy-headers / --no-stealthy-headers | None | Use stealthy browser headers (default: True) | diff --git a/agent-skill/Scrapling-Skill/examples/README.md b/agent-skill/Scrapling-Skill/examples/README.md index 0c2f031..344dc0f 100644 --- a/agent-skill/Scrapling-Skill/examples/README.md +++ b/agent-skill/Scrapling-Skill/examples/README.md @@ -9,7 +9,7 @@ All examples collect **all 100 quotes across 10 pages**. Make sure Scrapling is installed: ```bash -pip install "scrapling[all]>=0.4.4" +pip install "scrapling[all]>=0.4.5" scrapling install --force ``` diff --git a/agent-skill/Scrapling-Skill/references/fetching/static.md b/agent-skill/Scrapling-Skill/references/fetching/static.md index fa4d2a5..4115483 100644 --- a/agent-skill/Scrapling-Skill/references/fetching/static.md +++ b/agent-skill/Scrapling-Skill/references/fetching/static.md @@ -15,7 +15,7 @@ All methods for making requests here share some arguments, so let's discuss them - **url**: The targeted URL - **stealthy_headers**: If enabled (default), it creates and adds real browser headers. It also sets a Google referer header. -- **follow_redirects**: As the name implies, tell the fetcher to follow redirections. **Enabled by default** +- **follow_redirects**: Controls redirect behavior. **Defaults to `"safe"`**, which follows redirects but rejects those targeting internal/private IPs (SSRF protection). Pass `True` to follow all redirects without restriction, or `False` to disable redirects entirely. - **timeout**: The number of seconds to wait for each request to be finished. **Defaults to 30 seconds**. - **retries**: The number of retries that the fetcher will do for failed requests. **Defaults to three retries**. - **retry_delay**: Number of seconds to wait between retry attempts. **Defaults to 1 second**. @@ -50,7 +50,7 @@ Examples are the best way to explain this: >>> from scrapling.fetchers import Fetcher >>> # Basic GET >>> page = Fetcher.get('https://example.com') ->>> page = Fetcher.get('https://scrapling.requestcatcher.com/get', stealthy_headers=True, follow_redirects=True) +>>> page = Fetcher.get('https://scrapling.requestcatcher.com/get', stealthy_headers=True) >>> page = Fetcher.get('https://scrapling.requestcatcher.com/get', proxy='http://username:password@localhost:8030') >>> # With parameters >>> page = Fetcher.get('https://example.com/search', params={'q': 'query'}) @@ -69,7 +69,7 @@ And for asynchronous requests, it's a small adjustment >>> from scrapling.fetchers import AsyncFetcher >>> # Basic GET >>> page = await AsyncFetcher.get('https://example.com') ->>> page = await AsyncFetcher.get('https://scrapling.requestcatcher.com/get', stealthy_headers=True, follow_redirects=True) +>>> page = await AsyncFetcher.get('https://scrapling.requestcatcher.com/get', stealthy_headers=True) >>> page = await AsyncFetcher.get('https://scrapling.requestcatcher.com/get', proxy='http://username:password@localhost:8030') >>> # With parameters >>> page = await AsyncFetcher.get('https://example.com/search', params={'q': 'query'}) @@ -105,7 +105,7 @@ The `page` object in all cases is a [Response](choosing.md#response-object) obje >>> from scrapling.fetchers import Fetcher >>> # Basic POST >>> page = Fetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, params={'q': 'query'}) ->>> page = Fetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, stealthy_headers=True, follow_redirects=True) +>>> page = Fetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, stealthy_headers=True) >>> page = Fetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, proxy='http://username:password@localhost:8030', impersonate="chrome") >>> # Another example of form-encoded data >>> page = Fetcher.post('https://example.com/submit', data={'username': 'user', 'password': 'pass'}, http3=True) @@ -117,7 +117,7 @@ And for asynchronous requests, it's a small adjustment >>> from scrapling.fetchers import AsyncFetcher >>> # Basic POST >>> page = await AsyncFetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}) ->>> page = await AsyncFetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, stealthy_headers=True, follow_redirects=True) +>>> page = await AsyncFetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, stealthy_headers=True) >>> page = await AsyncFetcher.post('https://scrapling.requestcatcher.com/post', data={'key': 'value'}, proxy='http://username:password@localhost:8030', impersonate="chrome") >>> # Another example of form-encoded data >>> page = await AsyncFetcher.post('https://example.com/submit', data={'username': 'user', 'password': 'pass'}, http3=True) @@ -129,7 +129,7 @@ And for asynchronous requests, it's a small adjustment >>> from scrapling.fetchers import Fetcher >>> # Basic PUT >>> page = Fetcher.put('https://example.com/update', data={'status': 'updated'}) ->>> page = Fetcher.put('https://example.com/update', data={'status': 'updated'}, stealthy_headers=True, follow_redirects=True, impersonate="chrome") +>>> page = Fetcher.put('https://example.com/update', data={'status': 'updated'}, stealthy_headers=True, impersonate="chrome") >>> page = Fetcher.put('https://example.com/update', data={'status': 'updated'}, proxy='http://username:password@localhost:8030') >>> # Another example of form-encoded data >>> page = Fetcher.put("https://scrapling.requestcatcher.com/put", data={'key': ['value1', 'value2']}) @@ -139,7 +139,7 @@ And for asynchronous requests, it's a small adjustment >>> from scrapling.fetchers import AsyncFetcher >>> # Basic PUT >>> page = await AsyncFetcher.put('https://example.com/update', data={'status': 'updated'}) ->>> page = await AsyncFetcher.put('https://example.com/update', data={'status': 'updated'}, stealthy_headers=True, follow_redirects=True, impersonate="chrome") +>>> page = await AsyncFetcher.put('https://example.com/update', data={'status': 'updated'}, stealthy_headers=True, impersonate="chrome") >>> page = await AsyncFetcher.put('https://example.com/update', data={'status': 'updated'}, proxy='http://username:password@localhost:8030') >>> # Another example of form-encoded data >>> page = await AsyncFetcher.put("https://scrapling.requestcatcher.com/put", data={'key': ['value1', 'value2']}) @@ -149,14 +149,14 @@ And for asynchronous requests, it's a small adjustment ```python >>> from scrapling.fetchers import Fetcher >>> page = Fetcher.delete('https://example.com/resource/123') ->>> page = Fetcher.delete('https://example.com/resource/123', stealthy_headers=True, follow_redirects=True, impersonate="chrome") +>>> page = Fetcher.delete('https://example.com/resource/123', stealthy_headers=True, impersonate="chrome") >>> page = Fetcher.delete('https://example.com/resource/123', proxy='http://username:password@localhost:8030') ``` And for asynchronous requests, it's a small adjustment ```python >>> from scrapling.fetchers import AsyncFetcher >>> page = await AsyncFetcher.delete('https://example.com/resource/123') ->>> page = await AsyncFetcher.delete('https://example.com/resource/123', stealthy_headers=True, follow_redirects=True, impersonate="chrome") +>>> page = await AsyncFetcher.delete('https://example.com/resource/123', stealthy_headers=True, impersonate="chrome") >>> page = await AsyncFetcher.delete('https://example.com/resource/123', proxy='http://username:password@localhost:8030') ``` diff --git a/agent-skill/Scrapling-Skill/references/mcp-server.md b/agent-skill/Scrapling-Skill/references/mcp-server.md index 0a63642..7bbc86b 100644 --- a/agent-skill/Scrapling-Skill/references/mcp-server.md +++ b/agent-skill/Scrapling-Skill/references/mcp-server.md @@ -27,7 +27,7 @@ Fast HTTP GET with browser fingerprint impersonation (TLS, headers). Suitable fo | `retry_delay` | int | 1 | Seconds between retries | | `stealthy_headers` | bool | true | Generate realistic browser headers and Google referer | | `http3` | bool | false | Use HTTP/3 (may conflict with `impersonate`) | -| `follow_redirects` | bool | true | Follow HTTP redirects | +| `follow_redirects` | bool or "safe" | "safe" | Follow redirects. "safe" rejects redirects to internal/private IPs | | `max_redirects` | int | 30 | Max redirects (-1 for unlimited) | | `headers` | dict or null | null | Custom request headers | | `cookies` | dict or null | null | Request cookies | From eaa0e8cae6342221fe882fb8e358776fedd37e67 Mon Sep 17 00:00:00 2001 From: voidborne-d Date: Fri, 3 Apr 2026 07:05:32 +0000 Subject: [PATCH 31/54] fix: save checkpoint before cancel_scope.cancel() on force-stop to prevent data loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On force-stop (second Ctrl+C), cancel_scope.cancel() was called BEFORE _save_checkpoint(). Since cancel_scope.cancel() causes all subsequent awaits within the scope to raise Cancelled, the checkpoint write was silently aborted: 1. _save_checkpoint() uses anyio.open_file + rename — both are await checkpoints that get cancelled immediately 2. self.paused never gets set to True (code after the aborted save) 3. The finally block sees 'not self.paused' and calls cleanup() which DELETES the previous checkpoint file Result: a user who ran a long crawl, pressed Ctrl+C twice to force-stop, loses their entire checkpoint irrecoverably. The old checkpoint (from periodic saves or a previous graceful pause) is deleted, and the new one was never written. Fix: move the cancel_scope.cancel() call AFTER the checkpoint save. The save completes normally, self.paused is set to True, and only then does the scope get cancelled to abort in-flight tasks. The finally block correctly sees paused=True and skips cleanup. Adds 6 regression tests covering: - Force-stop checkpoint preservation (core regression) - Graceful pause still works - Force-stop checkpoint is loadable - Normal completion cleanup still works - Force-stop without checkpoint system - Existing checkpoint not deleted on force-stop --- scrapling/spiders/engine.py | 14 +- tests/spiders/test_force_stop_checkpoint.py | 278 ++++++++++++++++++++ 2 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 tests/spiders/test_force_stop_checkpoint.py diff --git a/scrapling/spiders/engine.py b/scrapling/spiders/engine.py index deac28f..e668216 100644 --- a/scrapling/spiders/engine.py +++ b/scrapling/spiders/engine.py @@ -330,11 +330,11 @@ class CrawlerEngine: while self._running: if self._pause_requested: if self._active_tasks == 0 or self._force_stop: - if self._force_stop: - log.warning(f"Force stopping with {self._active_tasks} active tasks") - tg.cancel_scope.cancel() - - # Only save checkpoint if checkpoint system is enabled + # Save checkpoint BEFORE cancelling the scope. + # cancel_scope.cancel() makes all subsequent awaits + # raise Cancelled, which would abort the checkpoint + # write and leave self.paused=False — causing the + # finally block to DELETE the previous checkpoint. if self._checkpoint_system_enabled: await self._save_checkpoint() self.paused = True @@ -342,6 +342,10 @@ class CrawlerEngine: else: log.info("Spider stopped gracefully") + if self._force_stop: + log.warning(f"Force stopping with {self._active_tasks} active tasks") + tg.cancel_scope.cancel() + self._running = False break diff --git a/tests/spiders/test_force_stop_checkpoint.py b/tests/spiders/test_force_stop_checkpoint.py new file mode 100644 index 0000000..e375b1b --- /dev/null +++ b/tests/spiders/test_force_stop_checkpoint.py @@ -0,0 +1,278 @@ +"""Tests for force-stop checkpoint preservation in CrawlerEngine. + +Regression tests for the bug where force-stop (second Ctrl+C) called +cancel_scope.cancel() BEFORE saving the checkpoint, causing: +1. _save_checkpoint() to be aborted by anyio's Cancelled exception +2. self.paused never set to True +3. The finally block to DELETE the previous checkpoint (cleanup runs on non-paused exit) + +Total progress loss: user's checkpoint from a long crawl is irrecoverably deleted. +""" + +import tempfile +from pathlib import Path + +import anyio +import pytest + +from scrapling.spiders.engine import CrawlerEngine +from scrapling.spiders.request import Request +from scrapling.spiders.session import SessionManager +from scrapling.spiders.checkpoint import CheckpointManager, CheckpointData +from scrapling.core._types import Any, Dict, Set, AsyncGenerator + + +# --------------------------------------------------------------------------- +# Mock helpers (minimal, matching test_engine.py conventions) +# --------------------------------------------------------------------------- + + +class MockResponse: + def __init__(self, status=200, body=b"ok", url="https://example.com"): + self.status = status + self.body = body + self.url = url + self.request: Any = None + self.meta: Dict[str, Any] = {} + + def __str__(self): + return self.url + + +class MockSession: + def __init__(self, delay: float = 0.0): + self._is_alive = False + self._delay = delay + + async def __aenter__(self): + self._is_alive = True + return self + + async def __aexit__(self, *args): + self._is_alive = False + + async def fetch(self, url: str, **kwargs): + if self._delay: + await anyio.sleep(self._delay) + resp = MockResponse(url=url) + return resp + + +class _LogCounterStub: + def get_counts(self): + return {"debug": 0, "info": 0, "warning": 0, "error": 0, "critical": 0} + + +class SlowSpider: + """Spider with slow-responding requests to simulate in-flight tasks during force-stop.""" + + def __init__(self, num_urls: int = 10): + self.concurrent_requests = 4 + self.concurrent_requests_per_domain = 0 + self.download_delay = 0.0 + self.max_blocked_retries = 3 + self.allowed_domains = set() + self.fp_include_kwargs = False + self.fp_include_headers = False + self.fp_keep_fragments = False + self.name = "slow_spider" + self._log_counter = _LogCounterStub() + self._num_urls = num_urls + self.on_start_calls = [] + self.on_close_calls = 0 + + async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]: + yield {"url": str(response)} + + async def on_start(self, resuming=False): + self.on_start_calls.append({"resuming": resuming}) + + async def on_close(self): + self.on_close_calls += 1 + + async def on_error(self, request, error): + pass + + async def on_scraped_item(self, item): + return item + + async def is_blocked(self, response): + return False + + async def retry_blocked_request(self, request, response): + return request + + async def start_requests(self) -> AsyncGenerator[Request, None]: + for i in range(self._num_urls): + yield Request(f"https://example.com/page/{i}", sid="default") + + +def _make_engine(spider=None, session=None, crawldir=None, interval=300.0): + spider = spider or SlowSpider() + sm = SessionManager() + sm.add("default", session or MockSession()) + return CrawlerEngine(spider, sm, crawldir=crawldir, interval=interval) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestForceStopCheckpointPreservation: + """Verify checkpoint is saved BEFORE cancel_scope.cancel() on force-stop.""" + + @pytest.mark.anyio + async def test_force_stop_saves_checkpoint_before_cancel(self): + """Core regression test: force-stop must save checkpoint, not delete it.""" + with tempfile.TemporaryDirectory() as tmpdir: + spider = SlowSpider(num_urls=20) + # Use a slow session so tasks are in-flight when we force-stop + session = MockSession(delay=0.5) + engine = _make_engine(spider, session, crawldir=tmpdir, interval=0) + + checkpoint_path = Path(tmpdir) / "checkpoint.pkl" + + async def force_stop_after_delay(): + """Simulate two rapid Ctrl+C presses.""" + # Wait for some tasks to start + await anyio.sleep(0.1) + engine.request_pause() # First Ctrl+C + await anyio.sleep(0.05) + engine.request_pause() # Second Ctrl+C (force stop) + + async with anyio.create_task_group() as tg: + tg.start_soon(force_stop_after_delay) + await engine.crawl() + + # The checkpoint file MUST exist after force-stop + assert checkpoint_path.exists(), ( + "Checkpoint file was not saved (or was deleted) after force-stop. " + "This means the cancel_scope.cancel() ran before _save_checkpoint()." + ) + # Engine must report as paused + assert engine.paused is True + + @pytest.mark.anyio + async def test_graceful_pause_still_saves_checkpoint(self): + """Single Ctrl+C (graceful pause) should save checkpoint as before.""" + with tempfile.TemporaryDirectory() as tmpdir: + spider = SlowSpider(num_urls=5) + session = MockSession(delay=0.3) + engine = _make_engine(spider, session, crawldir=tmpdir, interval=0) + + checkpoint_path = Path(tmpdir) / "checkpoint.pkl" + + async def pause_after_delay(): + await anyio.sleep(0.1) + engine.request_pause() + + async with anyio.create_task_group() as tg: + tg.start_soon(pause_after_delay) + await engine.crawl() + + assert checkpoint_path.exists(), "Checkpoint not saved on graceful pause" + assert engine.paused is True + + @pytest.mark.anyio + async def test_force_stop_checkpoint_is_loadable(self): + """Checkpoint saved during force-stop must be valid and loadable.""" + with tempfile.TemporaryDirectory() as tmpdir: + spider = SlowSpider(num_urls=15) + session = MockSession(delay=0.4) + engine = _make_engine(spider, session, crawldir=tmpdir, interval=0) + + async def force_stop(): + await anyio.sleep(0.1) + engine.request_pause() + await anyio.sleep(0.05) + engine.request_pause() + + async with anyio.create_task_group() as tg: + tg.start_soon(force_stop) + await engine.crawl() + + # Load the checkpoint and verify it's valid + manager = CheckpointManager(tmpdir) + data = await manager.load() + assert data is not None, "Checkpoint data could not be loaded" + assert isinstance(data, CheckpointData) + # seen set should have some entries (requests were enqueued) + assert len(data.seen) > 0 + + @pytest.mark.anyio + async def test_normal_completion_cleans_up_checkpoint(self): + """Normal completion (no pause) should still clean up checkpoint files.""" + with tempfile.TemporaryDirectory() as tmpdir: + spider = SlowSpider(num_urls=2) + session = MockSession(delay=0.0) + engine = _make_engine(spider, session, crawldir=tmpdir, interval=0) + + await engine.crawl() + + checkpoint_path = Path(tmpdir) / "checkpoint.pkl" + # No pause → checkpoint should be cleaned up + assert not checkpoint_path.exists() + assert engine.paused is False + + @pytest.mark.anyio + async def test_force_stop_without_checkpoint_system(self): + """Force-stop without crawldir should not crash.""" + spider = SlowSpider(num_urls=10) + session = MockSession(delay=0.3) + engine = _make_engine(spider, session, crawldir=None) + + async def force_stop(): + await anyio.sleep(0.1) + engine.request_pause() + await anyio.sleep(0.05) + engine.request_pause() + + async with anyio.create_task_group() as tg: + tg.start_soon(force_stop) + await engine.crawl() + + # Should not crash and should not be marked as paused + # (no checkpoint system = no pause state) + assert engine.paused is False + + @pytest.mark.anyio + async def test_force_stop_preserves_existing_checkpoint(self): + """If a checkpoint already exists, force-stop must not delete it.""" + with tempfile.TemporaryDirectory() as tmpdir: + # First run: do a graceful pause to create a checkpoint + spider1 = SlowSpider(num_urls=10) + session1 = MockSession(delay=0.2) + engine1 = _make_engine(spider1, session1, crawldir=tmpdir, interval=0) + + async def pause1(): + await anyio.sleep(0.1) + engine1.request_pause() + + async with anyio.create_task_group() as tg: + tg.start_soon(pause1) + await engine1.crawl() + + checkpoint_path = Path(tmpdir) / "checkpoint.pkl" + assert checkpoint_path.exists(), "First run should create checkpoint" + first_checkpoint_size = checkpoint_path.stat().st_size + + # Second run: force-stop (the fix ensures checkpoint is updated, not deleted) + spider2 = SlowSpider(num_urls=10) + session2 = MockSession(delay=0.3) + engine2 = _make_engine(spider2, session2, crawldir=tmpdir, interval=0) + + async def force_stop2(): + await anyio.sleep(0.1) + engine2.request_pause() + await anyio.sleep(0.05) + engine2.request_pause() + + async with anyio.create_task_group() as tg: + tg.start_soon(force_stop2) + await engine2.crawl() + + # Checkpoint must still exist (updated, not deleted) + assert checkpoint_path.exists(), ( + "Force-stop deleted the checkpoint instead of preserving it" + ) From 9950dde7242f8ea1a8364818853bea0418c86fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?d=20=F0=9F=94=B9?= <258577966+voidborne-d@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:13:19 +0000 Subject: [PATCH 32/54] test: align force-stop regression stubs with dev branch --- tests/spiders/test_force_stop_checkpoint.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/spiders/test_force_stop_checkpoint.py b/tests/spiders/test_force_stop_checkpoint.py index e375b1b..212758e 100644 --- a/tests/spiders/test_force_stop_checkpoint.py +++ b/tests/spiders/test_force_stop_checkpoint.py @@ -75,6 +75,8 @@ class SlowSpider: self.fp_include_kwargs = False self.fp_include_headers = False self.fp_keep_fragments = False + self.robots_txt_obey = False + self.start_urls = [] self.name = "slow_spider" self._log_counter = _LogCounterStub() self._num_urls = num_urls From d1baf1fc465602cdbec780afe2a4cb24a1a1af30 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Tue, 7 Apr 2026 04:08:54 +0200 Subject: [PATCH 33/54] feat(spiders): add a development mode --- scrapling/spiders/cache.py | 79 +++++++ scrapling/spiders/engine.py | 136 +++++++----- scrapling/spiders/result.py | 4 + scrapling/spiders/spider.py | 4 + tests/spiders/test_cache.py | 228 ++++++++++++++++++++ tests/spiders/test_engine.py | 2 + tests/spiders/test_force_stop_checkpoint.py | 2 + 7 files changed, 399 insertions(+), 56 deletions(-) create mode 100644 scrapling/spiders/cache.py create mode 100644 tests/spiders/test_cache.py diff --git a/scrapling/spiders/cache.py b/scrapling/spiders/cache.py new file mode 100644 index 0000000..40d39d3 --- /dev/null +++ b/scrapling/spiders/cache.py @@ -0,0 +1,79 @@ +from base64 import b64encode, b64decode +from pathlib import Path + +import orjson +import anyio +from anyio import Path as AsyncPath + +from scrapling.core.utils import log +from scrapling.core._types import Dict, Optional, Any +from scrapling.engines.toolbelt.custom import Response + + +class ResponseCacheManager: + """Caches HTTP responses to disk for replay during spider development.""" + + def __init__(self, cache_dir: str | Path): + self._cache_dir = AsyncPath(cache_dir) + + def _cache_path(self, fingerprint: bytes) -> AsyncPath: + return self._cache_dir / f"{fingerprint.hex()}.json" + + async def get(self, fingerprint: bytes) -> Optional[Response]: + path = self._cache_path(fingerprint) + if not await path.exists(): + return None + + try: + async with await anyio.open_file(path, "rb") as f: + data: Dict[str, Any] = orjson.loads(await f.read()) + + return Response( + url=data["url"], + content=b64decode(data["content"]), + status=data["status"], + reason=data["reason"], + encoding=data["encoding"], + cookies=data["cookies"], + headers=data["headers"], + request_headers=data["request_headers"], + method=data["method"], + ) + except Exception as e: + log.warning(f"Failed to read cached response for {fingerprint.hex()}: {e}") + return None + + async def put(self, fingerprint: bytes, response: Response, method: str = "GET") -> None: + await self._cache_dir.mkdir(parents=True, exist_ok=True) + temp_path = self._cache_path(fingerprint).with_suffix(".tmp") + + try: + serialized = orjson.dumps( + { + "url": response.url, + "content": b64encode(response.body).decode("ascii"), + "status": response.status, + "reason": response.reason, + "encoding": response.encoding, + "cookies": dict(response.cookies) if isinstance(response.cookies, dict) else {}, + "headers": dict(response.headers), + "request_headers": dict(response.request_headers), + "method": method, + } + ) + async with await anyio.open_file(temp_path, "wb") as f: + await f.write(serialized) + + await temp_path.rename(self._cache_path(fingerprint)) + except Exception as e: + if await temp_path.exists(): + await temp_path.unlink() + log.warning(f"Failed to cache response for {fingerprint.hex()}: {e}") + + async def clear(self) -> None: + if not await self._cache_dir.exists(): + return + async for entry in self._cache_dir.iterdir(): + if entry.suffix == ".json": + await entry.unlink() + log.info(f"Cleared response cache at {self._cache_dir}") diff --git a/scrapling/spiders/engine.py b/scrapling/spiders/engine.py index e668216..6db6866 100644 --- a/scrapling/spiders/engine.py +++ b/scrapling/spiders/engine.py @@ -13,6 +13,7 @@ from scrapling.spiders.session import SessionManager from scrapling.spiders.request import Request, Response from scrapling.spiders.robotstxt import RobotsTxtManager from scrapling.spiders.result import CrawlStats, ItemList +from scrapling.spiders.cache import ResponseCacheManager from scrapling.spiders.checkpoint import CheckpointManager, CheckpointData from scrapling.core._types import Dict, Union, Optional, TYPE_CHECKING, Any, AsyncGenerator @@ -52,6 +53,13 @@ class CrawlerEngine: else: self._robots_manager = None + if self.spider.development_mode: + cache_dir = self.spider.development_cache_dir or f".scrapling_cache/{self.spider.name}" + self._cache_manager: Optional[ResponseCacheManager] = ResponseCacheManager(cache_dir) + log.warning("Development mode enabled -- responses will be cached to disk and replayed on subsequent runs") + else: + self._cache_manager = None + self._global_limiter = CapacityLimiter(spider.concurrent_requests) self._domain_limiters: dict[str, CapacityLimiter] = {} self._allowed_domains: set[str] = spider.allowed_domains or set() @@ -130,57 +138,8 @@ class CrawlerEngine: if not request.sid: request.sid = self.session_manager.default_session_id - async def _process_request(self, request: Request) -> None: - """Download and process a single request.""" - if self._robots_manager: - can_fetch = await self._robots_manager.can_fetch(request.url, request.sid) - if not can_fetch: - self.stats.robots_disallowed_count += 1 - log.info(f"Request disallowed by robots.txt: {request.url}") - return - delay = await self._get_domain_delay(request) - else: - delay = self.spider.download_delay - - async with self._rate_limiter(request.domain): - if delay: - await anyio.sleep(delay) - - if request._session_kwargs.get("proxy"): - self.stats.proxies.append(request._session_kwargs["proxy"]) - if request._session_kwargs.get("proxies"): - self.stats.proxies.append(dict(request._session_kwargs["proxies"])) - try: - response = await self.session_manager.fetch(request) - self.stats.increment_requests_count(request.sid or self.session_manager.default_session_id) - self.stats.increment_response_bytes(request.domain, len(response.body)) - self.stats.increment_status(response.status) - - except Exception as e: - self.stats.failed_requests_count += 1 - await self.spider.on_error(request, e) - return - - if await self.spider.is_blocked(response): - self.stats.blocked_requests_count += 1 - if request._retry_count < self.spider.max_blocked_retries: - retry_request = request.copy() - retry_request._retry_count += 1 - retry_request.priority -= 1 # Don't retry immediately - retry_request.dont_filter = True - retry_request._session_kwargs.pop("proxy", None) - retry_request._session_kwargs.pop("proxies", None) - - new_request = await self.spider.retry_blocked_request(retry_request, response) - self._normalize_request(new_request) - await self.scheduler.enqueue(new_request) - log.info( - f"Scheduled blocked request for retry ({retry_request._retry_count}/{self.spider.max_blocked_retries}): {request.url}" - ) - else: - log.warning(f"Max retries exceeded for blocked request: {request.url}") - return - + async def _run_callbacks(self, request: Request, response: Response) -> None: + """Dispatch response to the request's callback and process yielded items/requests.""" callback = request.callback if request.callback else self.spider.parse try: async for result in callback(response): @@ -210,6 +169,75 @@ class CrawlerEngine: log.error(msg, exc_info=e) await self.spider.on_error(request, e) + async def _process_request(self, request: Request) -> None: + """Download and process a single request.""" + if self._robots_manager: + can_fetch = await self._robots_manager.can_fetch(request.url, request.sid) + if not can_fetch: + self.stats.robots_disallowed_count += 1 + log.info(f"Request disallowed by robots.txt: {request.url}") + return + delay = await self._get_domain_delay(request) + else: + delay = self.spider.download_delay + + if self._cache_manager and request._fp is not None: + cached = await self._cache_manager.get(request._fp) + if cached is not None: + cached.request = request + self.stats.cache_hits += 1 + self.stats.increment_requests_count(request.sid or self.session_manager.default_session_id) + self.stats.increment_response_bytes(request.domain, len(cached.body)) + self.stats.increment_status(cached.status) + log.debug(f"Cache hit: {request.url}") + await self._run_callbacks(request, cached) + return + + async with self._rate_limiter(request.domain): + if delay: + await anyio.sleep(delay) + + if request._session_kwargs.get("proxy"): + self.stats.proxies.append(request._session_kwargs["proxy"]) + if request._session_kwargs.get("proxies"): + self.stats.proxies.append(dict(request._session_kwargs["proxies"])) + try: + response = await self.session_manager.fetch(request) + self.stats.increment_requests_count(request.sid or self.session_manager.default_session_id) + self.stats.increment_response_bytes(request.domain, len(response.body)) + self.stats.increment_status(response.status) + + except Exception as e: + self.stats.failed_requests_count += 1 + await self.spider.on_error(request, e) + return + + if self._cache_manager and request._fp is not None: + self.stats.cache_misses += 1 + await self._cache_manager.put(request._fp, response, request._session_kwargs.get("method", "GET")) + + if await self.spider.is_blocked(response): + self.stats.blocked_requests_count += 1 + if request._retry_count < self.spider.max_blocked_retries: + retry_request = request.copy() + retry_request._retry_count += 1 + retry_request.priority -= 1 # Don't retry immediately + retry_request.dont_filter = True + retry_request._session_kwargs.pop("proxy", None) + retry_request._session_kwargs.pop("proxies", None) + + new_request = await self.spider.retry_blocked_request(retry_request, response) + self._normalize_request(new_request) + await self.scheduler.enqueue(new_request) + log.info( + f"Scheduled blocked request for retry ({retry_request._retry_count}/{self.spider.max_blocked_retries}): {request.url}" + ) + else: + log.warning(f"Max retries exceeded for blocked request: {request.url}") + return + + await self._run_callbacks(request, response) + async def _task_wrapper(self, request: Request) -> None: """Wrapper to track active task count.""" try: @@ -330,11 +358,7 @@ class CrawlerEngine: while self._running: if self._pause_requested: if self._active_tasks == 0 or self._force_stop: - # Save checkpoint BEFORE cancelling the scope. - # cancel_scope.cancel() makes all subsequent awaits - # raise Cancelled, which would abort the checkpoint - # write and leave self.paused=False — causing the - # finally block to DELETE the previous checkpoint. + # Save checkpoint before canceling to avoid data loss if self._checkpoint_system_enabled: await self._save_checkpoint() self.paused = True diff --git a/scrapling/spiders/result.py b/scrapling/spiders/result.py index b374152..3710750 100644 --- a/scrapling/spiders/result.py +++ b/scrapling/spiders/result.py @@ -48,6 +48,8 @@ class CrawlStats: failed_requests_count: int = 0 offsite_requests_count: int = 0 robots_disallowed_count: int = 0 + cache_hits: int = 0 + cache_misses: int = 0 response_bytes: int = 0 items_scraped: int = 0 items_dropped: int = 0 @@ -97,6 +99,8 @@ class CrawlStats: "failed_requests_count": self.failed_requests_count, "offsite_requests_count": self.offsite_requests_count, "robots_disallowed_count": self.robots_disallowed_count, + "cache_hits": self.cache_hits, + "cache_misses": self.cache_misses, "blocked_requests_count": self.blocked_requests_count, "response_status_count": self.response_status_count, "response_bytes": self.response_bytes, diff --git a/scrapling/spiders/spider.py b/scrapling/spiders/spider.py index 6aaa24f..edd9d8b 100644 --- a/scrapling/spiders/spider.py +++ b/scrapling/spiders/spider.py @@ -75,6 +75,10 @@ class Spider(ABC): # Robots.txt compliance robots_txt_obey: bool = False + # Development mode + development_mode: bool = False + development_cache_dir: Optional[str] = None + # Concurrency settings concurrent_requests: int = 4 concurrent_requests_per_domain: int = 0 diff --git a/tests/spiders/test_cache.py b/tests/spiders/test_cache.py new file mode 100644 index 0000000..fc9bc59 --- /dev/null +++ b/tests/spiders/test_cache.py @@ -0,0 +1,228 @@ +"""Tests for the ResponseCacheManager and development_mode integration.""" + +import tempfile +from pathlib import Path + +import anyio +import pytest + +from scrapling.spiders.cache import ResponseCacheManager +from scrapling.spiders.engine import CrawlerEngine +from scrapling.spiders.request import Request +from scrapling.spiders.session import SessionManager +from scrapling.engines.toolbelt.custom import Response +from scrapling.core._types import Any, Dict, Set, AsyncGenerator + + +def _make_response(url: str = "https://example.com", body: bytes = b"hello", status: int = 200) -> Response: + return Response( + url=url, + content=body, + status=status, + reason="OK", + encoding="utf-8", + cookies={}, + headers={"content-type": "text/html"}, + request_headers={"user-agent": "test"}, + method="GET", + ) + + +class TestResponseCacheManager: + + @pytest.mark.anyio + async def test_put_get_roundtrip(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache = ResponseCacheManager(tmpdir) + fp = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14" + original = _make_response(body=b"test content") + + await cache.put(fp, original, "GET") + restored = await cache.get(fp) + + assert restored is not None + assert restored.url == original.url + assert restored.body == original.body + assert restored.status == original.status + assert restored.reason == original.reason + assert restored.encoding == original.encoding + assert dict(restored.headers) == dict(original.headers) + assert dict(restored.request_headers) == dict(original.request_headers) + + @pytest.mark.anyio + async def test_get_cache_miss(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache = ResponseCacheManager(tmpdir) + result = await cache.get(b"\x00" * 20) + assert result is None + + @pytest.mark.anyio + async def test_get_corrupt_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache = ResponseCacheManager(tmpdir) + fp = b"\xaa" * 20 + corrupt_path = Path(tmpdir) / f"{fp.hex()}.json" + corrupt_path.write_text("not valid json{{{") + + result = await cache.get(fp) + assert result is None + + @pytest.mark.anyio + async def test_clear(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache = ResponseCacheManager(tmpdir) + fp1 = b"\x01" * 20 + fp2 = b"\x02" * 20 + + await cache.put(fp1, _make_response(url="https://a.com"), "GET") + await cache.put(fp2, _make_response(url="https://b.com"), "GET") + + assert await cache.get(fp1) is not None + assert await cache.get(fp2) is not None + + await cache.clear() + + assert await cache.get(fp1) is None + assert await cache.get(fp2) is None + + @pytest.mark.anyio + async def test_creates_cache_dir(self): + with tempfile.TemporaryDirectory() as tmpdir: + nested = Path(tmpdir) / "sub" / "dir" + cache = ResponseCacheManager(str(nested)) + await cache.put(b"\x03" * 20, _make_response(), "GET") + assert nested.exists() + + @pytest.mark.anyio + async def test_preserves_binary_body(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache = ResponseCacheManager(tmpdir) + fp = b"\x04" * 20 + binary_body = bytes(range(256)) + await cache.put(fp, _make_response(body=binary_body), "GET") + restored = await cache.get(fp) + assert restored is not None + assert restored.body == binary_body + + +# --------------------------------------------------------------------------- +# Integration tests +# --------------------------------------------------------------------------- + + +class MockSession: + def __init__(self): + self._is_alive = False + self.fetch_count = 0 + + async def __aenter__(self): + self._is_alive = True + return self + + async def __aexit__(self, *args): + self._is_alive = False + + async def fetch(self, url: str, **kwargs): + self.fetch_count += 1 + return _make_response(url=url, body=b"fetched") + + +class _LogCounterStub: + def get_counts(self) -> Dict[str, int]: + return {"debug": 0, "info": 0, "warning": 0, "error": 0, "critical": 0} + + +class MockSpider: + def __init__(self, cache_dir: str): + self.concurrent_requests = 4 + self.concurrent_requests_per_domain = 0 + self.download_delay = 0.0 + self.max_blocked_retries = 3 + self.allowed_domains: Set[str] = set() + self.fp_include_kwargs = False + self.fp_include_headers = False + self.fp_keep_fragments = False + self.robots_txt_obey = False + self.development_mode = True + self.development_cache_dir = cache_dir + self.start_urls: list[str] = [] + self.name = "test_cache_spider" + self._log_counter = _LogCounterStub() + self.scraped_items: list[dict] = [] + + async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]: + yield {"url": str(response)} + + async def on_start(self, resuming: bool = False) -> None: + pass + + async def on_close(self) -> None: + pass + + async def on_error(self, request: Request, error: Exception) -> None: + pass + + async def on_scraped_item(self, item: Dict[str, Any]) -> Dict[str, Any] | None: + self.scraped_items.append(item) + return item + + async def is_blocked(self, response) -> bool: + return False + + async def retry_blocked_request(self, request: Request, response) -> Request: + return request + + async def start_requests(self) -> AsyncGenerator[Request, None]: + yield Request("https://example.com/page1", sid="default") + + +class TestDevelopmentModeIntegration: + + @pytest.mark.anyio + async def test_first_run_fetches_and_caches(self): + with tempfile.TemporaryDirectory() as tmpdir: + session = MockSession() + spider = MockSpider(cache_dir=tmpdir) + sm = SessionManager() + sm.add("default", session) + engine = CrawlerEngine(spider, sm) + + await engine.crawl() + + assert session.fetch_count == 1 + assert engine.stats.cache_misses == 1 + assert engine.stats.cache_hits == 0 + assert engine.stats.items_scraped == 1 + + @pytest.mark.anyio + async def test_second_run_uses_cache(self): + with tempfile.TemporaryDirectory() as tmpdir: + session = MockSession() + spider = MockSpider(cache_dir=tmpdir) + sm = SessionManager() + sm.add("default", session) + engine = CrawlerEngine(spider, sm) + + await engine.crawl() + assert session.fetch_count == 1 + + session2 = MockSession() + spider2 = MockSpider(cache_dir=tmpdir) + sm2 = SessionManager() + sm2.add("default", session2) + engine2 = CrawlerEngine(spider2, sm2) + + await engine2.crawl() + assert session2.fetch_count == 0 + assert engine2.stats.cache_hits == 1 + assert engine2.stats.cache_misses == 0 + assert engine2.stats.items_scraped == 1 + + @pytest.mark.anyio + async def test_disabled_by_default(self): + spider = MockSpider(cache_dir="unused") + spider.development_mode = False + sm = SessionManager() + sm.add("default", MockSession()) + engine = CrawlerEngine(spider, sm) + assert engine._cache_manager is None diff --git a/tests/spiders/test_engine.py b/tests/spiders/test_engine.py index 74cf875..10cbfdc 100644 --- a/tests/spiders/test_engine.py +++ b/tests/spiders/test_engine.py @@ -98,6 +98,8 @@ class MockSpider: self.fp_keep_fragments = fp_keep_fragments self.name = "test_spider" self.robots_txt_obey = robots_txt_obey + self.development_mode = False + self.development_cache_dir = None self.start_urls = start_urls or [] # Tracking lists diff --git a/tests/spiders/test_force_stop_checkpoint.py b/tests/spiders/test_force_stop_checkpoint.py index 212758e..a26e552 100644 --- a/tests/spiders/test_force_stop_checkpoint.py +++ b/tests/spiders/test_force_stop_checkpoint.py @@ -76,6 +76,8 @@ class SlowSpider: self.fp_include_headers = False self.fp_keep_fragments = False self.robots_txt_obey = False + self.development_mode = False + self.development_cache_dir = None self.start_urls = [] self.name = "slow_spider" self._log_counter = _LogCounterStub() From aee6888dd5f0aada4e2ed1a0bab27c28f8550d5e Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Tue, 7 Apr 2026 05:08:45 +0200 Subject: [PATCH 34/54] docs: remove an old sponsor --- README.md | 2 +- docs/README_AR.md | 2 +- docs/README_CN.md | 2 +- docs/README_DE.md | 2 +- docs/README_ES.md | 2 +- docs/README_FR.md | 2 +- docs/README_JP.md | 2 +- docs/README_KR.md | 2 +- docs/README_RU.md | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d6b6e41..ab5fb8a 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ MySpider().start() - + diff --git a/docs/README_AR.md b/docs/README_AR.md index 7cc7193..a66ed18 100644 --- a/docs/README_AR.md +++ b/docs/README_AR.md @@ -192,7 +192,7 @@ MySpider().start() - + diff --git a/docs/README_CN.md b/docs/README_CN.md index bfc0b6f..d75f32b 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -192,7 +192,7 @@ MySpider().start() - + diff --git a/docs/README_DE.md b/docs/README_DE.md index fbbd7e9..5acbf15 100644 --- a/docs/README_DE.md +++ b/docs/README_DE.md @@ -192,7 +192,7 @@ MySpider().start() - + diff --git a/docs/README_ES.md b/docs/README_ES.md index fba23a0..f2cd179 100644 --- a/docs/README_ES.md +++ b/docs/README_ES.md @@ -192,7 +192,7 @@ MySpider().start() - + diff --git a/docs/README_FR.md b/docs/README_FR.md index d001bbb..ce5ac11 100644 --- a/docs/README_FR.md +++ b/docs/README_FR.md @@ -192,7 +192,7 @@ MySpider().start() - + diff --git a/docs/README_JP.md b/docs/README_JP.md index f5c6d37..7c89cb3 100644 --- a/docs/README_JP.md +++ b/docs/README_JP.md @@ -192,7 +192,7 @@ MySpider().start() - + diff --git a/docs/README_KR.md b/docs/README_KR.md index 020a3ef..b74d877 100644 --- a/docs/README_KR.md +++ b/docs/README_KR.md @@ -192,7 +192,7 @@ MySpider().start() - + diff --git a/docs/README_RU.md b/docs/README_RU.md index fdef497..2d636a4 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -195,7 +195,7 @@ MySpider().start() - + From 88d0459cd2ce9a6443f4333f667e560d0751f07b Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Tue, 7 Apr 2026 05:30:51 +0200 Subject: [PATCH 35/54] docs: adding the new development mode --- README.md | 1 + docs/README_AR.md | 1 + docs/README_CN.md | 1 + docs/README_DE.md | 1 + docs/README_ES.md | 1 + docs/README_FR.md | 1 + docs/README_JP.md | 1 + docs/README_KR.md | 1 + docs/README_RU.md | 1 + docs/index.md | 1 + docs/spiders/advanced.md | 47 ++++++++++++++++++++++++++++++++++++ docs/spiders/architecture.md | 4 +++ 12 files changed, 61 insertions(+) diff --git a/README.md b/README.md index ab5fb8a..408cfef 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ MySpider().start() - 📡 **Streaming Mode**: Stream scraped items as they arrive via `async for item in spider.stream()` with real-time stats - ideal for UI, pipelines, and long-running crawls. - 🛡️ **Blocked Request Detection**: Automatic detection and retry of blocked requests with customizable logic. - 🤖 **Robots.txt Compliance**: Optional `robots_txt_obey` flag that respects `Disallow`, `Crawl-delay`, and `Request-rate` directives with per-domain caching. +- 🧪 **Development Mode**: Cache responses to disk on the first run and replay them on subsequent runs - iterate on your `parse()` logic without re-hitting the target servers. - 📦 **Built-in Export**: Export results through hooks and your own pipeline or the built-in JSON/JSONL with `result.items.to_json()` / `result.items.to_jsonl()` respectively. ### Advanced Websites Fetching with Session Support diff --git a/docs/README_AR.md b/docs/README_AR.md index a66ed18..c0de787 100644 --- a/docs/README_AR.md +++ b/docs/README_AR.md @@ -210,6 +210,7 @@ MySpider().start() - 📡 **وضع Streaming**: بث العناصر المستخرجة فور وصولها عبر `async for item in spider.stream()` مع إحصائيات فورية - مثالي لواجهات المستخدم وخطوط الأنابيب وعمليات الزحف الطويلة. - 🛡️ **كشف الطلبات المحظورة**: كشف تلقائي وإعادة محاولة للطلبات المحظورة مع منطق قابل للتخصيص. - 🤖 **الامتثال لـ robots.txt**: خيار `robots_txt_obey` الاختياري الذي يحترم توجيهات `Disallow` و `Crawl-delay` و `Request-rate` مع التخزين المؤقت لكل نطاق. +- 🧪 **وضع التطوير**: تخزين الاستجابات على القرص في التشغيل الأول وإعادة تشغيلها في التشغيلات اللاحقة - كرّر العمل على منطق `parse()` دون الحاجة لإرسال طلبات جديدة إلى الخوادم المستهدفة. - 📦 **تصدير مدمج**: صدّر النتائج عبر الخطافات وخط الأنابيب الخاص بك أو JSON/JSONL المدمج مع `result.items.to_json()` / `result.items.to_jsonl()` على التوالي. ### جلب متقدم للمواقع مع دعم الجلسات diff --git a/docs/README_CN.md b/docs/README_CN.md index d75f32b..6cb9d4e 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -210,6 +210,7 @@ MySpider().start() - 📡 **Streaming 模式**:通过 `async for item in spider.stream()` 以实时统计 Streaming 抓取的数据--非常适合 UI、管道和长时间运行的爬取。 - 🛡️ **被阻止请求检测**:自动检测并重试被阻止的请求,支持自定义逻辑。 - 🤖 **robots.txt 合规**:可选的 `robots_txt_obey` 标志,支持 `Disallow`、`Crawl-delay` 和 `Request-rate` 指令,并按域名缓存。 +- 🧪 **开发模式**:首次运行时将响应缓存到磁盘,后续运行时直接回放 - 在不重新请求目标服务器的情况下迭代你的 `parse()` 逻辑。 - 📦 **内置导出**:通过钩子和您自己的管道导出结果,或使用内置的 JSON/JSONL,分别通过 `result.items.to_json()`/`result.items.to_jsonl()`。 ### 支持 Session 的高级网站获取 diff --git a/docs/README_DE.md b/docs/README_DE.md index 5acbf15..82d336f 100644 --- a/docs/README_DE.md +++ b/docs/README_DE.md @@ -210,6 +210,7 @@ MySpider().start() - 📡 **Streaming-Modus**: Gescrapte Elemente in Echtzeit streamen über `async for item in spider.stream()` mit Echtzeit-Statistiken -- ideal für UI, Pipelines und lang laufende Crawls. - 🛡️ **Erkennung blockierter Anfragen**: Automatische Erkennung und Wiederholung blockierter Anfragen mit anpassbarer Logik. - 🤖 **robots.txt-Konformität**: Optionales `robots_txt_obey`-Flag, das `Disallow`-, `Crawl-delay`- und `Request-rate`-Direktiven mit domainbasiertem Caching respektiert. +- 🧪 **Entwicklungsmodus**: Antworten beim ersten Lauf auf der Festplatte zwischenspeichern und bei weiteren Läufen erneut abspielen - iterieren Sie an Ihrer `parse()`-Logik, ohne die Zielserver erneut abzufragen. - 📦 **Integrierter Export**: Ergebnisse über Hooks und Ihre eigene Pipeline oder den integrierten JSON/JSONL-Export mit `result.items.to_json()` / `result.items.to_jsonl()` exportieren. ### Erweitertes Website-Abrufen mit Session-Unterstützung diff --git a/docs/README_ES.md b/docs/README_ES.md index f2cd179..58dccb8 100644 --- a/docs/README_ES.md +++ b/docs/README_ES.md @@ -210,6 +210,7 @@ MySpider().start() - 📡 **Modo Streaming**: Transmite elementos extraídos a medida que llegan con `async for item in spider.stream()` con estadísticas en tiempo real - ideal para UI, pipelines y rastreos de larga duración. - 🛡️ **Detección de Solicitudes Bloqueadas**: Detección automática y reintento de solicitudes bloqueadas con lógica personalizable. - 🤖 **Cumplimiento de robots.txt**: Flag opcional `robots_txt_obey` que respeta las directivas `Disallow`, `Crawl-delay` y `Request-rate` con caché por dominio. +- 🧪 **Modo de Desarrollo**: Almacena las respuestas en disco en la primera ejecución y las reproduce en ejecuciones posteriores - itera sobre tu lógica de `parse()` sin volver a consultar los servidores objetivo. - 📦 **Exportación Integrada**: Exporta resultados a través de hooks y tu propio pipeline o el JSON/JSONL integrado con `result.items.to_json()` / `result.items.to_jsonl()` respectivamente. ### Obtención Avanzada de Sitios Web con Soporte de Session diff --git a/docs/README_FR.md b/docs/README_FR.md index ce5ac11..a452210 100644 --- a/docs/README_FR.md +++ b/docs/README_FR.md @@ -210,6 +210,7 @@ MySpider().start() - 📡 **Mode streaming** : Diffusez les éléments scrapés en temps réel via `async for item in spider.stream()` avec des statistiques en temps réel - idéal pour les UI, pipelines et crawls de longue durée. - 🛡️ **Détection des requêtes bloquées** : Détection automatique et réessai des requêtes bloquées avec une logique personnalisable. - 🤖 **Conformité robots.txt** : Flag optionnel `robots_txt_obey` qui respecte les directives `Disallow`, `Crawl-delay` et `Request-rate` avec mise en cache par domaine. +- 🧪 **Mode développement** : Mettez les réponses en cache sur le disque lors de la première exécution et rejouez-les lors des exécutions suivantes - itérez sur votre logique `parse()` sans solliciter à nouveau les serveurs cibles. - 📦 **Export intégré** : Exportez les résultats via des hooks et votre propre pipeline ou l'export JSON/JSONL intégré avec `result.items.to_json()` / `result.items.to_jsonl()` respectivement. ### Récupération avancée de sites web avec support de sessions diff --git a/docs/README_JP.md b/docs/README_JP.md index 7c89cb3..9a2a29d 100644 --- a/docs/README_JP.md +++ b/docs/README_JP.md @@ -210,6 +210,7 @@ MySpider().start() - 📡 **Streaming モード**:`async for item in spider.stream()` でリアルタイム統計とともにスクレイプされたアイテムを Streaming で受信 - UI、パイプライン、長時間実行クロールに最適。 - 🛡️ **ブロックされたリクエストの検出**:カスタマイズ可能なロジックによるブロックされたリクエストの自動検出とリトライ。 - 🤖 **robots.txt 準拠**:オプションの `robots_txt_obey` フラグで `Disallow`、`Crawl-delay`、`Request-rate` ディレクティブをドメインごとのキャッシュで遵守。 +- 🧪 **開発モード**:初回実行時にレスポンスをディスクにキャッシュし、以降の実行ではそれを再生 - ターゲットサーバーに再リクエストすることなく `parse()` ロジックを反復開発できます。 - 📦 **組み込みエクスポート**:フックや独自のパイプライン、または組み込みの JSON/JSONL で結果をエクスポート。それぞれ`result.items.to_json()` / `result.items.to_jsonl()`を使用。 ### Session サポート付き高度なウェブサイト取得 diff --git a/docs/README_KR.md b/docs/README_KR.md index b74d877..6d79389 100644 --- a/docs/README_KR.md +++ b/docs/README_KR.md @@ -210,6 +210,7 @@ MySpider().start() - 📡 **스트리밍 모드**: `async for item in spider.stream()`으로 스크레이핑된 아이템을 실시간 통계와 함께 스트리밍으로 수신 - UI, 파이프라인, 장시간 크롤링에 적합합니다. - 🛡️ **차단된 요청 감지**: 커스텀 로직을 통한 차단된 요청의 자동 감지 및 재시도를 지원합니다. - 🤖 **robots.txt 준수**: 선택적 `robots_txt_obey` 플래그로 `Disallow`, `Crawl-delay`, `Request-rate` 지시문을 도메인별 캐싱과 함께 준수합니다. +- 🧪 **개발 모드**: 첫 실행 시 응답을 디스크에 캐싱하고 이후 실행에서는 캐시된 응답을 재생합니다 - 대상 서버에 다시 요청하지 않고 `parse()` 로직을 반복 개발할 수 있습니다. - 📦 **내장 내보내기**: 훅이나 자체 파이프라인, 또는 내장 JSON/JSONL로 결과를 내보냅니다. 각각 `result.items.to_json()` / `result.items.to_jsonl()`을 사용합니다. ### 세션을 지원하는 고급 웹사이트 가져오기 diff --git a/docs/README_RU.md b/docs/README_RU.md index 2d636a4..0fa251f 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -213,6 +213,7 @@ MySpider().start() - 📡 **Режим Streaming**: Стримьте извлечённые элементы по мере их поступления через `async for item in spider.stream()` со статистикой в реальном времени - идеально для UI, конвейеров и длительных обходов. - 🛡️ **Обнаружение заблокированных запросов**: Автоматическое обнаружение и повторная отправка заблокированных запросов с настраиваемой логикой. - 🤖 **Соответствие robots.txt**: Опциональный флаг `robots_txt_obey`, который учитывает директивы `Disallow`, `Crawl-delay` и `Request-rate` с кэшированием по доменам. +- 🧪 **Режим разработки**: Кэшируйте ответы на диск при первом запуске и воспроизводите их при последующих запусках - итерируйте над логикой `parse()`, не отправляя повторные запросы к целевым серверам. - 📦 **Встроенный экспорт**: Экспортируйте результаты через хуки и собственный конвейер или встроенный JSON/JSONL с `result.items.to_json()` / `result.items.to_jsonl()` соответственно. ### Продвинутая загрузка сайтов с поддержкой Session diff --git a/docs/index.md b/docs/index.md index 9ff2d04..ae41d6c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -100,6 +100,7 @@ MySpider().start() - 📡 **Streaming Mode**: Stream scraped items as they arrive via `async for item in spider.stream()` with real-time stats - ideal for UI, pipelines, and long-running crawls. - 🛡️ **Blocked Request Detection**: Automatic detection and retry of blocked requests with customizable logic. - 🤖 **Robots.txt Compliance**: Optional `robots_txt_obey` flag that respects `Disallow`, `Crawl-delay`, and `Request-rate` directives with per-domain caching. +- 🧪 **Development Mode**: Cache responses to disk on the first run and replay them on subsequent runs - iterate on your `parse()` logic without re-hitting the target servers. - 📦 **Built-in Export**: Export results through hooks and your own pipeline or the built-in JSON/JSONL with `result.items.to_json()` / `result.items.to_jsonl()` respectively. ### Advanced Websites Fetching with Session Support diff --git a/docs/spiders/advanced.md b/docs/spiders/advanced.md index 9b1f5b9..98e930a 100644 --- a/docs/spiders/advanced.md +++ b/docs/spiders/advanced.md @@ -97,6 +97,51 @@ async def on_start(self, resuming: bool = False): self.logger.info("Starting fresh crawl") ``` +## Development Mode + +When you're iterating on a spider's `parse()` logic, re-hitting the target servers on every run is slow and noisy. Development mode caches every response to disk on the first run and replays them from disk on subsequent runs, so you can tweak your selectors and re-run the spider as many times as you want without making a single network request. + +Enable it by setting `development_mode = True` on your spider: + +```python +class MySpider(Spider): + name = "my_spider" + start_urls = ["https://example.com"] + development_mode = True + + async def parse(self, response: Response): + yield {"title": response.css("title::text").get("")} +``` + +The first run fetches normally and stores each response on disk. Every subsequent run serves the same requests from the cache, skipping the network entirely. + +### Cache Location + +By default, responses are cached in `.scrapling_cache/{spider.name}/` relative to the current working directory (where you ran the spider from, **not** where the spider script lives). You can override the location with `development_cache_dir`: + +```python +class MySpider(Spider): + name = "my_spider" + start_urls = ["https://example.com"] + development_mode = True + development_cache_dir = "/tmp/my_spider_cache" +``` + +### How It Works + +1. **Cache key**: Each response is keyed by the request's fingerprint, so any change to fingerprint-affecting attributes (`fp_include_kwargs`, `fp_include_headers`, `fp_keep_fragments`) will produce a fresh fetch. +2. **Storage format**: One JSON file per response, named `{fingerprint_hex}.json`. The body is base64-encoded so binary content is preserved exactly. Writes are atomic (temp file + rename). +3. **Replay**: On a cache hit, the engine skips the network entirely, including `download_delay`, rate limiting, and the `is_blocked()` retry path. The cached response goes straight to your callback. +4. **Stats**: Cached requests still count toward `requests_count`, `response_bytes`, and the per-status counters, so your stat output looks the same as a normal crawl. Two extra counters, `cache_hits` and `cache_misses`, let you see how the cache performed. + +### Clearing the Cache + +There's no automatic expiration. To force a fresh crawl, delete the cache directory or call the manager's `clear()` method directly. + +!!! warning + + Development mode is meant for development, not production. Cached responses never expire, and replay bypasses rate limiting and blocked-request retries. Don't ship a spider with `development_mode = True`. + ## Streaming For long-running spiders or applications that need real-time access to scraped items, use the `stream()` method instead of `start()`: @@ -236,6 +281,8 @@ print(f"Failed: {stats.failed_requests_count}") print(f"Blocked: {stats.blocked_requests_count}") print(f"Offsite filtered: {stats.offsite_requests_count}") print(f"Robots.txt disallowed: {stats.robots_disallowed_count}") +print(f"Cache hits: {stats.cache_hits}") +print(f"Cache misses: {stats.cache_misses}") print(f"Items scraped: {stats.items_scraped}") print(f"Items dropped: {stats.items_dropped}") print(f"Response bytes: {stats.response_bytes}") diff --git a/docs/spiders/architecture.md b/docs/spiders/architecture.md index 82d388f..dc360b8 100644 --- a/docs/spiders/architecture.md +++ b/docs/spiders/architecture.md @@ -69,6 +69,10 @@ When a request comes in, the Session Manager routes it to the correct session ba An optional system that, if enabled, saves the crawler's state (pending requests + seen URL fingerprints) to a pickle file on disk. Writes are atomic (temp file + rename) to prevent corruption. Checkpoints are saved periodically at a configurable interval and on graceful shutdown. Upon successful completion (not paused), checkpoint files are automatically cleaned up. +### Response Cache + +An optional cache that, when development mode is enabled, stores every fetched response on disk and replays it on subsequent runs. Each response is keyed by request fingerprint and serialized as JSON (with the body base64-encoded so binary content survives). It's meant for iterating on `parse()` logic without re-hitting the target servers, not for production use. + ### Output Scraped items are collected in an `ItemList` (a list subclass with `to_json()` and `to_jsonl()` export methods). Crawl statistics are tracked in a `CrawlStats` dataclass which contains a lot of useful info. From 664e41975120951a244e9245ad50a5ef027176fe Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Tue, 7 Apr 2026 05:39:39 +0200 Subject: [PATCH 36/54] docs(agent): update skill with the latest changes --- agent-skill/Scrapling-Skill.zip | Bin 80521 -> 81526 bytes agent-skill/Scrapling-Skill/SKILL.md | 2 + .../references/spiders/advanced.md | 45 ++++++++++++++++++ .../references/spiders/architecture.md | 4 ++ 4 files changed, 51 insertions(+) diff --git a/agent-skill/Scrapling-Skill.zip b/agent-skill/Scrapling-Skill.zip index 8daa704beeec310c297074e243fa9197cb9cbdb5..c43d0df74a3310ba0889e6bc2f6d30f1c55b9d78 100644 GIT binary patch delta 15835 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 z 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 Pe|(WaKyH2~f`I%VXQa@O diff --git a/agent-skill/Scrapling-Skill/SKILL.md b/agent-skill/Scrapling-Skill/SKILL.md index d4519bb..23f1de4 100644 --- a/agent-skill/Scrapling-Skill/SKILL.md +++ b/agent-skill/Scrapling-Skill/SKILL.md @@ -302,6 +302,8 @@ QuotesSpider(crawldir="./crawl_data").start() ``` Press Ctrl+C to pause gracefully - progress is saved automatically. Later, when you start the spider again, pass the same `crawldir`, and it will resume from where it stopped. +While iterating on a spider's `parse()` logic, set `development_mode = True` on the spider class to cache responses to disk on the first run and replay them on subsequent runs - so you can re-run the spider as many times as you want without re-hitting the target servers. The cache lives in `.scrapling_cache/{spider.name}/` by default and can be overridden with `development_cache_dir`. Don't ship a spider with this enabled. + ### Advanced Parsing & Navigation ```python from scrapling.fetchers import Fetcher diff --git a/agent-skill/Scrapling-Skill/references/spiders/advanced.md b/agent-skill/Scrapling-Skill/references/spiders/advanced.md index 1244c9e..654e17c 100644 --- a/agent-skill/Scrapling-Skill/references/spiders/advanced.md +++ b/agent-skill/Scrapling-Skill/references/spiders/advanced.md @@ -85,6 +85,49 @@ async def on_start(self, resuming: bool = False): self.logger.info("Starting fresh crawl") ``` +## Development Mode + +When you're iterating on a spider's `parse()` logic, re-hitting the target servers on every run is slow and noisy. Development mode caches every response to disk on the first run and replays them from disk on subsequent runs, so you can tweak your selectors and re-run the spider as many times as you want without making a single network request. + +Enable it by setting `development_mode = True` on your spider: + +```python +class MySpider(Spider): + name = "my_spider" + start_urls = ["https://example.com"] + development_mode = True + + async def parse(self, response: Response): + yield {"title": response.css("title::text").get("")} +``` + +The first run fetches normally and stores each response on disk. Every subsequent run serves the same requests from the cache, skipping the network entirely. + +### Cache Location + +By default, responses are cached in `.scrapling_cache/{spider.name}/` relative to the current working directory (where you ran the spider from, **not** where the spider script lives). You can override the location with `development_cache_dir`: + +```python +class MySpider(Spider): + name = "my_spider" + start_urls = ["https://example.com"] + development_mode = True + development_cache_dir = "/tmp/my_spider_cache" +``` + +### How It Works + +1. **Cache key**: Each response is keyed by the request's fingerprint, so any change to fingerprint-affecting attributes (`fp_include_kwargs`, `fp_include_headers`, `fp_keep_fragments`) will produce a fresh fetch. +2. **Storage format**: One JSON file per response, named `{fingerprint_hex}.json`. The body is base64-encoded so binary content is preserved exactly. Writes are atomic (temp file + rename). +3. **Replay**: On a cache hit, the engine skips the network entirely, including `download_delay`, rate limiting, and the `is_blocked()` retry path. The cached response goes straight to your callback. +4. **Stats**: Cached requests still count toward `requests_count`, `response_bytes`, and the per-status counters, so your stat output looks the same as a normal crawl. Two extra counters, `cache_hits` and `cache_misses`, let you see how the cache performed. + +### Clearing the Cache + +There's no automatic expiration. To force a fresh crawl, delete the cache directory or call the manager's `clear()` method directly. + +**Warning:** Development mode is meant for development, not production. Cached responses never expire, and replay bypasses rate limiting and blocked-request retries. Don't ship a spider with `development_mode = True`. + ## Streaming For long-running spiders or applications that need real-time access to scraped items, use the `stream()` method instead of `start()`: @@ -220,6 +263,8 @@ print(f"Failed: {stats.failed_requests_count}") print(f"Blocked: {stats.blocked_requests_count}") print(f"Offsite filtered: {stats.offsite_requests_count}") print(f"Robots.txt disallowed: {stats.robots_disallowed_count}") +print(f"Cache hits: {stats.cache_hits}") +print(f"Cache misses: {stats.cache_misses}") print(f"Items scraped: {stats.items_scraped}") print(f"Items dropped: {stats.items_dropped}") print(f"Response bytes: {stats.response_bytes}") diff --git a/agent-skill/Scrapling-Skill/references/spiders/architecture.md b/agent-skill/Scrapling-Skill/references/spiders/architecture.md index 9976f31..54186a1 100644 --- a/agent-skill/Scrapling-Skill/references/spiders/architecture.md +++ b/agent-skill/Scrapling-Skill/references/spiders/architecture.md @@ -60,6 +60,10 @@ When a request comes in, the Session Manager routes it to the correct session ba An optional system that, if enabled, saves the crawler's state (pending requests + seen URL fingerprints) to a pickle file on disk. Writes are atomic (temp file + rename) to prevent corruption. Checkpoints are saved periodically at a configurable interval and on graceful shutdown. Upon successful completion (not paused), checkpoint files are automatically cleaned up. +### Response Cache + +An optional cache that, when development mode is enabled, stores every fetched response on disk and replays it on subsequent runs. Each response is keyed by request fingerprint and serialized as JSON (with the body base64-encoded so binary content survives). It's meant for iterating on `parse()` logic without re-hitting the target servers, not for production use. + ### Output Scraped items are collected in an `ItemList` (a list subclass with `to_json()` and `to_jsonl()` export methods). Crawl statistics are tracked in a `CrawlStats` dataclass which contains a lot of useful info. From e5c5e7f50c87ec94b165d06a227e3a2c65ba7f65 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Wed, 8 Apr 2026 13:24:43 +0200 Subject: [PATCH 37/54] docs: adding new sponsor --- README.md | 10 ++++++++++ docs/README_AR.md | 10 ++++++++++ docs/README_CN.md | 10 ++++++++++ docs/README_DE.md | 10 ++++++++++ docs/README_ES.md | 10 ++++++++++ docs/README_FR.md | 10 ++++++++++ docs/README_JP.md | 10 ++++++++++ docs/README_KR.md | 10 ++++++++++ docs/README_RU.md | 10 ++++++++++ docs/index.md | 3 +++ images/SwiftProxy.png | Bin 0 -> 7786 bytes 11 files changed, 93 insertions(+) create mode 100644 images/SwiftProxy.png diff --git a/README.md b/README.md index 408cfef..f9f9816 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,16 @@ MySpider().start() Stable proxies for scraping, automation, and multi-accounting. Clean IPs, fast response, and reliable performance under load. Built for scalable workflows. + + + + + + + + Swiftproxy provides scalable residential proxies with 80M+ IPs across 195+ countries, delivering fast, reliable connections, automatic rotation, and strong anti-block performance. Free trial available. + + Do you want to show your ad here? Click [here](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/README_AR.md b/docs/README_AR.md index c0de787..bee3c07 100644 --- a/docs/README_AR.md +++ b/docs/README_AR.md @@ -179,6 +179,16 @@ MySpider().start() بروكسيات مستقرة للكشط والأتمتة وإدارة الحسابات المتعددة. عناوين IP نظيفة، استجابة سريعة، وأداء موثوق تحت الضغط. مصممة لسير العمل القابل للتوسع. + + + + + + + + يوفر Swiftproxy بروكسيات سكنية قابلة للتوسع مع أكثر من 80 مليون عنوان IP في أكثر من 195 دولة، ويقدم اتصالات سريعة وموثوقة، وتدوير تلقائي، وأداء قوي ضد الحظر. تجربة مجانية متاحة. + + هل تريد عرض إعلانك هنا؟ انقر [هنا](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/README_CN.md b/docs/README_CN.md index 6cb9d4e..c92ddae 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -179,6 +179,16 @@ MySpider().start() 稳定的代理,适用于数据抓取、自动化和多账号管理。干净的 IP、快速响应、高负载下可靠的性能。专为可扩展的工作流程而构建。 + + + + + + + + Swiftproxy 提供可扩展的住宅代理,覆盖 195+ 国家/地区的 8000 万+ IP,提供快速可靠的连接、自动轮换和强大的反屏蔽性能。提供免费试用。 + + 想在这里展示您的广告吗?点击 [这里](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/README_DE.md b/docs/README_DE.md index 82d336f..614cfaa 100644 --- a/docs/README_DE.md +++ b/docs/README_DE.md @@ -179,6 +179,16 @@ MySpider().start() Stabile Proxys für Scraping, Automatisierung und Multi-Accounting. Saubere IPs, schnelle Reaktionszeiten und zuverlässige Leistung unter Last. Entwickelt für skalierbare Workflows. + + + + + + + + Swiftproxy bietet skalierbare Residential-Proxys mit über 80 Mio. IPs in mehr als 195 Ländern und liefert schnelle, zuverlässige Verbindungen, automatische Rotation und starke Anti-Block-Leistung. Kostenlose Testversion verfügbar. + + Möchten Sie Ihre Anzeige hier zeigen? Klicken Sie [hier](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/README_ES.md b/docs/README_ES.md index 58dccb8..0094988 100644 --- a/docs/README_ES.md +++ b/docs/README_ES.md @@ -179,6 +179,16 @@ MySpider().start() Proxies estables para scraping, automatización y multicuentas. IPs limpias, respuesta rápida y rendimiento fiable bajo carga. Diseñado para flujos de trabajo escalables. + + + + + + + + Swiftproxy ofrece proxies residenciales escalables con más de 80 millones de IPs en más de 195 países, brindando conexiones rápidas y fiables, rotación automática y un sólido rendimiento anti-bloqueo. Prueba gratuita disponible. + + ¿Quieres mostrar tu anuncio aquí? Haz clic [aquí](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/README_FR.md b/docs/README_FR.md index a452210..c24bb99 100644 --- a/docs/README_FR.md +++ b/docs/README_FR.md @@ -179,6 +179,16 @@ MySpider().start() Des proxys stables pour le scraping, l'automatisation et la gestion multi-comptes. Des IPs propres, une réponse rapide et des performances fiables sous charge. Conçu pour des flux de travail évolutifs. + + + + + + + + Swiftproxy propose des proxys résidentiels évolutifs avec plus de 80 millions d'IPs dans plus de 195 pays, offrant des connexions rapides et fiables, une rotation automatique et de solides performances anti-blocage. Essai gratuit disponible. + + Vous souhaitez afficher votre publicité ici ? Cliquez [ici](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/README_JP.md b/docs/README_JP.md index 9a2a29d..5c568be 100644 --- a/docs/README_JP.md +++ b/docs/README_JP.md @@ -179,6 +179,16 @@ MySpider().start() 安定したプロキシ。スクレイピング、自動化、マルチアカウント管理に対応。クリーンな IP、高速レスポンス、高負荷時でも信頼性の高いパフォーマンス。スケーラブルなワークフロー向けに設計。 + + + + + + + + Swiftproxy は195カ国以上、8,000万以上のIPを備えたスケーラブルな住宅用プロキシを提供し、高速で信頼性の高い接続、自動ローテーション、強力なブロック回避性能を実現します。無料トライアルあり。 + + ここに広告を表示したいですか?[こちら](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646)をクリック diff --git a/docs/README_KR.md b/docs/README_KR.md index 6d79389..a67226d 100644 --- a/docs/README_KR.md +++ b/docs/README_KR.md @@ -179,6 +179,16 @@ MySpider().start() 안정적인 프록시. 스크래핑, 자동화, 멀티 계정 관리에 적합합니다. 깨끗한 IP, 빠른 응답, 높은 부하에서도 신뢰할 수 있는 성능. 확장 가능한 워크플로우를 위해 설계되었습니다. + + + + + + + + Swiftproxy는 195개국 이상에서 8천만 개 이상의 IP를 갖춘 확장 가능한 주거용 프록시를 제공하며, 빠르고 안정적인 연결, 자동 회전, 강력한 차단 방지 성능을 제공합니다. 무료 체험판 이용 가능. + + 여기에 광고를 게재하고 싶으신가요? [여기](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646)를 클릭하세요 diff --git a/docs/README_RU.md b/docs/README_RU.md index 0fa251f..5a2cb30 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -182,6 +182,16 @@ MySpider().start() Стабильные прокси для скрапинга, автоматизации и мультиаккаунтинга. Чистые IP, быстрый отклик и надёжная работа под нагрузкой. Созданы для масштабируемых рабочих процессов. + + + + + + + + Swiftproxy предоставляет масштабируемые резидентные прокси с более чем 80 млн IP в 195+ странах, обеспечивая быстрые и надёжные соединения, автоматическую ротацию и высокую устойчивость к блокировкам. Доступна бесплатная пробная версия. + + Хотите показать здесь свою рекламу? Нажмите [здесь](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) diff --git a/docs/index.md b/docs/index.md index ae41d6c..b47bee2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -83,6 +83,9 @@ MySpider().start() + + +

diff --git a/images/SwiftProxy.png b/images/SwiftProxy.png new file mode 100644 index 0000000000000000000000000000000000000000..6750ff26c9d6177df18891162e4bc9e666f06d94 GIT binary patch literal 7786 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 literal 0 HcmV?d00001 From 2046527575c879a0586b953966387eacad2b3aff Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Wed, 8 Apr 2026 13:30:00 +0200 Subject: [PATCH 38/54] docs: adding a new sponsor --- README.md | 1 + docs/README_AR.md | 1 + docs/README_CN.md | 1 + docs/README_DE.md | 1 + docs/README_ES.md | 1 + docs/README_FR.md | 1 + docs/README_JP.md | 1 + docs/README_KR.md | 1 + docs/README_RU.md | 1 + images/browserCash.png | Bin 72208 -> 0 bytes images/rapidproxy.jpg | Bin 0 -> 4611 bytes 11 files changed, 9 insertions(+) delete mode 100644 images/browserCash.png create mode 100644 images/rapidproxy.jpg diff --git a/README.md b/README.md index f9f9816..94eb7b8 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ MySpider().start() + diff --git a/docs/README_AR.md b/docs/README_AR.md index bee3c07..e336d70 100644 --- a/docs/README_AR.md +++ b/docs/README_AR.md @@ -202,6 +202,7 @@ MySpider().start() + diff --git a/docs/README_CN.md b/docs/README_CN.md index c92ddae..d909999 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -202,6 +202,7 @@ MySpider().start() + diff --git a/docs/README_DE.md b/docs/README_DE.md index 614cfaa..d44485b 100644 --- a/docs/README_DE.md +++ b/docs/README_DE.md @@ -202,6 +202,7 @@ MySpider().start() + diff --git a/docs/README_ES.md b/docs/README_ES.md index 0094988..bdac12d 100644 --- a/docs/README_ES.md +++ b/docs/README_ES.md @@ -202,6 +202,7 @@ MySpider().start() + diff --git a/docs/README_FR.md b/docs/README_FR.md index c24bb99..36f0cdf 100644 --- a/docs/README_FR.md +++ b/docs/README_FR.md @@ -202,6 +202,7 @@ MySpider().start() + diff --git a/docs/README_JP.md b/docs/README_JP.md index 5c568be..dd786e7 100644 --- a/docs/README_JP.md +++ b/docs/README_JP.md @@ -202,6 +202,7 @@ MySpider().start() + diff --git a/docs/README_KR.md b/docs/README_KR.md index a67226d..fc5f71b 100644 --- a/docs/README_KR.md +++ b/docs/README_KR.md @@ -202,6 +202,7 @@ MySpider().start() + diff --git a/docs/README_RU.md b/docs/README_RU.md index 5a2cb30..bd23f55 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -205,6 +205,7 @@ MySpider().start() + diff --git a/images/browserCash.png b/images/browserCash.png deleted file mode 100644 index e9ec8769c18c17c1e215cea6bf8c1b1b48def06d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 72208 zcmeFZcUY56w=Wz7Y0^Ou1nD3kkRZJZNbkLiAp{65kkF+{lP(~jROuj6M5;7Figf9{ zgGleaouIzY`@H+S``i1R{axoTZ!VIonKf%>{q8ku-7{H}5Oq~Kd|WDA004lmApcks z^}dbzH^;t#dKLK0{t^JtpM`*>^snEoo$bs}#rII}<`5nSFn}09&F zaeeyDCHITV?q9i_5%$8)&MIC&vv5KbW;AU`LF4-A2TLHvRc z3lOR=_KDx(*nWuvBmcKfxxh#y6yXGe+apm8*dF|D@bJ39qfR`Yno#+a+*|C!`##dJ zpQGe*vO+0h4~E%tL2SV=J1%Pns3oc?fXvLo+zbG-16xA5f0x~#GJ~0$U5#GN0*Dl4 z6=VTuC}7Y~e}Jpc0Ag%_Iba(djTmr)7!92m?P>xb4Zy^>iFp$P6Z0k}78WKpE)gy+ z4h}9E0U;g{1=*cD6lCP&R5Z-ARCgJv$;s&+&@r;Gvaz#K(sBYh?*p0dv)#WIf`)~K zg^P_#ii=BnpNgF7{{QiH)eN9Tjq@hj&+A&Z>kI7$I%;rO*f_YiPz>cnfE#G&=r=IX zZ{EbfK-GGo>H!$UH%aJ0l9;3#U@QhlG9JI^bZkbc4~@4q`?i@LnjxO!;F43^p`>DF zVZG1B&dbLyASfg({a8j;PF_J#OIt@*Pv5{0Vh**iw1Qb9ot#}<-P}F=0|JABUxb9l z#J-A)Pk8+%F(Wf8J0~|U|6S?Fvhs?`s_IWoUz%H5+rGAU^bZUU4UdeDjnB<5EG~Ut z{;{&Uv%9x{aCmfla(b;78UX#5TBz55s~75apxwa0K*zwk)(h>1>$T#<7&qxbm?V-K zSYSs|1|C0bGO6hF4~;mC4>h-Mn<4se$(eZPn0KyK`>EM~PqF9!SDO8$*gy3e2jHQj zq09)K7$5;Sbyhf?GfH02;ltPmxx&lDSGN&30fS^KtrdQ#R5o#bYDO*v2q#A z6-7x~=3VH21t8l95=kplxV$}bAb-U8`j9Xnjp*&MUzTxCg&out#?T^abX45M^F9~d z-@lRCthzw+(7uHg*-;K3XVfYkg4;p1FT$8e+etnei_7bB#I}_?Jz%gtVtw7W4d1|a zR`KrmV$cV!sQeTMrXb&o-yMrYW0zxOvc!C~U7@FJEth_7cpPE3eWp_GQf4!8)(U+N zIHR}~(be4p`1(Vr8zLK)*E$tNL0jhf0%Pp5wco{Z6CNv(>XYs4r770_T@3y?jm)mr zWcI>FH?(x3e6Q)nDwCWb@ca zw~9pOA&BYrokkjQcz;phO%)TM>i6E(v=9La!ufvu__UjAro8F3W0W^AZVjL(Z{}wz zXxBB?;KM_|>3cE{)PnQ%&7QS_WGwOC;In+cGbx9Ttsr>*<4_=P_W7yt9-9PAMOmmV zc>jRL-$dLe&h9{G(zq?#%9M1|nLB_gEj&r3(kSfVeezDw{gyjudl1G#2s@j$dev5T zFE4ytO~)yDi>kK?Ecmv8eKJ0ScGX5geKzm%&+30;YO@EL?L4y(!d1sr-;BLlq4=1M7 z&pJ&6_Ft8^3!=t_uK-=*ropy4B3Pe1uK@HWraC6=)vFSCqL!=?wVYhV&Nlebk#lp< zNU60zz-IgDWn7@5XG}>H^N_gc}KyQ*_3lCv){g{ViQz7$xuKuWf0 z!-B)hV4F{-IE&sUbi^tOT?>2*Y*Rlj~VZ3&rUo`XZ^%Wq-gxlEb)6Dkbh%V`I zfEwg;Eo~tL(Qjz54!QqYEylGMFI)nG?8a`{CE3u{uF;g)}+&Ojoc<07md&*R4G2SniUcyTB_|B<;hhmeS%^ zhvL1eMu`X~#GX|10f`Us_^K%p)(hub+$=*|nyW@e41&0l<5@fB68V|Yd5E|5m10VV z84Z;~dX3|Ov0D|N%HrKd!=d`=)ZTnSxB0r>X!0$Ue7ph#sT*9j!&CyOIcV#9<{6J~XKUbk zGde}wOk@^Q!dQ2RzBiM^h3j~sZ9F(aR3h_Bkvz-bdoETMXgFPr08S;H<4c|3B}KBq zyA_|OdgU9or*r4I$1A3@RUCc=u)G3Le05s06aU`9NcO?7 zXf#PgpC@u`nQ^@OB8^!mU+xIe+REz_5Q~|3X!#N`agMWNSd-ps81^IF}cb68o+E!)}zXsM-|(JNU)M zK~zbj*V5yi6H@*mk)Vgk_u^(WsaKm>&5rzPWpY<$NNhbN3z}A}5+V?N<7h8BNyE|4 zIcjo^akau6mL64#57Z6s7qC389bC7Y`X>B-1UvB6J{@U)?GX8)Osd?O+GL~a_Jm4m zzgw=~!TpYQ(jeW7$rS9_5m`z@kC?72z*w6609=$fulHH**rdwE+xG7&H)`yN)6ton z7B8K4NURNUX!Kf@zMk`Kf6)^*CN_N2G;u%h#!a4-_YNW(-7?JGd=BEa+q$`P8xo2; z7*q%4%_9^QGnVC68G1yis_QX81V!#sn^J3)0_@Ra%6tXb0;%ON$0((N&$!xa+P>fg zErTZlEJk;{nfuDUu$qsw>Rx4Wc6O81tG&7VyxISo|490~@K+hU12Pxrtq&{m?eGsG zLJX+ozRLh2zNJ2FVbdCrV#ep?si(tpD_Zv{O*_TTRhwg{#YxIvbsg!GO(=XN9d|K3 z?HHQZ!Woni-=;7Jz5{_+!ns!0BCQ2 zzjHhN{%%Wv*(TUcdTuLZ_PEZ{%V+K^dw@ba9>x-_-L@2uz*&y|T9BguOoa6tiK~E* zl_}bqVMD0XL=@f+l1U@Zgak%I*--Rgi2v7R3~lhc7MVR@45z33u}$$>8i6Re)0)(2 zYOFbZb*TmJF?;ny>~qN{vd{W+4MEApA2G{BXIFg4)Pk{sBuWpq)*ai%#5;EvT-NQ| zl=s5tE5B!VcRM@Ud|1B%0L1TEi{JfLUa`Y_3oo(sz3hmCg}m=mSBS6bUYo5RpT|2D z#6CNFTa-*}}sCW9Mv|h4*y24lv8bAZ@~|p|yP5m0vzd_|}iY-%kr} z;^*|Q*WCGVQbQqhUXV%>o-^6uS`i#&lgQn&MWlZbY*0D^=e$*LqJ@39NkfR?RP>@F z+@w!9HE=E}==3mfTe{_wS9j%`LhH`D2lG$6(xn28m+wa6wA!%ggui3$!{;RA***#s zXLGYRSp4RcV%@lmW^N_rIZN$}P|GjO7S@~!?K&j%&)*?{c}h}5jA-$1K`Ta|*nZd4 zr>QzD@~H1sm5Mt}=KkCpJk8i&Pj6`A=Kp1c2(|*}shP|mo74k9Sd)j#S6iC;%(L^- zsKxIchiM&$JaeAgA~aUZZO5a3GDbxI^tJs$a-ZybZB7<^YlGvhSyNIQ?*{{$PALSd zhZqA-1G8SR;jS8Sw6ArOvCD#nA~G{*3CdF-hu-~VSe0k@DlJ8(m}?ig)ZVam$j_1# zM;}{cj^_;_esEa1SA@+qgo#sz+L(~H8J7mmJj?z_FKY`ClmBtkc3IKzgSE?PPSipK zayDftz7Cc*ZPTVDC!aG81?E%lj3N!#r1-chZX`6#1Xk=?>1`~i+>qa@J%7aNB0E;% zqPy}bMREKrJRT{5=XrOYhOtdoz#}Kh*#<`RO0*QM42n&2v8EUs77{=hH) ziM#3erCjpmlc^+^O~JMbkkp|I#V3f>CCNz9!H=Q?BiK_7U?R+t?jaHKGmXN#SMzZ% zg_z0+sedG<%!Jt6bH2}sr@#qT2Rgi^Vr#!Y7D}REkl<+tc6NnW#FvQnL@M3f*d5e@ zi7EIitjf(zC=@!yMSf?Ae6;RTX{;gOn*Pw$gM7%RpWzDN<&z@GIx<(#AtouA!H z9L}4D2`e$Ri4bn&tygphQ4*|^etU*)IFQR8*+=Tab1t+a_vId4>5#qCT2eAB{`=~@ z5a$@RELtbpOtx{JDn9FA98+`bd|D9X8*d6UyY`Gc-(k37dA-fiSx_Vxyrnj(x+&)W zhCFRvE@(t=#HpPtOKF3Ojkzi$gRb;#r@PP0=k@!CJf&VuKl=M?O|q@M@!_Q7wQUOe z(BzMgr8mM$;0Zjb+)^_Y5Vzt;`i1Kv%eTlqt;kXGbuiTaI{81g605=jta4U-Tac?QY4nc-S+r(BLo?b`=3X$V4te(MG26%} z9W;(b0SVUKM9(KXUuK~vd&wxXDLzAo%8RoeTP!zGcryjf&y`Im=;8#AjVeaU18lDV zez?U@W-bk5|0Yw?bI|;T(V2jJRrMJxB1^lf610V0gtHJ?ruyn_Q8W0-xy`mQRX}{s z>dp{#rfPgv%S#7UkLaz?aw(Uph=yaOvamF=C_?}pt*^Qntr9}9?S?yKaP?+_Mh)&S(?$S9_AJ5stmJMSD=6KO5FqfC8o%Zkx-L*cznG1 z*yQ4dW!+FMXm$%{{Kt6lVNS|B(LHztJ^#n$VvR^-GUWEhGALVRrqnLKsL^a^w@;fX zNil;`70bY$yX~}=U9tlWS{qZ5W7L`!RVXNQ6W_X5{n$BoTIEc*J~h5SZ0tQZMBW6q z+5*NOPHP;xoHtU@@OHQ@+_3vufg7Z8niRGCGTl%S(RTknW80Brus4#|s)_*IDXN6B zsnk)iejVL`Ki?9STqq9Fi)NlAg`Lz^z}YH#1POc>V)O*t4)OzK?zFgKR5hC8b4qjx0HKL3yu_dA$Lj)05~ELE%MhV)ZA+^0OFE6Z2#2F)(7Zbey`RKjo;j4b-XFLrNPIsed&rSN` zN6w@bN(c9~ArfPeW}1<${6EknoWhTCaoC-v4nATP2-&=FuD-%`6O#nRhmnzs zZVini4qVyPE|-wHmH;?h3`4s4X`9-Azz>D!jG@wU}frVcA5 z@x*q0H+@}#9$UtCjdQuna@Z;(q-k8@g{a8R-OsKgi2ApD9N2Sac9h=jXet>z&v?QS zs5O;lcskQ3>;*%mPFl3JCxlgfd}MI!&|Svppi4)qT>=@xhY}l@j_8F@}e2)=G`>E)iezn6jiaw5B!uk zb*B+)EAi=KJnraMjPMKVtd0A5AurwRRiJaj>+^P+UX)JNaWYf&O?NFj0L#hS0Ny1# zS`87rV`n6D_D1&mE5P{4OHnfle0P>P(63{Zb0=VZO z+sm*VTSUK{5+bOq4AohF4g<}wHoYohSCtUN( zCTgYw<#$a~J2<+1m>tujvR$S(=n02a0Ux9Dje0O{DZP_sMwkpWTZZ!tmk)$k%K;#(h|?mBqv zDa*iad8GBc4t*F~=Br9*ZT^w#M{(5<&_=gG63G4w5yEJPw;fb8xR{(g zgx!&R&p~Zj8FR%@to4I8=i6DY6(+_2Q?1L}q>0aKxQLK0a&KX$1W8%8M30Jrj1T3} zg(RNL1Ujg`&p8l*sDegU1AFU`#%mwgO*{sabw$4{5Jqmj|6Z}(5ZGB!?&Z5hI~`)= zTkxZMZ4_yu9VhIx^rC13dljrvvn%IfdhE3wCBJ4^ zZ|@!_%LqH>YNp&7S3J#`8h{m4`Wz#RfOP|KY3}A}vTvel7M%sKZ+FJL4L$_R?qd~s z$E|}uC_X)tV8cUNKoo;*vyE&9qz*+mF zRE}XE%1Ae&^M1YA5MLQ9D2MT@56sR~qu-M|vpsv2!iAgpMD7{PkRQ6+-iBehVj^gj zGu0mbWp%tKJL{ZwkS~G)wG&ruGo{F(a{7Ard!UYfiQ4N~emy4Q{&Ee~>_n*_rrYbE zmILcqThe`+6y90$>v`}d*mGrb1_MQ&^QdBTG*b5`JecdY&m2p2o|~awbBVD^=szwn z-P1jg8Lf(5tk9Qyf{d+-uHgtB)}G&dA5y)Eni26Iy>i?rR6g~U-%1ilP!D$>?k|JB zY+a_L`9!t!rA3IhPg8JWCe*T}fmoCjGZ2|)U?i6p_AJk{zW@<9U{ivHL0seCU#9I& z&k?xUgH)*7!Fu6|EYRn#Szr!3({`iHb z%uZl-P{F)eJSc=NN;R&_ktihoCHih#c#NC}TdQbDncwC!7XKn^kL5<*!7`(KxO3}( z35oh^muhwG*?Wc2$EnXoL^Coqd%^pnQFE3r_-?@|SB5WHheZwUy_;9B(EFSOOM+}* zfF`jhdqB}fL!pkI`TDV@v)_~E3GcR`A^}V00u2OmzhaEw3u)CaU#j99gLGS9h)m6*B%z`fS;;w=4ZeP0!8LxmzEH2LX*O~Y%2G& z##s!lb+I}h(H`7yfRy5T}X8{2qiK6yP+m#i>DNbOkuAgBL5=W5l*h$!KdZn zyzFnQW9g-0zmZ_Kvrdl0>P8(FvDe=vxB@t(%x-KJz-_ql8~FSA!|TEsh2SIA(#`R? ztuwdEF={^Ye|%AU=IpB1)+uws zDza^#vS4#p#6;I7FS9(^n7$IKX>_OYtEld)?{n`+2dfuMZ^7z$zL_cvV>DP=8r+_@ zD~hbc7L8nfF47+`N%O5zC<^DC{#&icJB7TkDb34+!s#)lPp<{y!)sK5!n3oMx!ExG z{+JSd*K>9nz%j8W>tUVJnV`u-G1FdU^r})RdI_peggp@>*!JYrlslgV!=Mj-nT?l7bG(A1 zr`br>9+!f12Te=8T0*44TH=$TDe)%uaZka%2hJ+@j`_N_vd3=;nk8qp{t!Scz*(2Y zQ}RER!iwfWxyL3M_#|ga6cqE*n;UTIV3UqnBEX4bIOyGBe_adGRKK5dbKhXUyI&gP z19n)GR24e4e`Kw8t|d}r4H-7zO$$pD9U-)H_P+GwzE$uQo4yt2GmRIAoK6YSwl2f- ze&q=7gcykVQ(0Y?Mwf*l59I#&D%3P?CSP4MwpwdJ;JGMsdI!sMFB!hDmd@4$ zbnuhFNxQA^r$i!$`Pqh<1L;{Ou*@#T8uE${6H*&Xt-xy0;Ya~jOOd=W=EAePuj}Cp zrr+5FpC^fRstji)zbJByJu;`JZLMd;4}WFKsS;IG9H9H%FC7_6no%LmrtY12dtFK) z)kaq^Z7fJzW`{q45hKN1Zu#4d+ym3$+8&O#tHZg4ujT}2x(!WZfKB4s=ZRMU#)dw7 zXB!_?V&rPU)|8jyo-C zd%QAcQWvg9v^pSZ0e(?-bQC%~Tw*x1hbi4CMB;=OZFO3o-Ke{iWVB zdNVTcEjig{A6DsY=M~qBmXxT3s-y9SiwJ1K-uyeAG{czClMdJ=4rr*1A;IPnd)IVq z^`LBv&>Hb6B6f<4$MHzr@?>OvGzAK23Tk`L8ftfpN9onZ_x!~Kx8(bf1SgV$DEpZ} zofqG^CT!3hNcIoN5MjwYN zw#7@l0j8X#EW5n$wr_X?qed^gp-$>0&!xnIU`V>91PG53$D6x2a5jm}Atcx0Zs!N2 z>UQrNx>yab{40Ry%U$o>$~g`8h7Mz^D*))jyeTm(mFOGQ=6aTcEdQ4qPbrN`?_XM0 zY8kW$)i4_H-E{Gcn%^QZu{muSPWL!Tb!|t_i0lyaov&HAecRljLsfNsnaT`-)K=h3 zZrcFvT>&^LPC}){_;1bME%dyb;dR8gCWAp25vEuZ; zi(pvI$>qIs#js}nzK<~1uE#(8M{bU+MOS$<)G4b{es@!YQdqx)wVV{zt68}1UbLvf zOMAT|Ik{UBmG#JTZcXve;&>lTwh3V%un(yHw@j1eZobG2KdgcyG`#ms$F`gg-<}#C z8Qu#PwY;VFq_^RmgYOD}IZVbjnCS2-?3614o@4!Sl4Qj7L9X-0OwSzlS9RGN+^S?~2SxAn-!&M< zVQU3gweB`>#?%^xA@)9*n1Y2J7d7qnB=$s;7TO{*s(jt#9yM1!ETMR!KI?QMo{P>{ za_?YYR4AT3jxpRpBbP8(38_#ZG`3!trO_EyXudMNv9+{xX<-ov=qppQ!LWTj!X>u@ zT1-4$5r-Mij*%#Ns$Z;Y$#;LCAI`3PC5kAGp48dRv6v7}VQd9Iu*8)PIx1BbK;+)p)^I8sO(y-W9E zZnC@=Hv_xV9yQ*}2qX9*R~P8L?@OzwYxarGONx8WITws1W?qaDA07O#kIrk@_$)GR zQ`@}RM^R6@EHvf`Bm_-*RYsHLqP50?Q%kIp?V%X!kVzU7T1u3D+>6gNN7kRMTt_;A#Wkv8-q@3ahG z{KLauQR#UTC6ihK;q1i`tp&6!2`3M?Xu#(yDffoKh|8<%03wr&_V??|2Y- zd98V9rm$p_BZqQvL%W*#?LDPuCo0pL61F%q`4`|Ugx~8PZUSv`NrM|LV4}))d}Z+w z9(*Scdk%H1eDJ4eA}ZTxN!-0vdV}I&4Wl#d2aQ_iYrP40)kXUANs*_mB$yLvzLBtS2dNyb?rhfmhRR6PsweJ;T9ao3~gkFOBc)=a3dM%Nfcj zT)BjJ^3D2XpgRiLq)689XHj!`_@ylm*Hx<}$$aR3b~i@99%rnZ$zn{_jxFhBP9f2v zR;X?HWUiWF@u?@{Zj$`8pTq_!|dAt!kv z*X7X<^1gFEZm%2WeP*1wO3)=JuGfP#kI9D2s7s2`whdY+-6dqm3(fI%x~;DV)Fa zN{U~~&-)Rsu|sGCL!*d&NZM+=ciOj3l=948+%)u(uA<}Qi;Lg`-+{v$hexA}o^lIz z@McP*wf3BF>KARe60-~yfr4KkY(1J&$+g8SThlbTVj`D1`7cAhcX(E5RIg(B6r$PK z8$`Ab`NcfVI>1F(l@y7tq!0KfTmj4^&p#-0p-a7739_*nqYN_P~cCELTCZ=#Nc za_(@LXy4Qq#+z3Fd6i~DGoX!;{&Gz9%3gEeZQs^s1VdI$@?D=8Fq__`6^dJ^v9n9R zBYRC>R_36#lP)owGRDbR(g&%ion$JlFvdH7=aZkYRfQ;O&Fg=lzh_^D1TMZf=wBc7 zEb%)|^z1T@ejr07hZ}GQ;J~BG_GG<4-^Mc}PLLO^+bu~|VL^bT*P#a#$h{l&c0+}U zWo%hsRo7C-0zj=BdOw&&J)Wl9|H11X;i0@tA(b~rE)ExOAM4RaX$@+ab6MI-eK3j2hTFCpx}n6ye{R<14ww#w3@pIAYYK`(Hjer6OugcfbU!v)k)L zvyRP)nW>898{=#grblXOGGW}Sk-@_~Z8;wf)FjDtSd~@5F_sfsZs%LHpR2e&C__$?EBkggAiB<*)nvb={EpJyfQjL*;?k{_p7j%uhwJ-=Mr6 zW*+<}ZHj^xiU2#o5pcR|>^TugZXg#gKNryHKh>>@DEFFeJ><9_-|dfV!z5p|%!` zKNS(vkBK<;;jFfdF!76aYop>@%~NzPqX+Z$FH{kF7@^|eChn=+1iqC z_PqOtOUA4)pT>xIEwSBIGZe$Ndm9q9mHev**8i%9-{$#S=xTDIe@eg_Utu}F()8fb zS)#s!w>ysV!;Cbz7?o!-&M%>C|CP|cOy;-P&ay6_zVOe?uQLw1sq`qc7pv%Cw8#6w1(NE49gw@Re0?7z|GD<7=}uNHnR{AG&eH` za#{!p2%*xTLFTB0Xh8u^a~?i0AJkkx$U@M<3MI@5=410U zJdl5rAN|+;LKzar-D^YY4=^C{%Rm!kxkK-7a8m;K{H6%-IXdH8bX4N(4PwAcQ5I!I zZ37bWt_#CN>Xlv~J|+%(kbKY)J{lVSO#qMqL0 zJqewlbkdT*dldLQd>|g60MA1{Ac)`a7YX9~O#%f$1VFrN8Mvf3e|m|3(6|0UlLN_q z^@0D(i~Q~h`1}UL-v@+t6M+8w1|a}dj(g+z4K%<8E-izMWT4p8i%d$>fAmB1W=-aaFaf!K5 zxcrcGopz_~#?+m}wIe38ASR3!a$fC=2Yjs?w$foKx&tMR5F@d z0pv{iG0W5RfVM(0$Y7k!MCJ}n)vduz%AkU(I!E%PeNLU;^lJV z4!qNCccc4YpU}9&kZFgcS*YbOhxL{h7O$rJlW-m4mhR4{K_A8*;u-2Rfj-}!bHd~h z3brPVCq+1II+yleS(Q5;U1+o(80OzQO#}sgJ0{;n4-LQ5QivqA;UecEq?1T^hi+Eq za8gH)uYHS00jyB^nL#kQ%7R2&@i^{tA`x4xAe(#7bk6y%dH(xHR1oT#A7|B2zk4OAjETBa^g8%Th%9~we5v@29H_zf&y zAn7&DjDZKl#l+^oM7wzt3mxO95z>%eGu;3JfV9^vj2L%-NSw1y{{-3B2@?hQM2*!>Swu+;8)e zm$M?643t(>cmr4peGXF|!~=~lP2){%ssq|EsZDlnpB9jHv$0Dg>c%5~gxfS2O*e>F z`#q|>P;Qr0ecu%g&S~{q=@8zf7(G|o&qL?ja{j_H5@0Z=RT37}`rRb3?FWrtF2dEN z6Y{t`YEZ?+|BDtt&%7wbT;VNwr4*890daFv`pTnkx2h#vX7O=;Z26ZG#tzceW8CU{ zJ5R9;?Htc7+-PKV$;B!UeEGBljT@ow-W(*UTA&5HY1?g!di+qz89R6CFL0QtIiXgE zLwy};jK_c}dXGPoyEOAoD3M3LNH2}#OXOW4-B-!Zc0 zaodT@FwN34_TAzEG_nUaUbsp@X3acu-+5khl_<6{MP%{u#r8fUX6Iqpm;5$=T$=o( z-bASb;Nlu)oS*RK>l*#qfJbIx13uj zCu#?}1;n{_aG0bwBy~X7Mu{S@Nzqlc^gx6tZWM(@imBypZVz<_kphXY3s6TwemO&R z1l$}%^wYvIZjoaB_KhHv-CnbylM?>JFH1|(UGx6sj~@d0c!7LE*Z!Cv2mGz8RDR7DjlK659=RVIjcy}3$Xx;S6d0hB?fPp>!Jnr2|RsBsm$xjYc5tpwr?a-5F z7at8h?j2#)kAF*^ier*AgSU~mgPre{l}4#pKU#N>K%K+P3$sH;=TVPPx=!lzny^C| zYooLr8qT%0Wbu?Lz(a~n8L^;~6YlqY3|S9E_g_k$F~2jCRK#cC|osO#v_>sQh63D=ea6Lcd zV(&M2_{;M?M{5B-M{E37?~8^BBtco`FGq_F#6`7@S z_LjcZO@8pH&-mkhHkyrf51`-3KRh`a8irp7(60@s{EreKTF|5GH3ky3$Z)WMaY3%v zBAl@6=M;M|>gX5Tj@t!vUBmsW#sM`=xNSk)$X`!1+;9le0qFXdv>QP-{}X92B+?m* z;D!j80R@4)W}LhN=1@)`4;aMBhk6F&$=7dkDhz=Met()Q56dKSLd*5$cJ<0ggvbra5&n2@OgKP^gDqZIL@=Ke>Y} zDr~Le)hv(i=iU=c;IueN-L@W@TH9-r$*W5ww(%|5ZQ@PNRzqV{E{0TPd3}-Je&?I_ z)r^={KFX8ZFp;BDK8%mLtpTuTxg5I4R_s~2gw?m~Ah1v4f1@!it-p@;sM%;_k+<7C z7LskMAD~_LwYbvt$o3X(>Sb9JSEqBY1<4I&ag7Q^z2iIHBX}aX;wAFEQ+$SUN_=c# zFV@G3@JEGvS|1-i9W!(h(&2QNsRK^7w7;)Q#cC;+Qwt?!S-A85*cf}xXRx8FF|nz(GCD?LRdDGvii)uN09w`?wM^db}|9(;wp-2+VT4}6ny5C}n!dptHp zIjhe9f$hK5@C{(LHQ6m9h5APGkA&Y1M|~-HujU-`n(?M z8Xli_UnO#ANI!64WHJYy+vNG3-&nd*cp8uq&NlnXVbxRb5h7+Xh>-k(F-)EN{-klb z#o$f$=G4az8&7LS89C8ArjtS+C3PXN4yKHisRUlYcaMX4zwjH=hidOOwBtyXAD4iL zZ(rqE!h!~hI0|)z0TzS;jXQBBVL|6nKi=n@#56ccNjzIi>U~VZ_c#HF5i3|^&HBAx z#8IOR&)zcU1M`E$7sy(xeKZdPOhr_vlSPF(u74~RIvh+!wAcri`lORyt;-;Y{~qVA z7YlxV{~GB25y|lW9?2+xx3A;YEj$8gTe!11ow_a9>3aS2S6IaR85Ws=jMr@Fc+~%l zf^?d2xD#r{K__PmHv`+!$?^OZJ@I}=&)>lh1mXuid?!^p}&^{(=HL=I&P0IM<)d__6oEuP{Dusd`Xpd_WB6y-?ZtrZ285 z7F_pv=<_76uaf6IoCv(+-IhIH(z9taMqGdGwCUN^(a%^Z6jh>B{Ab5`tk_MNLB3eh zBNoRG2Dj8mT>+DmmW2{$)m?2`z%+_S?U?}Iox{A*m$&0zr42+?uEe*l1P?0A$yN|V z)CA)3G5;ux1!xwqme(?VJpJU2Q*c=So*0*cvd5==%picYdZR`~VnSSLB7(0ZgZSKO zCz&IVvKS0^zrU#>6?rk8WVY1Di*v%Y9G^Nmtu%Drr@hS|>IS~_$`6jOcu?fJ(^Pj> z^ckaxe|DHOc49TQ73Y)ww}qil&863BCyzYYbluSzv8H0 zC(!TfzloY?|FTF~QCNLFl?>DNitvd_xBc42wpfV#TUhKC^4EngxYhPqwkyz4Uvh z_Cn2wpBEDMbSnfI-IjwtY{c31h=p`x&o+D6KO>4F>%Xp+Xvsq+1i-@}Q0sD<$6BAtX$HDIAvEwFXykbL}LV&MuPvEMy^ zoLMjRW%KTE;vr`C1m2UhH+}oUAS&(3oVDB?azZ)n$0-4!L?6CC<=ePDdKTCD@RY!j z3h7_Vb*xtyQx2c`(A!CCyldoY#Q9QsDsK~_fbSk;0M@N|%5}u!_Mnq@h#K>Qc-bQ! z9-1wih*;6-n8hf4XgLn!8Fd!Q3Z`&o)4wni*3d|)_NP|vgeY}IzXfXw% zDZaBEt;VR?%+5jX%f)d6@bEKXjH}In2`0Ju%qy|N`yG1DvJ|lvzL1d~( z1*xuEf#`F@G*5#r@}bbd>}qA!&Ui7ocuwsH>DL>g2IPLf_e1}@{XyUl0)G(rgTNmI z{vhxNfj0`0p`UmSDi3f=$U-B7#{AFjZD>Xm8ZoxMA{o_T$zdkeJ&FZ4fNeyAoR zCFh5=Z?`(dzUai_UR6lVUcKxYL3SbbtM%nqT58@f%i8X`6>;1*MT$ zweELcSyZr=xQ_l1MnZXHC%h7k>Y3fMga!g>5360>A4DVUDGSVQx20qM;26SVrZWEq z>!#bmWfrL}q4SdcYYbbBvjJ27ZqgBesp=@u=X7rGidS}k0(B1KwlS~%3J>~r>a8sy5MVx&DNF_kgD_vdIE~Lf`33kG-nL{$%w)6(iRlCT_i`lh4BABFx z<4Rm?k_|xVs>Zgm#tb1C-eJ_N(VC+W7XqR0jfAiL#<%)9tfzGhpINj!v-Q`&MF~}E9xeU5?}{)0@JzK z+1ew8-Naab$`wX^zAolwMKK}FErc~6%l>9TQDUsWzn61$b>(t>_%mf5BqSun4dmhG z;o(Gaa3bC9oxpCK_DDvQ1YEkGG9E*b5Ckg69+llrcP$fa26uK6V`asq`z>11$q4~7 z`%Ah#k_-H^#QD0(&qNupIg%U11-u>>9i8Yeeql*-vCnex@`1ylqf72ROp%*G>D+TMGZ@RpJIv zq$u})UjP5q6lHxsG5EjI(;V`bIXa*Y7W_1Ia|k!o7K%EE;dE_`px?&$kL37&nxdqw z)4!S`YK{rpg6%EESlu{Lzf28wwnbs0WB)U!-kc6~+ys^JMaM}8`ZqW5w=7ZazjgCJ zP|M+OllXd?{q!Z*nP{jAlui$S>x80&C7qqD;0SRQFhm)3oW`C`3j%j?61`^pN7erb zV3hy)3*mnmi5uAAZ&UsHoWjp33P)(e;kM$x@@~xG5V6OSa{RoH;W{7{8#-sy53q~r zNXiJn^!_vIj1N9@+L$ewDjM+6gn60z5=@aCerK*+RYqT%&geWuU7MP3jk|6?= z*)`apiq+aI(c5ag)-7AX{l|s6nTzQw&Ha4hE z2dvA;_T>gPsNxhkW8ZB@Xy(T&#$hWztv_@roKs`bdiQG8g;RXquffIEkGwhH68Qzg z=yhGiU2>4e!~y+GTv5vj)poA7^&;+V7QUd~iHRj#&%Ezz8sIfVxpt1`tQD-xRw@D~ z&JaS4$TnosxUXX!DE?S8P(9-_VGO%ANi_+5JVyXept>Uc%Z>@AQvMI`bspG}D<%cZ zWBOGU>F7_e=fUg?tgXEUzU_XhKhZH+*sDBHD_Ff(mtTGLX|<=bBdPaEf|$GG!Q4ls zqtxV@iyuxW7Yid>)YTq)pR?CmmWwyUwG7>M&7QPZuQTQj5GIf6dOmPGnj1~QV%Q7& zf^fyM+?Y(%sAA@W?^SHnhX_4!d~-KrV2ZJ#I*3^$n5k%ns(}k=U+&Yj%Gt-^#qeIt zzfertw)RWB9rlw&s*-Q+VcB76feYVH);yPscKBVlc|ZOy0HQ!$zu#zTx4~qU$E=m^ zGgr9ISne{_+Ih-S$EnL4r!IAxVzqvX)w&55cFWxY`T=N-Qml}PBqAZvtdas~jasGx z1`;t)#;a06pD>gSSj(jn0$QV{38g}&<)pIc&Mx~{)!gBX@d z6>7Cwt3z3%?fxf|t3WWv7vHH=8ktPRLD4X{uqZyE#oF7A1D|8I9v}Xv(F6MKU$rw{ z!7icP&VqriW)SO9R`WgN--2fB3>M&_G9nv5D^wa>qwZwp4PUYwDj?*FCPPyQ2XOH-*6kR(B+;X)*FMD zZU|c97O=!E(9+q@(s|1Q$IbKB`p#YJV@6DS&R(@)maWIkm2NXuxK3N{GG&?5WYE0S zk;eoJdmE3yexMohED;HmGLc5bfL2L0EVODFXcY@Zz*;GXax6~0vJLH&DZPMc?2$Y^#{!ho&6R#ZkoS-6M!~f?FFVy?KYa&>O^|#GAD@i)MW@_dh*is6D%B- z18BKKC_{(^jBHRV(ddCjz=SAd_?*a=iN#W}7!wK^1)qlTAQcx&G+hLDXIf*3{|snt zx3}O^p-?ImijEGomN5+mU?Ai&=h!&{djIhx0{T#N;em+!;!ucm=B|MB9a~cJ0kjtZo#_#k?iP{i z5}NE3lC(Z39wNOiD1OzZh!s9z4*sz#d?FaoTOrb58pgxIiMfuoK679^z_k5FlT{mL zuJXX^oYh_gGz(H_tMwBWI@oO3+D|4Chy-mCQJX?80@Dy_7DTg30cu5Tnp7FDDg~Kl zIBc1e+z6})oC)j)$W|%jy$Cjb`#+Rtts!7Hlv3s)Fad2=)Am3(8R*_XlN*sKnUn4t zXtJ$xVhCakEZFT{Lmzi=;8g6b5#{Xf{|n9Rf}!nllp(R?q}b=QOKe(W1QY=wOWGYBCWbmJ%X&s!7K-W_So7FPD3gd zFc00PFBgiL-+(+rgT zL75yJ%Vi3f4ceLvOoMMVGn~w(GIa47W4)c-z1s~yx9eZwr$J9U!wBDTdyT*ZaFHt22+RK!HCL29{g=-hk%N(aHbDU_odd0?|elQzysZgULC#{r=WMU!5S|x{(5K;lL zJI&li%UFpJH0ywdY|A8==;oe{{q+Axq={n+UJC5cs^K|6Gk>e0%@fUFm*AG+OMIwd zOfz}bX!Yc2dr%0T%S;gJuS_8lNE~3KavIl?xfuyrP4E7E8H8 zHwZ-aZMuq%Y6f%#%7wM8qJsf_p8;L|yyDv9m!%J%UAq7D9Lj|^@0XO_J$&WX!P4@* zMnGpCIg@taWHQQydkE-A0L^T45C?i&o)4?&*ce4XbEX}G6Ja^l1jG>2n*lT#57_8s zylChiXz8MN9qTvETjx86M4Fs5)pgjq6Vm{CIV=aThM#u&bboT*H7yxlpMf+0^Pj`8}rpV-Ia4&+R&hRZ=PJr-~O1 z!LeGc?qHS!d@EreVTI(R&4`YUc7sCq;Eb6Kbswl=T^d&Jq3MfcBcQcL6Gjr-p~t<$ z825JF=Q2%_=%tsiNZzfNo$w*EBq-rkXprj=L}4+~gG$raR$kkhiuiPxYSO%a`E`;UCI&wDs;K`JI#|h|NhoTDh6VN%kw`T70Pul^YHzjX} zjfO&dMC!_g02;0XOxtfE%K?S9@eYGX8-RvFTf%iXQT^S#^}b}I0kpl>>{Xt;t^+_% zTkbrKRpCvwcKl`0>XiUmDr!^ff!64PY_yb=TZHl=bQ z=st^s|5l`x{}O1LfFOfo&Nr{KgJEvh@;Wa12f>z7QBSGW_5o!FRRdGy#_!PznOF2= zy~9YA+uOVAt})Q<{}O0zcc2mEZFF@h-9G2W1Gfv@Z!m*_Z(IWsDX*N?utFhKcaZB4 z7P!p4?L08KW!;hC`LAmhx zg_|N<@+zebx^r5k5zP}8hH%orS|So+LMa!Egz(%b5lVQdlvL4y z(DsmLhBAnUIRWt$N*?ka!K$mf0o|_cMz$JvS}ZBWtw0GemBUxCN@!>{tBoceFa?QK zk`IAS<715>sOiN>fDOQDSTPHK&~>qxDj>S?%fZD3q_6!|_YB>&!`USTRa11Iv#YMn z5KMg;)BNsZml4*1KLn76o6xq zv_&Xs7D}1~;>I>n1GKq?DxqrtbhEI!%SKbV@JUte!-}`}UR6<{@aZdf=+XzzK=ZlV zj{x+^8)T!Q&>Uzg7aqTmbM&09=8k)40G+pwYVI<31!nB@OU?IA%<)Oc_M~!Qx<^zh zfOZVlRdm>I0nm|DF7yfKu0vOO=N7;edJDC%IC9tF4a>pHg>W6SR=Q7Lq1S0xj>T(j zHwL3nC{QbeU|OTpF%6*Q5~#8WG&8l9s+AI;+1c5_qMb#LKl-o(#Tb>&STid^K*J>T znz{F12T+E0&{x%Up$yjHHg(%*Z38Nw@0hKiyCCq~wxZI#P`wXR&KSLip*t#1Xw%9gg5jZM$$ z8lSwae^6C>r=se{iwd}ole~H5+U*0EZ|uErZRhDy2K3qNqi3>9&SoAxlXm#zwu8qL zi;u+?9f~a6A6~F8yr4KRd#7(wE`Vkto#qjp<`I+b2C^fP9m5jW2F0ynEi4e}*5gZioE zLIBMJOp`n-sK$dTq?z|nE5!KuuU@{+&MMB%-j|-f7m<-soRzf?ZxK1!`?qgDbo|8S zhWdIOHv`ZbmAtbaN~5g zwYHU%Tn>-Ob#jheX69x?(%i3m(i)&O=Ou?Bm*QHV!P=epkuvRuKQhxJMa?0MU z>_a)*k7Q&Y+`I3bOb&0|4QI@Dx%5tY=KjoV?B1b_%>AWT?h-w^20ubgES6%PUAs=^ z}uEA`846hZHwb?m`a=OpMBfEQ4RW~rKJ6qc%`31+Z1AHFrMta6U ztRyoF`*SdN`;pyyPnTVPD3dGcnuJJG^)pqXD_Nb4pf@5i!2U61aZA;&moQ5dIYiv?c$(if*LNW8ux~gcr?ml!rG$ubRt{@~PKPWnH zYt;6Dh+Mz0?7-;lM=qD2z5Nj7!XwmP$bdd`dB>?sg=b3R3J!V%r#WnlwsnnI;T&e; z6k_cdVzqXw)tbP?4gpKn1p;M=^oHn+4Y3(E?xAK@8_kw^np$m~wbwlZG&fLyxf#YVl4)Zlkq^TL4V#Z3h88)ud$#}T@X0>jm zCs$}a)6i)x4uwR3(5jf3R!WpIA&zEvc=n(E^2?B6rh|seLJS<-Ertx6J?g``-+X7C znN^JIpOqb94#fvI7gVrFuDwS&T)V1%vHwCLj{NtZfrq6JsvStUF2h4on;Q2{Y zR*(K@9)4Z#J;$N=(_beB1!pnT=)#ML%=^<{CgAfve`(iJf0-DUu#-#g=`%O5d)?+4 zqJL!*!$z2X^vQfvbGJM9p5p=#iWFq5$#@XWT|ldhfL5xY%_?nsMRkMQhPbc3S%#l5 zlun!pKX*Ka3^T!RH!*doX%@U~X`{x%`sSK8;px)bI34_)4jF2~0u!^bC;j_RTkR5Y z>EW|u*KX^8K7VcZxvP06F0NP~I{wQg*wDyP<|9U!>0%_p95HgF`S9Up%UweJQu9cm zW759=#di2G)6t{njv75@eu4<`<OFWPi_T92==R`{ z4E)IP;}?JW*+L#;^ugbLGR9)mM{_?IH1p`u^Eg#X1#XH2G$b5{a@_buV~iH*zN`h7 zeSg3-%SB%L(joO^!m@kzp29{)j-G>g#*Ed?X$bxyCV=~hQRWve-eky9HSQ9tO~Xf; zj~&10vvIW4XX7j%K6=H0Zf8JaS|X7HXdJK4##)XUYdQL(xzlH^SHJ`?D{}bAC7g9! zEuV~8fHlzX9osF&eZCko1O#Sq?Zj(HSkAz~Gsl0ixW}Bvfp2k$qM{2p&__#3@$0aA zh8aFJIx_s%A+XxtCa-R65_0ds4Yv+xwGq(z#zKWgQ`^vDV(QxegXtfBJf9t_g*^g4 z85%Up)CI-C+EyVo7S=a4N~AtpQgO_2=z7nORek-P^?~zcr_1jHXl^-*FJ8B@4;(UN z7A`#9srigWw==a6$9b7!kY8H9XKcpC*bD%TPdGL_7CQ#m`$yY&g|6BXId#4>Sm)p3 z6beakApLY4(zPG`_9nCqf{IBQsN@bX_m8w6|szd zLR9PyXau-4UTtrnP0U=Y8(V7tbYlyOhL0<2ewpNeYs|29E+U5MVZ%+g#^hgo_!Nc0 zgO|$>UA>uDco+xy<4@?EcJ2RX-9iVnQ}C9w0$wfz(7@ENW=t$`40Q01Uf~^PwV8wShyVLGz3iuCN>qzF`2G7Ygwz0NG+e zt5n#kk_(yTAi>rtba2Li?m*+*eEsdRFTS(_*jd{R5-~+)QebRLVacO=1t#xxt8>hAh&=9=4 zcVBQ)VJ+ddspFemF=bj$pz$HO;ffld&|}k($qxq2z;9u%GjPlP!=HZg+mgx@TG<Pv+7Oe`8)z%1z|{dU zD}BQ4{G+EWaOnZ`1d}x=8ZK~VG_UnBHy96F*2uELb><4UNn|;o&~gGAuNpZ44V_j> z1yWI~T+*h&=Q^M@fm_qyV#hF`@xz9WFw4!^*V^1zQ&r>T6%V({?vezw#X?WDQmWM` z6ulIhs2ytJ>=JqT(#?{ROG}pevV(5HRd&FjnVDJpi4U0+uVrO-dmuG-yk%@`!SNHN zCC5vHLb8k{9~Tu6eDuM+z(d{L3XizR6pIv$h1J`v+z~m@)0=pMLB1Q}S0z6|)>&K(lLXuEUzp*to*5 z@Z9i-yzq$ac#VqAkBrKH`m9n9^c4m!$7Lb*dHD+ev*&M~K68D|x-fjzb#MmG;C@}v zU7_`sLnqQwR*}~%5dZv(-KY|# zBwlGB1iQ>H13KWJFIu%W^ygnz8CJ!V*dlk|sZ+Ne?7wimr2L*oK+14B)E2rE@zK0* zez38y**wqMXYNvOb1N@|*%Hq=OT9cI(>HJ1?oE|<89)5m6X>PR1oWy+k@f*G@X(`1 z^;GCZ?t$~yQRN*6nmXUHW^fY%+6^8WK$GQADFiAoO<0rhkc->2N~)HI_mGfOi{Pz0 zJCs4e=|(`)p+9i&3_mM73QOR+_$?K}x!lIDqa7w#-WzC~?DzUlOWsz*7u>V=H0I}r zk16!5?Rkd`KwrIj$4L7Z4jDds*>XQaO%}ZJY%>=q8M`MMfPT}_*$HYF^#c0d{g*_Z zN-dGdMxYnWe_`nKM_J<>&)wtl7>XsGVv- zF3Z7z-t3pk56;C)w;7Q-KL+cPUr^EobYGT3u3{xZbq}D)kA3;|(&~m5kzCQtdfVxH zkWya`1Pru5sfz|FcoMPfQ$I8oUb>J$GNY~(P{;37Qxw`|Yd-M)p*7NTB?h1|g=4Tsx_}#=6-~VR& z`8QUEo3jDvB^$UxBLKA5+_k=DtBGk-yN$D`3eRJTwUhm(h<*}5vqIXUQ3~XeR+*@U znQ24KvbI=+P1+miF~lWGjbW!w zUpa8#jO8M)VeGETuaLFtP@3$}XaK960e!n0(8Ff0vfm25!wl4$NUfN+U_%d}U%ssR z9iSIfRMrLrX1w$6B&-eCna_2Xk%wN_r$Rq}Q3bqcL8G!7wuKuJmc`YJufF|$In=bV zv5gCVABDm(3xiI7Fc-hKX`$9lj?@9 zbobE89+smQ&|fcoQ`;og1KliUBMSkvKvdJx)+m--z4h2o1jnz2=q_C1bMIAE*~1t2 zE8qGAq``rFsuyqgYzODa(Ovwnl9cLn7Pv@X}f29 z)(^cD8rMm;Fb0}=Xecy*UclSH*D%+?dz0ExMTf~M_o>!ScCZ|3c^g75ZjlI@B*JF7 zxD^DG{SdVPXti9Zh?SK|001BWNklPv+x#a1T%0 zoSFyA(OaR7fA}XofF3pHSF^QCHwIZa`B^v-(;R4XveBLtuyJ4%*pP+Gf+WV>C-+COO|JAiKM({gkJx>=~}75%oUO`_4nCGX}X zd!wBv!5YN^Yv?YSb0$u9I0PGgaNw}B5goU+W>&IdrtT3EEIWkfbv z4!JH=ESN%Tj6F0}+YL9}S$L#FD{E=2*Q%t21&0hLhEDUq8NtEntTRk!51=s*s}MB- zpa@KS*u8EJb3T1SjUc6^w|W)|?NKPy0A=a|t&q>R=o@IQR%5fm-%v2`5ymY+hTtaN z)ZA62(lvdvEIZ*R{#8Wx`G=o~q3!A3X-gH~~%BlXRGJ!(1dGs7abmtMJh z@>JRBGuKa@F2kxsVwqtair#XV8(&wTkT=C6ApfI3>Q)tiw-+u_+fzeR>qgH6+f_mK zt3&J@Lhx#{G9V}Tv*wj5L_tZNoZwF2mvq+J91x{>yIyWr)* zLzizJxpr&ch3k7ST-$yAYSPZbUSXN`?opO2{btN{{eGftj~g10wcQx8HG7wLV$P4h z^#mFv!k?!({50AA=cx|x+yeD@Te$=`8oXVJ;@i~~QA@1JWh)3`|ZIM!U4fWK6Kb@Okn2+4xWj5 zY8zUy-i8)|akU*kI8S(Q7F@2Z!=19DUD?qgRH>?(TE%?<4d6zOG9NjL`dVPSJ%FC@ zoBiP{}UAuYUTKR#poBPXd6Zp=PGdlK z7z3@Zw%bit0z0F)xz9Ip4!oKA-n}Q>av0U!kg|UH)zTe1j@`QT@YbzI85#R{MTGv_ z`Qyefdi}bZcWuqc+>aC7LvL_ae)-M5qM`<6!C^*z@nb{FMM!ohyPGRis-iuolT-Gl zrSDHkEly6^gXwK4dvkIRFI?npXz#$!dH?r;22U<-F_}4su=uvrVgy!|n!XQFQCZ6u zF$S7d+s*LXlE$wAUg5pYF#!zf2o^fk7#5%Y~t#XZAqtPm_5gY!1KQHXA51TF@n7r~$eIj&`9MRs4pX}g(^t4T zY>LnUtq(FD#%irH03Doe-1JR{@0(>mPq6**=aoPGyplIVblr#t&Ik#~V3wmN(C-hJ z9vesZXsK989hel-MT@<9^t-svfPVPk*(YNb8Z(VsryhN3gZ+QrlJUP2Y8|sF;0bTwGZ+kTU z!&d-kb$f?MDmS0!VYu-0cMLP*J;ApA{*Ou1X0ESlY>{ZSF^OFY%{P=)aP&o8)6J)^ z?!J6;`r4iGUn~Kgyc3QN=*#8j?mzJk$r#Xo`mo`qxK|)xpecq^^l*1L9cz4JsiLEo z<-mlsTk!gjM29Ugjv)zsD75LiW!}LwD9hO&BE85hV1bk0oV7l@)ex2gB0b&4#hwA( ztdupYD7u(7Cc96dxfulnJdE3@sZcm*RyOq`g$B3x20A{TfM$cZ6l#^sa*@~25#4}( zaG%9^1_-qSHPc{^bM$uyN6}AC}W=Ml+*|V3bfnjHj zYYh3l-qhTUMz1hm@!-Kr>}v0>MO0_kZM$St&Ymm(y;_dGJ0}gA8D;34RMHJ-eC6$% z%u0<~UKQx%(i`Y&{{raRh88)B0iB&ep*c9zJvt{D?+Nx3X2!(KwX(iR+O9RW9JHZP zbG8?qsA?75e_j2gy7uC&hdqJ*jetJ?@Tq4|8ZMR3#u<9&Ss3EuG2PDE*v}Vv$7OHL z+_h<2UT>h6dxScLCa!_yAfVlQ0F6SSwRZ@OVPW|?7O+utK16X4tqm_`-5X zo0ZaL6_4&x&6$>qo8h=ZLeg;W8~=G%-$=l12t(IL#*OJ0Ut3jHRA^KZQs}H=SiNzy zpd}6p&Y1ydjap7XFZLcedM*wI@H2!(sFXR>b;ECyS3`q%?+g7|>)Mmg#xENE z;XDqsj4AYB-Qm))p0o4#o0hhFuc{tb)t)bZ@cCC(RD(Hg;h1sMk96WB2M)A%Q2L0G z=0-sG2s%wDBSE$;Y&VDH@1+r0DfvH6vcu)^DbA^`JD$Y~k5K2ZBmfP|F>8_ADC6#< zqv!r&y4Kn!WU(s&jj(X?Bc`c^MaQ(cgXfGDu4^_!p`}eMIMBK^}|X@A2ao09s80UNq?$#ov5qjWdm9skvdKIm8tKRlt>l)k2$p znqa$^NiR$HsKy^IhQ_9rZ@#sD=bw}N(;ar^U;h59)zVD{@4ntC;?IBm1)pQL-+gZi zPSwj-Zy8CX?oXapVq4%9-$Lhl#DIa*@hwign|my-4R#r*Ine+0habUf??nuOL-c?C z`^V^59ng?lY`s7G3Jd=CAAjbSgViw;(2mZLfB5r+4+i7lOveFyZ@{!OXKTX&w~ zhJykLH$r&DdT{O=ngvRY7M25rzbDV%tg;V=Qs8j0HqIG_PJaVFm<~Pu`#Z24j<6h3 zttK|P=r4c$1$P7LONez2n3liiB!IsEs_Icy&E?yV!4^IoJb2dNAtvv?KlR6nwnwkr zI{)CQPjE*6_oogWsv8Y}iJ`2h%IwGN;ls^v10VbO;zf4;p}BjuX6^D%-?1gF;D=vr zhkzEG%@O9jL2>2AaHp_j0PPf(IBT){upzTYvGOR$9y(+;6xzl&4Ayyxd*EXCK$Hu0 z)g5bf^>>IFE8JFZj_jwBG%}zYS#*P&t2OkyQzL%z@IhQ`Zel`SLcAgJS?DGb6ACgj zcAq?Pp|!c8vqPz718+OEvNsi#NlAr(I4N;QV#1F2gdHzmyy`?LUrAXZJKL2PE?kd} z$xBMwnV7UQF0LTI;ILdKp&VB#=r=V)*SxLWxwFJ~Q?kuU|9SI0OlCXJHg(2jV7n@C zi+}3AeW#n6s9Ob%N7bizWK4IKmfnep-I0{M8xa?`^U-6f4OFpa>Q}GdCMFdj@D=9a z_i>(D4#hF(bU{H$T*6NHVdd;7hTx9$< zKC$QpyLey>T3UtanFsI*+sQq#aXarnc*zTi!M z?)D?_;t5H#LPS*Tj@`v)WGtBhA5ci?JJi~Hk6slOpGi(Fj)~tH6<0_RyCafCPiSy<96J7`ubT-110FFsJ`>EGCsX{qu(~KfHY746jz^w znDo7;Zr(qB<8JOLhl$n*+9BVK0rJklsHqKk&yFNH^eQ?6M!1%RWO7~Z^Bfnh^RxB}vGUkz4b(v#9qaH_vU(U4kL0Q5{-_ceae0J?#g=Ai>x&onb3 zK1+r1NWHpFH)hb4pQbwLSh0^`>N#;pyGcMMCy zt9?MUoqv>#cW74v8VodgXPL3mZH-@4Kc%>VLPBAr)*NUKw@#fNLuwly*`xkHm=S3k>p~PLadMq5K5U)DWlYnDusm0GfT@PQz@t$lR_@h zg+i+CnQEF6*-+|UjnNQVI^9Z@O0HCLlntRv!mFk90F>5I_ecGNDQy57NK07_R7QV1 zgQ9U5WgSrKL*E~W`2nN69J&eCT%!4%=(Yy^WkBGvMr>VEwvz2g7tkzI86`JUs^kh4 zOG3#qC-E%N3YAQuq|}TughC=mC`q9yFNexFF9&6Y)}=8JQ$IU_h%y4E zl}_3y*<*XJWad4=?JK%;x_5L-u+;loO==^h7z$xdu=kIld>wvKD>p@~VCfAgkK@K*0@^Lm zQkR?5Z-FkU5;d~SvUOkUAKOnMs#7Aw^%Od+VLzIbk|wnCGE|0SNtB(U1aw~GtV%i&+l)@vr^B{l@&nphe-Hmb~<=9Qt0qq%=AP=hc$I( zGB^;qxP^z3uokL_X6gqbm*7=EZxxh~NiG#4D7m^)s+7}{1B^|G-groRFipuIHyiDp;N?r?X^biTHPCK;* zpTMTIERT&IN-giL-_`w%Cq^=O36>j!!0qxI?Yu9C#$Yq_uWFtoiGRgAg;Kvz_Nwlq z8?;&`SMgNP6bxUfoaKjRX_JH!nLtcWqG%IK31XoHuWdS@shfX8o0u#I_3?-0pa)YR zD6eh<%b)ay@+Up4tTyBXyUx;(Ub_GEA^}Z_8;@VVcjU^g0~fFFJ%4T2nbI96FOlmg zp)5)OdfUE|gx!Z@cO9gR(Rsz;d3&j%1C}GTz%M1=my$;7aw)k-F_8}2hFBY%=n|2( zhDwJCVA_^t+9idC>tI>JmU*Id7_`VWz|z@&p|R^&3PPhG zKW!-9^Z>f6OheWe#X((xucIA?S;2B~@KPEKh*GQV%GE?>THCX9sQXF}bngP0@l79v zz23`m$IQX){cSs|{pgxm+hM#8t-e;I&jo+)D?Vzgs~a@SYSw!;lr4DLB$=|0gjF~+ zpt)5yVnrlYsU#Y0i$vNe5;sXu%2C#f#1#!qaMINPT`Z{)ieA(;-hW-ilAl*Tf?&U` zyZffP^wD!zjszl^ZwBrcMe>=x#QI3?Z+?dI9r;3<`T+NGu2TZ%^M0%TZM9Rj5Y`56-^`VJ~ zH9>K9TcRmNn0J`9SBUjS%H{}>UhEdIkXpiZ71Hz9`I^{ytlbjbPa&+K&|ytsB-jl~ zBcOFAL_&o{OluXbjkRwQ;`5@TvSXri5V0{ii3xd^&R^B4U^8e4Om^0u$cU_j`25=H zD!_fC>{dj0R(N>U`Eyrs`mSBQ84{A2l2X*z(134+Z7r#(MG=uX85w(JQhMG6e})S* zJ3D88WaRdQ#GSP@^*Av%ZrqQE%ngsoy>NjlWy{L$hJmsa5y>fgZj?XlU`#{#3U?lljLu6)+*MQC)XA#M3^e0y zV{=>1_9NcD$&SvEPA<{@0qKVhUqXRQN54jU>GGYFv|_9_K4E7}Y+>@YJu!@mdcgjd&kb>cpIOvD>i;70$UG@*na)SLu_&X!3*J$ zd2#qEt`LEV-0er6zogM+x<1e^G8f1kBu$h_sg^H=!J zTkbP=nfIJ!-d5`Zq0k7J4cEw2Yqt>7rJg2>HcVgOI(>o5l(|j|*KL8BUhV~^DI+?i z_~$^oZn4nU-z`|b$$Yif`heJea$${}n6B+6)m>`c&>ib09tv?|r&e_P#?21~Q#(Tc z4`%%H{b}zHnDN1&Swlyd9o&Bc2fwAE=94iN|M=(B0Rv}VxP1Cw$ep&Q=5sY z3#A_zJc|vcqVZJy2hMPFkLB_h6r4SjrAlHA7Ss4v-qtjpJ%1BB{P%zSHt|>cPBt9) zop&ey_3yu$&-1`rl-s`h!3G<}o?yG!bFBP-{qOI$2GO`~Q**b!{7u&s^&ZQDKy9>N zF1dJ_#zFELk4~1Ph948G0l(|L{!=k0HutKMT0_gqA7gL(512OKgXye=h+0x`cz5qR zD^sgQ3T1M7@zCM3^?9&n4jy8HUor0U#fMK`ZB;0i+Xi5rfBW07n}agmvAYghnBZh%Y|=gUz_l7mXdi2#7m6g?4tzA>iNtXv2Gi zmR_bVHL)?<2M?P)e59GLZ$c-f7t6vy{Nl?cAl}C}v96}}t8bQ${%GDe-&r>|HFapA zuJlxZwzk&qSxYt6bL9##7aO-@@KCm%P1H~v5s^D^u*nx+TH);Cle)Slmj7!0*WX$T zh2oZ$HoX1#Q;W~WEi#|$F=g6X>?E$DVAi)zB$iH_u^wmQ)6W+2k+a+`^I?KTdrkpg zmPW|po9~umJ-l-WcCzA4U2T2K$DdH27T(8W{1;2Gi7&pgs&8n$aOpPh-9KlZ2mJ1Q z3(paw=HN6RKTe~7#Zoyoiao^qpMPof<=0EG^5G-R6WM^2C03g-^Vso=f!7ybEx{IX zknz*(9Df$^F~!mya21i5ajJNOhY+;jlhz#TwwKJJSpbclYiDL8&1ZQlvJ{p3^n3iyuwd@=7KY_}mQ zs_?*9-&<3sv9T6ozgYa)cuTB-vP67sXJ#FQfZ^MPi@XIw$f@X>2bP|wxR7au=y6>o3detPddWwbeU z<~nv5r`&R}55D5VD9z`%YnbrEff#b!?Aw3-)$7{Fk1Me9R)HA)#A@m0VJx{>T*A)! z#@6tNJl@)GVd=#mTPR~dA3u2w_ZdKGKGz*@6O(s;FmT4`kLMjdeuXPEJ2i9Ys+P*r zG7k(LZi=v59U@h!tLs~Snn>M)@LRI-k2Xmp8-0@@*ti~6+6Og=B@XMtu=5b$;K=RI z>l#mAy@NvGbQ34+2GHDb@LX!aVaO+b`&ZvCi%Q>-{9l&*45*!1PcrQ2He>l)T{I=>tHo#|+>3VDT zkfkm`%iM!)eIizEj$%c_2pA98XkORBb4ZhoX7zVwt34fq;sNvV_KUVVL2#Ojg~2N*Lq4h(D^!`eRM#-&ww^s4#6gLoj)ycE#va;H#)7M9`^NqU=7QqBg+fNg0b8;y)6l}C13mL?Fm3`1+ zD_@@b6!!(7Ck2qu77mIqb)YwzKBAEm`RwoK(2?QdtTI`r~;} z-$^sqO*M0#Y~r{nJ~yHGXh`<%Z+^64im^~s-fr)KH$EiWo+>RW~QA*0whlq09EYC1b3;|hWAFa~r30~&`H^Uqu8`NdaD zhYp+l+tk&Qr|Yi#?Zu~$UcN=y{RYj18y-G_2JrKtgrO-#*+upi>iHrx!dZRZzl zyD8FkQxurC_6%K0ojSGx=tcCfEPp=M5FXkI3N3A^;K8xxA#bhbSj+WH%Z0W2Af{!4 z+D=u=?dvx<(A-wSV2x%sn!o*Ha$IbVR?+s=*Gu_8pt7q}9T6Xwrvs4z9U7W3aL6nm z9v7FNoLu<+2Q$WewonIjLxWZ$XV3GL8-T`ou3sk^OGqf}4s`ZFR%nZlFWk0mHxPkx zaG(VYXxQsf)JxMcEIc?!m*(aXcl*vWUXSr_68we)ppd%Zq1r^X{|zk6#;z8BOPf0!IMl)bQwhpL_ zed7ELok9=c3LQj1uMZo{M#_9dg@pN(@wRKU#Qf}{D4k!#@ z<-m8nS6sn~i!4`Yi>>W&yR9wbgVgnr~mf{wYHc&ho&%l1t|eQEZsLv;v2({ErL@(QR{#=T*(E!uAgG&^$qKLSj)< zt0*EWfA~n9hvunAxk8udJv6Qsej(vP#ap`7l-_;#w34HY9o~E3+?fkEm#+-qr^b8J zwidDUho5ank%yj}U2v>LD)-#94Tls!udv(NqEKwyl+4Dk5tm7J9NyH8xs*>@^s=g~>i*FkrO4xLRvpS(DRdQ_9NZH~ze%ia^1TDT=CpS7(N zbD*(r7Pgz=q32k6(eeCv-nT#7ctoc=1|_cXi(Ta%Y3mg++iD|E`~v&2(kH@hOY}ciA92mzuFnr^9_pRw4TL~*r4DH4m4YjS!h@e z-adK!3WPygP#)qy^I8tLnwcg}D4BizjWwp<>pv|e6<(k2{vWam2b9)JVLjIz7=U=WszzGjY20vNARQFjc>Nz8K}X@n2buVIv_14W8w? zB^f}okup@v@!9xA2(r|l%y;mLi7Pq`pg)G#e`etnkQ7&Zc(rdVr}n!ED^Vz%IDIuE z7)x1>8Mlztfy{$c+xkYrb*$W^Q)s5Vj`Pk zT`6s@B(huH=&c9|Hbv`ONlSIRys`Y+wLwE?|Kq)>*!B9Vmz|vw2=gER^2?w8`m38; z1n!?n33k2bf<$*ubPZ~Nn)Cn7L`w=bT(z=?e4-O0Gt8yo60 z09r1R$;2pMZc8aLq|t|D^1S>w4R>xoqI&4SG?++)e*ldzef6pe^uP1&Bw!~Hh#H$( z_4Uxx@gb|Soir_F)?7!%=-^s!8FgB?X0hd zp272aVZzNLR?FG~gG1YoNTj2**$;OzOE@BDM}-`@G{w@It{bE`n`U;qBw zyYEdoaf*r)xY)2K{Riqcigm(92L@&Gdgwp%dT9Or;C@1hxh~)3b=Vx}Bi-wv@zZ$c z{aL0iLaF@5?WfpP%z#r<#Zr0r`lbEp&lCRg*NIN9(QrkHDSL*FFw@mT590OEGsk_g z`0%N#wF1%O%C{4LwSVvZX@7s`x4?+pr#1Coe`npl|Fn1CoxCYH^I1bvbjt4c`%lGT zfsG!-s={!9ts(R{De^Y)GVomcn&- z=pF!P<-T=+(J%exa8zruU=MawW^K|by?ZnsHp8xQF+&{;goj>1cLMoY8uSSJ|Kr} zk;yVL_s7QVNJ!k7khCiysVE_FS6ceM2M=HJb_$feVqyyulXl}4Pmxp1X#XIk(q!q(zzpZJ)Cs^&C z;sh8Edt={ojR_g*y z7Hu%K+Bkdh29re|CW}25uM1eWHGXwK3_P^mW|Rvf$Vq##r@w6sw%QN``vDtmzQ%Wk zt%qw!LO*fCGot|0h?hn_ggfjCNf*`#_z+-Sr4YXD)V6kZv~_4&<;+-X6wRHT&{VNT z*(wv%%Y_XcYC&ha81_RgYiU;tAu$@t+e@99*q}o->u@2JOWIf>YS#CoL!*$3|{!Mrd*$MVg{j9 zAyumM3wA=VTgB{I_pEEbM5SsJ(39PS3Z+=BZW2rD1>#1Lq*bASHovZGLhxr{)rv&b zZNewjwRc~>dGfaYd41F4n)>U{UX|W|dgalx8!s!aK6`cf@$(B0o}Rt`#!w;*LCp68Nzh{YsfR4 z#xf6oukh%Mg6vC|PxTYkJrUO^b5Z)@hS!;&|746v&h) zma&dO#$7sjw>Z|BQ_WI0^3ezM)LNyAchlp2=+uTT{A{~w-g(~;d?*U*VW-js>%|Y7 z!5`n&BNen*rVz`NVwqedr4UMFLWx|Y?=srT`t>6O5?Pysp0~+37E%X)*3G|`J0Z__DU)X;t0B%C`?HsvlImz4yB6)(d(9XW65dS0BA#kN$pq=H`PF z*J;qoky3g*#r})e4L$uK(%DDPW*j=r9xr=>9+|xRDC9Ys^&{mEnT0%W&D^zxI{R<; zO{5&VUh!EQV>8?%)7&G{+#*t)sCzVZ`DY3p>%e+Q+p!iF8=o*34-4l2Tc42l%=8;q zPu?#-e)mSny<6w|32Pqg5Kn}4gf%1kIag~@;|o#aOL5~X3LVxY*y83&1h8hB zO=QDWQ?$O7!eO`8NCmYL@ZE~nTDh=ZC27*gTiX?FT18vCQlM1|H7q9+CtD_JkxS^A z0lJV15HbVPQWludk7@zam}YOK0GfqN7fLxXt)ypxE2vK?DKy8Lk6TgcI5r+a(lwUE zIC}T5*GIcibiM7~Q%`GvUjJ6Bo2OSV=U$zjHT)r*hDTlXe%cjJ%_!^kIAoRaW`)EZy6q*lOp@)9+XC(87aF#!N&7Z@0 zO^|^#(wG*&4#X=yqH^ZJ_}4AK9vTh_n%18J=dNdG8>*( z45owW!O2AdX@!jGd>`sfnnMq)pq_VW9??WLF&&&}0Gf+5n6~i=U+5MPk&$zw?9`Kc zXK$93l$9R6e18A!@^k$JH4g=E9|~(864rpb?lCl*V=bzCCTe&t;wGE>Y`~3zW8-Ua z6QV+_)9eaHc9kK-&3JvQ64$rOo7K`LVN-SG%cuA6+`fMGTIr?It5>exxpnKslgCZ< z)hcOg2YZ+cfR>6{=n(>vHW`J0g^(W4%)&^my93QajxaFIuvYLtr9w%eXIkGSTE$;~ z2jzcUwy+{=u67y0U>i!d?e--P7v2#{_gW6#C8!dhR!XzDB*G zjUN3?50>SREo9yN&(o6&3r=3*BAt2o3`9DWJ=Z>Q4~cZ_u7i;U`@^WSKaH6UNQc=d z*qoBTDS5jObUGo|BRU;4dqkzVMx;82ZF3AtTpyIUb}Mro{;{k5V^(g8TUt9HkinoXd0tEQ3VPw}nq0njeGkt*X7ovuj;)YkE zMixZ&Yf&TMhG4(puNrARXnytN!GV2;1Ggqvuke~?V*k|-i#{1Y=c92HW4~T7amtDX zi{0Edgy-b$xqb7tM9|!+kt$?u9BW21!&)lT2Pa!BlTql%=4vftK+BDEnnKQj=Ambr z0j<|+vJd|+Kx=!@YyeuVHTtk`IsX^J%z{0G&_J`1Hh0l%1T=N$??ayTqf~@aqPdOH zY#6B0M1F%(o$D34jRD=l#$Pqlu);ceguSss-+x^Vpl|7czRJ4!Gld4w_e+>UAG~~X z|D_uQ^yyLnop<~~Zppcde`amrt(TxpDeV`RQBNkKed@tgQ416#CZnv;A7C z?zUFlYh%$`eUI_2!&)Ct1hr2DIz#eI2&Q#dvjEdNxGRJWuhk;h=&JK)&UpJoPMU5z zYK-ZiQ8NaOnm+vFS))HQ`DmOej}OPr9`T9EkkK;+j+{31qnTg-xWv|Bb5YTu#yXTT zrSy!s|I65WfJK$1YukKhzBy+?&rG*%=xzfdhyf!Qz=)BYa}+S47%*W(MGyf+M8O;o zBOpi;BFDuA9c=+;?RVKjk0#)6`D-6Mpm6jUMRWY8eXfk7-$#>&f$7OyZ;TB<)+d9R|Xo}TfM4<8f2n@5MlQoJ3gpbuj$!s-)G zX$okVHYq|~C?*cSIGnqG-0x^TwjA9CC?9fcy_)R)=GlkLE z13*)!15HGZwtR5tnx>4(`mg179k|8tMHJCu`1Z;pGIc~;2z`M|!#m&lvOELAkKMdatlDvZ-^GB>DG6OI#r4%W z^)=Zwm6?@gU(1TV6y+xt6uZw_YL(MI^R@OVb z&TJ08wIS@zy3oLN*Dq_jx@_OEXYbP0VByB|RE$;Bj25dI&R5X^+XlC8Yi$`H?E|Lq zpa)^q{e9mt-QOn}=p&e(3?Wlw4-E7V5LlBy6Pkj0vS5^tDvS_qgwy(4iREtl)oNO2&>pU5`p8KRGWb*a9en&9ePTo76WB3uTk3lS|J4%e<;alKWPikyJMld~& zSFxFpjZRFE0>pvfKLQO9L+Ze|kAQ7s=k6OC2AV;*k5CFF_Mm8BNC*XD{{Rn<)8X}@ zIg&n>Si%%Z7(xkd{N@Y0iDe9Mhb*1dLSq8Z%@Cuh405@d%J%Nk<_;2Qh|&4=t(fVm zhO~3!yZO1gz7D7Q;l`GkZV!OK3D^`p*0KfXLW@*Pm{_iuQZ? z@y4?cS0BHf0{X(e7w1Es;S~<a5_sl9i`lkGHxe9c2@4PLusrO|@Lz^Ku-9eaGPXyzm=@S(d{4u|a;LJI!CYk{6*WWEm4!&DdYBxc!`WkCaEQg`gV-DaDL5C3 zq{H%l*$Fz6S6S6kR9p{729rl@dXHfp93J8FL{sAML}E!lIl%lo4;Bce9IlWrkbFxQ zBcnXNn9UW!Q^3o?eeo-Bg&_8%ID8SNb7+Lk6)`zNCP%>F2$*aEUnB*-$uYa>_BY_Gw zQX4ub^&OO^E*e;M4~GvGA-O0A0A1SHURc{)P}7uK*$6b_l?)o&iyGT9%IgyID&lj? zVzWwOGK!)zi=Ze>DyfReDFKw9#-%-u{rWIEB|PfOgOAA}?-HOW1e3n=_VdkW9|K>< zVy0h%Lw~#y_WI(zmykESp)?G`N3XljJa0Wb<_AgRzK2gJ6on`J!wy{wx%S{iS6f9( zLt#^0K|@V$O=VVPSz2jPN?~4NZr10lw3yVSclo)=3hcHbc3TO%y%@x4F9EnYodn#S z6@tBTiM&Fikg>Z#%PkZ{+71k?r|rM{2TR-B!_Q^)m}g?j*Hh!DW#&fR-nglTY_ z=Of{dGRa&d+=4{zGV#rE$@6jP`*G=;aq%4_@<1X>Bw26igcL#c6wste4@_xL{vW~gFdT>B zZZO@Pyvo&U?bX*gYOHr$x50V+#(k^S*jZS40nh;V@sqdJ*Ey)I#BDfgE3D!Cgy(It z?=U(hbLnzRc#6h)rwy9;xN(!K&y8n8#2_@;%?jqu7l^fY9$vA^X7knqR2rM~`^xH; z^&6d+t64ei!|S)_7u2j?Ye&8ZxE)??-3F%(n_PM*tm4wfRcmbFF7@?}8XKJ8w`|(t z77+A$U}&^|ay1$0AYQWnV@&2=T@T`~aDo?Eqi(OGcZ|su4vmc$mNwWsd2ih0g74kv zympr;QuEas_IR4d8r#+C zcFWW(k9*&Lk&>-r>IrwP-|TYX&Wro8U)(Q+EMH~4WVz*TM$thU+) zv|^3zQZ>uXyAPfWczEbWXmMF?cUxIA20E{O;+PVB>JaJuu3MrHZhok##v~j7!ABd{f|&zFp>tMVPd15 z;fjf&vI%Jf5*i`VPs6l1?_X$WZqF+I(zar_u2=kF&a=g%i*tSc!S&;A9k>GQ7FHD`BIb5CY=#oYON z@b(s|nEXntRSEACo|soy3ok^*<&lYrhL-N7_p=A4xL&O&Lcv?2O+%>#ZbyMf(BsHx8?lz)XU(3ud;h7xmtV8D>^ihi2`|B@zQO6{izxStca@Zk7cDa0yz`*%%a5*S z0v9ecf?ot$sAL4U&!4ZavC-Mh`|igtU+7&`txYAZjYUnhd9{^U6{YDVg{cL(Nx50^ zSs9Zz#*%rQ9gYH8J1)tCG z;EF|H)dGoFjctKIEDzWRKA<;TT&YXsq0{8Xve{u8Hs~f)0FMB@{ ziTl$saoY>t)p+OO)bx@g#{vK>xFtF^3!X@pec<^C$pvKh133MqtBgX_M} zC4<8y6-{Kj1KexpaOJq?9e0mg$4}j%aQLxFdF1cisI@O3_zk=n_&flV|<{Y^t?wzp=H~mAes7lQOh*jsVT@@rFNShn|PasQ}y(t8Bbw_o15+ z(MEP&3yIC_z?*j%cwp%r-DU3S_c(&uRo&5A-qBJDk)xp|udX_$wlcH4ATcxLZG6nr zm=8}rzIzn;GBh#njRK`ElZur^95X5SB#Ty`O|8#?Bds9^t1*|>2>xBfYA<88m$N!5 z0BeBT*8U_UjisxM=3|=mD>9cM>Axnp+hoJFXsiPXED&ohV*DR$$%8S7`7gv)LhJ>I zy%4cxBCaMHqalG62A#7O4ZJ~6{0Pn>-oJ!hN)ArD<|rD&=R{FeZ>5H1d0F-FphU>$ z0Ml};B|<_XsYDD|Pm(=_wX}}}nq->r>V85b)6zb?21s&B;pD946X4n8b!lv|!z1RF z=g2;h*tl%C<@4tp{No}cQ?oM|=-0{Q&L-|hZ^4i7VIW%%<38sQPah?LW)eV?Q?VPG zy6%O3c=bA|a0+N+40LK*2@nsD*`wo;mQnUKqcp#;mV6t@DMjSl)6qXpXYsG!cn1Co z$)T~e2QSl4jLl6<0!{9z5cVi0F{QAouDyR~7``L2^Q+0t3E){2Cbw^NR6rDka1!-7 z8wkc5v>Bd+7x4>>AlkwW{O`P`3&p)W!TGBX!E^!f>j5tkvr2(%cpc3xZrPQMCCwcM zrl;VI!P~y~Jhqn3&^J3pZs&J1{Cz?RUZBIqsWmkBWToAk^RQ=Cx}+S1);Axp4W)+4C1d9)v0MRDbQMPU|H^sY&mx&7@#uP%zw? z1n$|GZwz`4wE>*FfZkFfrPT)Byi4Y0NE)j&^vZzdz8}YV4rsIq$wrXW2Qd{8M;Ti( zkUiRp&)L5bK+h+b{udG-Md0F`)rj#|2qd!MVl-SWlSCliY+;Y0we6nY78yaF{#9v@ zq4_Zmn*pftK)9RyQ@RsH=u~|ej$Ty&YU|q7flEwTNsg zA$w2A`S_ZfT!G->v2iIeJO|X~f$$mex> zUU+QE!`M`90%!=|#JiR6T1`5%|LYh-+jB}p($KRyef7zklQ+Yy4qv_MAC{ew*4|b{ z?QQICDM?R!9Ugki@5a?D-rkq4UcGq5+w1a`bC)iky?FV=nREVkLKM0xQ$XF7sf1Dh z?(Qm#I6--OZw(fu22)P3p4C&6$!RalNse8<#tdSviW=l*!??Gr!JEgq4%l=>htN<8 z&KRtJVsP1W5ql8^T40V?KO?px2AVTp#+r{=ABaPJ;aDS$GA2BDIU25jk^!dYDDDcp{cLDZ!soGpX`zrO!d5N#CNXwv$v3ygOiQP6O-^5$Vj2v53TU#~ z4wB_Re%xkYL^haA13DT59UYrF9cVCG2pHtXijbiJBeHJ~zP>xUXfk3}>VynuXrLT; zB9{svw_gCZpc(^rH#GLd8F~_Ea)(d&e+(5^FO4$|XuO{Wd@!JXCFk!iTVd7KNduxsxQrk*mw`94+dd+=MQGP3bK4Sb0 z@pRF|I0A2GuR@HOSWHzkQa>TShxmUc^FA@nvqDi-wOkpj-!c1fJP5N}-q)&^bxQQc)0Ge$2a&hyqwZ9Z}=M8SFCV(dQ z35bouffcfZ;JLr&I zbPzerEN~nrS_5&efUo}AhK`#0j+QnmSw2GKAeyX+H) z@Z7rop}~={F`2Bro1U6c`s&S>9lPA&FM>i4;zxBu2k!O6k;93~=1WILAad}b$QT)o zipwG$dbPT}jpJo&`%Cav0pB@u_5_B!Evai&o!t7GT+kohA$))#mYlxyptkoL(0JDf zV!w-3YwgZnfAqPiB08rep|r}@m5dw)PVRmma*N5;?f__V8_zR0AG==+AvXypfIb}X zHg-DD{*h63hpwuqm@H5-a&$QRIqLa^%NIQ_TsnFF(y_DW-A|o8J#NOIMpHFE?1A zYNWkuFSAh*2^PslT97mtG5>}*E75Qj;%!E(*@&|MaTg-y4@l|_rJ)RpLc#p7I~w|i zyPArWP@loXKsKIHEq#dSe??3{d>*1h)ZT=~*fOp?d=k!{-9ZEoqq&RrUiEnZKyx`P zKA$7tbLCi1F->4Cw`!?aLNbljC%5Xpe);eRlvC1v8ImP^$yc82{FC+#;whTMq)mt% z7FJ&5(l9{zvlsEU_LoWQOwHv$<4&`Mh6fJ$f)fu84O?1yDH0z5GNQmMkXQSlxCD>E zi@eO!y>|Vn`w@RQfgi!(&v@a6$fT44CEV+3sh&j3(p|as=;GyYfw&LyG+7jC z?>zi5I^*s8uTYrF*;V;(0SG?^pZC&2YvltY~6kUScJ6w;9*QdYY${! zaz7f!eOFJOxqI|@py!#8UN-+zVh;Hi=^CEwWN;v6xE=C^_YcVW-+fbB-$pDcVFH2V zq*sWS_ru*f9`I3|J6HGgWjWAf(R|D+5ub(AnK~YGq=5fsX!QTBz8GdTVreKbkWrO zG|}5@wta{5sSB4p&R#rt(#!SO8E22v4(_KM+&%3Md)gj4X?y5|&A}6PhfY|z9zAo_ zU!kq&b8FG(HbSk1pWBM#+lv#vsU%UZ(j-t@X)>iI?f6lz-xlept~8pbZ1g-#1Bw4J zAqhuAUop7sS%|v^jZ`4+X2khrl%Y68RYd&RPy))vD2TgE#{7qgsrFxgHny;WVBL(w z7i3&LG}=Dt(7$4)Y5#+SCnttm5qDlgk?QJ=hKrU$b*Q^#$G+y~7Lkw(O!IggK95Uc z4NPMZSc@>zB0^#b{3iv;vj^$bke-2Qv7`@=4 z``1s}CA)A91TTzUo2r zWTyHs*Z7pS+srsKtT;*;`;VLMac10G4XUBR)wsT*vV7`8Jd z_uhrifRZwNW(Vl)k8Qa6BEDxMC+ke8u^*G4b^_a1Xhl$HE6BiFN*JFl` zhmK#qq0n6LsW~63r69JY@KbAHTq}V%R$)9KPT(HjR-DjUoB)BODCPa8ZMF-S=qWBS z-m0y`Zk~&T8)f5CG#G~j28g>3an;dqnG66G{5{Bb{Z{S0ttHBcKVxFB3`wua_?8yd zPV-fC7c8;LjNgt#e;=p*6Ak4f@l{0oZy9~Aj6P?A_KOVPSS%A>L+rnwI=$<+MaHTt z4HZ@OUcZTxN(5{+gU4lK0oHsjmVhS^f?Eqf1lA%D5KRa}4f%tx>wbdiegJxWVmvFm zLVcYhRD=#rSNi$~`^iZW;I5-%G&=W$=bdjCR*35hCr<@a={Vhj4a2kGD;TVC9Lc~Y z4VFNHTLMzXFRCS(A(3rawE@ut>ZC_UjvYuk$=DK1R2^Qy?Dz{qPN z8#MR9iRLDku3lzgNjgB7DUA(I z;OO_lqWF@2>CkXNX}zQKRp3%%qZ85R>!`Kat+A7ul2r!3S8b(@t>YDNXogU9`g|y) zbI1Upk76pDJJ)ONQ(IxJZ|qsx*xp2?pT2Z|sT#ibivP3P9;&s&CHV6p2M2_|&#G-o zD5!`ptcb}eNiMHBdF4KQA*@~J03iglTEiY5dGcD=>-1bb^V1N`p@cZ?_xMp_`Z4eO z5Y3mbu-$*Zyesoh#R8wwrQ(iQjH|NKYd^Z=wPO=`? zQh?P|5Z6OWMh0Z^ijgR zrfXpS!%Rav2a7Su6FiVN-iL-W9rn4+U#tsHe-OA5N#~AJ{)$BB&`=Q`*W=GZ?Ab^# zN5=Ro;<-%>)*$}e%*2(8mK!f#VK{fu9`|EDJRS>x=5V0KVQ~qjd3?Z{hXq&*_yWLM zBovB7Lb*{(#qtQ!2hmy5CubV0S}f^juy}M9kINV1T^&eGGCevrPN8$ktC|anYs;#d zsdO&B3J3{%7`KW*K4x)*BFM1B8u~&>Kbt4w@Wl{zzs-yzRvm!wz~YKTL_7fTg;FL5 zZW|zH8o{q)^ThC6;)qXJTp=9$iH(0)@=M=xG1vllIzT*an&=<|4?2raW$|fnWb&vC z9*ZX$7#$Nx`?*j}437x<`nwtI4l1ji%50-B+bJwOUzEdd?xr<%Qd%jDZWgbb$!+eT zSGRUowRAOg(^{#Fil&Z|`quKM4q~7jPknVg36@)iqz8T zl+xOy;;Q%pJeMObzx-oX(W|7a7YSL<<1?RpPJi?%H9RKe@#nOs2^pbL$#>p-z7zR5 zw>iAc`rvl!gK*TgIk?m2&~CfK+b!MP&RkPy$obfq69vZxe4ISV zjcUw|Y0Qak1kiJ1K#jSd8ggSgO5#r+_nNDsyLbg2t;57i=pC6PB&WPDwj zGUh5Y zK^>%Tee-b1iKAK%gH}qren!lh6N8nAZz*h>wS5oRv(W-oot?S|Aa!uLOeTxY0j#+! z4wnsBb9o#tkIMsLtfwVhICV1&(JPl}gY;*=Hr6CW>b!NkMo6iebs%u&{4{E9&Ek z@pOKUP{I*Oc+x%)Zd4&=Lep{Qm5`i}MB(zgnLIG*P9|O%x`WASrLvlO>CIF|GX+me zs_E#dZ0iQ$3I4=fj{Le7P;Omwc1;s8B^pm3O(?7Ynq%|IKIP$2j;QQnVv17U>#w=5 zQgWUpWj>Bif1Hs1@blO3xYW?7q|lFvx8KCxdL2{J&^R$ZI6BfdFd!Zn5RHxw4)qKD zZwFW$K4am2&cM}kkK-|IyCd6e+_&2u*<^Kalf?ln%L7^#p#7UH_HVY>zuoGfmZ^)A zr?*0V=KF@M_w`xt>#{!7XMd>A`cQ|@;g*k}hHR|H?5O(esD|w5y38nZvxDl^I;)4wdjZm&V&+2gc%GTIC@nsVok_v*FFZEW@&JZQAvb(c&MhGc^>VKCzSI;bD<&c~e+RNvoEVjfJPu4-S(P>WL|D|5lToQMeay>PPbY`v*+j zH94pGkD8hnI_+(I+hu}mvLdI{5yC6=3(0xU)8_JXMYuUdK3`NQV(>-ejD9MYoYYVD z5n_%zh&f5EG$!7OqK8g46xMc9s@pNpc-5<24ZtJ+*z1V zSos;xAT5i|!8At^Tdn}j#7zI}XNeh4h{^upvAD%B{8MVso49-LKX=pV2#pP4qv#(Q z?i(7EfO|x|d1d5!%>%Q6Q&}PennidB(n7M5*1-TM(TW{jJ!36)P zW$w1l$l2le6@}W2w{;os0zVe5!m@L3pb5^2}>Iu#kB%T%dc+ET&qge|~18;0Z{WHe;(`3RE zi1)YVLe(Ijb+qbvi2dUP$7LQE)4fKzIbw4N;{AG?u|(^kZ8u^ z@J(|RO&~@?c7O9Wmd9gKsl7BNjme}lSPTY>$>OjGv00O36E!0q+rzP1C?DG+#zBZ{ zjM6?42se-d(->>fbf%}=TF%qd$v2?C=|5uqyOZfOkMw`X@pmsfz5QhR%zaX%D}UPx zXo}`&rzf8uhD^E## zj!k_W_ch>6+@rWJe31~zM#+88M)1ZXQaslsFUtg~y@O}>IGxb8^Vn>CSkvMl$uZ!o zVdSE2==?ir7&*gTYxEs$j$Kly&xokacn1*IroE|6kE}_Htj2jS+W-xO1Vo`HsK* zZ2mG#14(`u7v0D7)BcJ$|3s3%A&wH_?2?In(8w1dul;j;bV1hLL;H0`C@YY7uB=x9 zF}IHo6eEG+=MSsrshUD?pRb}5eD4K>jqcuV3ayt$r_mTRI)OEv&1AA!SR6JJA~un- zv1enfMfge#mYBp^AeIOuQlX>|$i{FJK!e1SQ{yEAgydusNDU5239=^<9Qpp_JGlFY z<>&GfVt(9I$JFl#o;v@|lBZC{G!K8DF$U$kT20&AYO2o)M1~DKmmfF7;(l1NeQ>ga zP(bBjpnHin?=Cj(xdMBZ_YqP6Xgbgg_KY{9=;*DU0vb<-ZZE9I4xL-mG}W;JKqrw7 zoewd(3`#=`bVdOH{pM?41m5c{i`W_~{YiYrC9#ja}9n?OS8uyjtIJm7c?DJx96p9oHIw zoHUH~t$J@;`_=8M{|&#S&hRHVGBN_}1R6{|Wmk~oj7#(Gnc8rhKbA?jJE zikgw?N`o15O@nW(LxZ!%`m^x#Bd}_=BEI;CuLAxe8_LDwarA!xi@jCK@1CxdwEv2j z|0Uz77ctxq6-ro8-!|#zbrrKras#JlZ-M`*84BS+A=X% zh4?EAzb;WyGh3`?1a;w6-}?+EwX3_cr?;n<+DoDKQt4DGokqoEGr_ItESyE?_ymq@ z2qg>%-CQ0qd@Yn;W)w;IVvv+Ck&DrNhQ332nuezOP^KEI!~=t3(wM*NfyF@ch~0pu+cORu0-`xZ zAR_ux@RdU9WPb|o@y2-rXl6~#frbFx05s#gA*UJldpCnaw--0I6-)w+BS%$3T3KyM zNlj8wRU#QVa!X@~9kJe#K&RzJq~H#40QxzO(P?+zCuEjZj*X2>jE{~??yWkC_nRDm z?BO4Bf0vWT9#_vTHb*oq53MtGTWjR9+Q500zSBxw$CbJcD|GBv?6qB?V~0<4>{ja7 z1IK{)T774=J+@}Q%YQ zDl6fe!RR-oJ?F0kQ|Z0!9qpanon1X$-96nsl-^z{rI$vf0M-z$8FUJR-pgQ6=*%8E zy@$>Kb;FUtqO#ajHkS^yBwxV6woPIUZrvx8^kWhGM2W~?{d=;9roQ*3(?ZB}7Qegk zJ9eky#N=Inc#8CQt~?nt{^-q9NdvD>Af5DP2~HY(0_91XajO-!=SiAz*x(7syaD#y z&E|F!EwLTAiICk!Wwua(=Bd0vts#J}XzPLiP1tiA1n7c#B68F;1I_6bb!p|bUvT7@ z1Uj!QCbxv_Tfvco=$3u;B^!bVgpEgWsrBuhXaet;Ix@K<)X2!d01-AaGSjtf5AAk2 zwaNO(MvFu0CT^<@_pJn!^&Hi7?3eAaU5;f#$aeW|8$!0gF(AHL*J0T%Ym-Cg6xtqY zx4+QsNjOd|jp#0oX)kzJnewtE{#jYl3jn%2`DGcYq?dK+Z{CInE2|lj4t+mJ0}cH$ z)}M<+2V{(&kZ3`AoSK>WE-Q-_J~u88i9R5XA`-aBr1vKV@*#wvp)4f1gN7?H%Cx^D z+CShZqtBGl<{;Xf3CbLV3PbPv-MFBPct1~2W})7{BZemF(4`rR7p)*2diO=|yA*17 zTSq&ny%V_Z?C9$3=gLwL}~BtY3=T5>FRCn>I1{6M?; z!S?3+n>yc^b!G>(<-P1Eh^)_eTb%H`=<`#+x-=21B;iF>N<{RNkfp1P7q2jwIp5Uh ziYDrtIo_W>A-;n6f9tAPsBf@!kM1_V8_oxhc-?#O7NO4Z!7|hziG+s{M+-52LsSJM zx`-zv(SAVm-w^#58GV+FHVc48)W4$scl(cqBuCChf*BLN0Q6rFYsUm+Xx_rqPb(HI zHB%un^!ZBxRaIqGRh9LP^^MJqEv>Bt*`2MJ-Oh&A_S%-V>ZX=Ttkw!pV{18~GJI?+ zZ)&SmBOzwP4XtEPl9-Zk^-pr8?ZgfrBGvtj{2phyI z9AMAHx&;($&mDN_f(l}N8K?+Lh~}&O+Lk=Rp|h$Q(}^b8l+xPdl4?jD#K@f-=nq*% zAF>MHrRP6M%zT-WUD4DsI3ON^f5m>`;2@kxCWTjfL*s)Nd;_(tj%>3(zQOX)T4SKu zd4;YcpuBXK%@Qo@#XGDPZ?}+3+d@U#9FD5m78voJR?Bx=t86nfbUUq3cV|;=@aBe) zZH*yY+n(!mWcW5^Jn77T)mikhD&|-FiB=LDs{Bv;Y#E1v$wX7B`(*XoH`^W!P z$;8!VJCuE>|LK_IEgD;U=a%V3@0;~?N&605;?tgSm~D949lo0VgN*)98GQx@8cw*z zvXOekT!P>h`fP-0o;5+4jktd+$~aVBV6qi<16qS`#*3~w&w^uh~f{W{#Kqa91W>7Jq!a7h> zQC&+(LrZN(cLx|YV9n;S1VV!8Z&;HB7HNUPKU9t4e~b0BGVb?$|0mr4K(OSy(EJT( z;baRp*~KlKTxw&wH$`~2x{jWzc0BxC z*4jxrG|n57LmYW^psjVcx3oOgYs(0# zO9^jD3+pO;)s*+XFz#v5=Vzeegcn)SkN4_0El|}{R5jM#tuxd+6G;wFjENCysH=Eb zTjYiYoLu*5J-hcSLW$$TyNLd0#8kv6Gk(DWKW5CAjZ`A8KBE2|8#8T|j0&k^CSv~R z?6C7?*fzxf=S0uX2#Uggrtw36F$U#{t%n+F)lG7H7zYSueh?RstJH@E~shDug1bBHH~@Glv~|cSl?3D z+0(<~_OiJ&E}tuBn#3B1kl$e~{GRDaeFx&yfbrDW2zgnklPIDy~T=tc)$F0F>WD*vQ28oL^ht zOQ#INzn_>Z7O|xgUY``JA7d>TfY>kQA~c$jnWky7Ps{cgrrFpPLbDnK;@x(tJ0V@z zEC!UfnJW|Qf)fMQDqBn!Z7~6&tvLmfz5< z^50zJzq#SDX-nFjn#9oBggbzBa|Yz;=LMfZ&med>+aH;yqN}ppXvs>$x_m{{w`F3a z2k}gi;I|3>&wyiN!IC|D*VUAqN2p>PYC8gD89vT|jDe$be>9TZMwCC}$U&Vs4sluh zQ_!`=P6uww1ZPn1e`9;5{D^vgmJJpluJcg$zYNR`=BpSjTB^TR)8=`^+l1ug*!aXx z35ij$@$X_{-$cj0iTd>Vpm< zs+yY%YT+NVjvTp5F2SW?T3K~LO?`7$2h`sE60StV76_OkAxkU*u_a=TM8fTp@ZcyG zaS$5L$V%UA?z++H=z6n5YYbg*%-CzUbhoW4IPnfh6qaDX%G=BqZ80VBU9{N*Ym>43 zWV5mI7L&zWO#$nLT1I*RbpH9Z1sB&9U)3zSvcBZn#tOeJ<-VE?&+Qsh?$vw_#)`kw zlp0p~S1Jbc;u(B$mqzpdJ?D_M=$S&Kfe?9{C3#$pzs&B!(RqHe0BTL z>)VebL3f_M34Z=Q=-IoFS5a>gQd05?v&$RW)|#6o-_07{uJ_CU|LDbP??}vd9(52ZKjHwj4{v| zC)cE(T$^!fZT2aR>@ylU=hqip(k#5Xxx{B%-G_5kalvID11h3}%A*4+KLyn#KP*jr zX=<{6{$gDfH6w7%toWs1$rD30NPGj){|8UfXZ{M7jKn|J=c`%T9<#RIn)G&=pmFX5 zdnOY7h{W@eRAY3Yp4#m9AaLVuUGoc9-;0I0Xe3HD+>V9{kkAjYO%calHbOxRC064+ zZ5^Wpiw)r;v}}#(&A|IFBHrA8`0QTzvpWx-1m1geE99~N-A8`Gk9_Zf9^bh8#3$q_ zp=a0cJ-;6M;>LqlH^N@~K714MI{IxwYC>jyW_fK%Yga~fQ$k@?d|_2$adi?A2fvm# zq?Fc`v~+Z{I1s5BJffT;XaG^MAcpe6@PHe#YMdGjnU3`|&q_7yodsAl7)6FY`|eX%JVrJDFTa8Z8tC(A?aM z*B5QZBO7#L9cXG*YfpJ|Pf>kGeOK?$$k5o>Fhb)a!-GxDt#3ako(;V3eCd|?sq2Ow zmko|yGCX?G!t=V*#Q@J+_wPl#%P7ceYj0%IJ4JjNpG)DedH`{tna8CH1$3zx>Ox_^ znA6$O^yvAk-FEKF_4lo}bjQZ5=e%sM9eDE+h#A_r$Xl|*5~t+Nm{cXOVL0Dp0*L|y zHjJ%W%Lv?BWh-VKmxfRvZZ%WdVxsGOQX%!&+Al}frW{+7dVFo#NsY8q>$1*n%)7WL z@ABrtn|o`bua!gxmVWRr`*^D~DzM~(e{)LMJ-_RUOAS<47|vBTc6Qz|O8XHB%w=Oj z8FxFrO2U{6p?QM#E8@)<>3jF_!y5-%H8ZoV9!Iuazo>rWs>79Q0WOEGn^<|CII^iU z!wwDKlu3hR!kcKM6%7?5$#XP}w^x&K+!6D??uTxNuLAg83l{6?7#<3H{QO?{)8GeB zZihY&xcksQINUGz;f>qjK0#sEgTk%`hI`)%zZ^*D_M;0ykG%pOUI==0@y^pr!OzbJ zKff0KCj5QEtGJZt%>1mH#=_>#)QX1Bg_UtYc2RXwNo{g@JsgXhIy&h1vRMa%4FW#{ zW9wma$tA&ih;yI>LMk|=un(%6KCY;bBb2fQQWjsr;!ho=Y`&Bugls^p$2DyTc@pS> zshJqU|D2T}CF3b_#8qn=4}!h`U+p74X5hYb5U|U~l^-5sCf;Eelii8Mfy%FkMa)2A z%X2g7&2VfXYQe@{d>N&_3+D|e2P;~;8oFp=Y5yqR7h$Zvsp0CqC#D{k_a3^q=kR5{ zqu#nl;CMyH{oBVY>3zry1ytRl zcf&Etw;%Z34!Z#y2i*6*6?)k}^rGKAkeA=RbAIErQ(O-A!izk@MZ?N_H!d~83ll@>7YQ^C~9o018*M2doFUhoRH^l^bVfe;dVj) zsJEW`r9B7E?{Gb*?Rsvz%h|2Yr?)trB6M2I;pApVPmq?qr@GZqb*m#AY>sTSao=p? zzRmXV7Mp_`&D}K24yYNpEYo*UH+NSzJGj!&MNQWUP+o!)vz5v=3*{~5N}EiSaJn!i zP&NXN2^lD98Y*ryShx|NEYt$H;a+29En|q$5UH2%vR2z|yJC+Wd>)JsoK=X~um0(P zdh7v>*n{eEhc!MQUYBrm{g>mKsh*qC&TY%^+FEeiru5^@g4Z_+-uULf@ymUEvpgy= z@Vw`OC3u?u0#zfetvYR`ixBUp3277>F2Yx{Y5zp@xrp{R#L|Qyffv9U2u4yZ$ktE- zBGEsPU@@BD$mkm;dVWCE*{JtNL{-L}h%9|1eIXmjLzI~Vy|Y~p>5;1_K{>cvfB)@! z4+De4ZrusHdHaEH&;y^qP@jPN*8?A1z8P}KFXX&W@VV=^y?pPU^0|B3@4?x?N1ivs zj{DyCxPf)dFZ|@K$0q`wc-#y>>Ko=6_|)&|#|IyiUnOQHmDJ=lcBE7^#1>S>6jYH| z#}-z9t!%99=!JaQLY0$^uMZO<=JBxME4!(i(bU5Pz9Eft)A`*@Y~Ju=OAoW1#)cFN z1*LrPPjFejgw7E$dBDB&f3;*1WeJlngqI})Pw(WG5r8uAht)&F=8BKFq-!G1<#Rr} zD7Eq{BRy4kIJu{?y}KUx?xEJS_ri0DOWbmr$xGY?b?sSIjWr$JgTuIUtg5!!?Sk+2 zeP?tJUD@e&ew*{@t%(AI=bHS$a;Kq-)M7m zqm9P~8~gycUBmLIhSd>ui^Ce`hk)ZX#%?S1otNu45-8iL?zB;c;t%5TR&30G=|U|W zlod4%KnpR%hFF?Lio^*X+#nJN#EfOTY?tn`Ubf3>`7Rr^-8L)s*r;k-m>%*{c<-YA zZr|GXE^9xysz>eDh&izC)8X}T?i=DgG?R{RN`4P)9=CYYr)5^ z+&Sib$Nh5P;S0A8UI;vRDRBQqINm;VC1n4Vki*ySANF~0_(u4t+b?`y#yp5hipng? zu5Hb%ZH+If`&3vRQ&1gSR2`jP1$<}JG*`5DH<17D!R-JY2%z1xs&-0s2c@oyR?|hT zXz!_L?=5TXF0Ah;Z|#Ba?#6CL2ZKc>779lIbb@*xYT>WJ`w$0hJpJr%m1y2Fnv4P4fk z9)v7`?}a+gc>ikS{i}@JRvNjkFmPF}=e%6cX}ONWlHGQTciAr9VXZ=pV=Thi*9?pq z>>0qt?283&A|JAq@HWjn0lWa&;zwO!ULcH_AmOLtfrx}H*qa8i$O z)OhW@E^^thaYh&`h9`KVUH$t_8zw&vb-E{wdE_551ai>n#W zujNPh#)Mp6v(8L$i9Rs>i;}UUivtX`_E$xH?W2wZsb_o&*PMz{R4LYwg?Z^En050Ktwv(K%2*Zf0(<|{XYFWtD~=oQF)c`b1UHZrJ^yvv7@xLtE9CvzrG{4rmdi{y{NgfsEJT> zXK`~^NlSN8V`pA%M^QspO(zw;{X2-vI+<0topdPm@gN#x2C(+#9tMOKT&poLui*ME zI4xkytnZ?bUQ60Ap3m7stLl)qjR3wNU=hEc=Fa+VN(YTbW3%|UtCGtXa5y{`jY(_m z>8bDRs%q;jY;4PKXb0ugwB^;b7B#fvRrs4a^6Fc%YnpRwTPm8n;F|%Ana`nl-woa7 zaD11Wm$vI^Et_NOEIig(xvQHWT4Q`*mC^o{`fjTXTvi#nfYGYyIH=nm+vt9I{l2qn z%nxs{Kez?H&)(D5-qX*Hki%(zr*lEh=WjcmzvFm5*zM~5Q+HnYN5sC* zEXl5K&xTZ8S_k$Vm5=oyui{Ns+56nexZ>J`;<~Tp%~`drx$v;MHc)PTYi>gu;9k(! zkzLc8TiZr54Ypm|1uWud+t5QVZ|Ny*?yl~@)(AG=MrT2gZS1Dwh4N`kfEb(-I4i*) zl8(ZLc2GW^sM(rV*P2_`3g-n4t;N`JJIjINrjCO8HmKy-e7wwtbU->dDDLkUNu>g@ zm?sc$d3+{^OJ{SabVg4XrK`EKqprQZu%;=yvOcrC?rUjnUUg$b7X`eZCz6hhjg1WO z^Yb%Yysm9^JfZD;O3T(`18{75Xsz-7HO8($FdkXccUqzAsJ7QZZI9hbgMC`A=QZp- zH#nYJZFUe)1NYtRbW+34W9eQ;pcb4M)TR@Jf*1 z)AtGw?KV8JU5912?uq@nr}i73J8XR6wBePrW~9r;H?ErR4{iN$a7*-&Z809(QoK#F zA9|+0@cHuS+LwpkSc{1dW4yt)_a|AYi5 z(O@&QRAH8tvu-DCl1kn4> z_<~$d-*7$c>*RUE@uZLai5pI5Z`q!>VSVhn^)Vli%}GD&lQ(Tn`P-fev_E&-?p*M` zEB8Hup8H28e#)=RZD>!cX@MmDF}Ly~;nQz(Dqp3QzDO;8ms=TASd&=R0Jfc0-TW0) z)s#`w0-&c=G((ArS+8!(t!*!C>MCgJ1kk}9;g-Ti?ByjbT@V9GTe{#!aQd7Y3@a(9 zM04xf@GI1{=G3-k*S2KUv}9H{WmGj~)--2UH^T|sl~vPR*w8{@b4EsnWq5z0k&%(X zekq^NqqCX4OhzYz*2$oEF&RB2cFUzaidCVmkk| z2}#^Ue}-(RLN=H$8_1CjWRD0F2kDn57zT(nOGf)|YPDkc{Vf}{4asHCK|r(iUbpMM zAwhQ^-U<%$yYt{i(0!lVVctREmjWJMzVqbb?Z;<*LQY@1?RoX~i7SD}t^~S6Vg{zY zZep5G-Ej8waXRVac*4i-_%-_z*X>XGSRHKV6;l8J8uUp-K~%kJdGwm)k?WR6uUmTf zSRC`QJnn0G+|SC>-}-c*?b+KlUU%#--#g&@*ylyu$DFdXs^-+H=GdZ|cexd>(o0@` zEq#++4vNSueVthbC$YsfpG#_!${P~P8sm%WQ!1NMtD1oF^y-$B@+MGvO>0(NTUu31 zT4gh2^vs%;EF!FfzE(7+mNlkVHh(Q|f*Z5z;NI5E>edW+dR6n+$|k5vzg9G+mN%q; zDjMJxIEgE$DyVDg8^Qw-qr-hHMt4(vSz&H+Qe0%z+sE%B!ahWXllm0(JR|i}aY0&j zMP5@wc~fIeXL~b^+CihV(|TKbI%>*`Q{!Xb-43~H<8gko-SI7U$F;27*P9+#Yvj5b z{8-m{`Cf27O$0Z-d7mrrplOmJgBU?%^Y9Q-ev|I z<1YvB<{j3n^_|rrn(I2K8#u$a#2N!!kqi`!5xOEjNT%YXw_t z23u*~wbs06tpy6T(R^U56=ts$?x6L^dGk}(ZO>e{z1Xk)65u|xBl6IW=rab%x7@xy zx|ICLJLSoB@8idouQr~)7>|2EQ~)T|HTBP(-JX-ECTg99Sbs%=9}sUg;>;x$Ae~Rl z>-Z;P{bjK0$C})wK>^#e_riz4SW(pgZ@IF}Kv5MZjhnYZ18;{1-hC7p`s7yV6aUbs zz9El&L!V!}_xw`8qbouGUr*=#)K=c5dH)x)v$NCN{dRDU*d|CQkdt!G8ReW10!avE z2_cfmIpem;*~SsuV6ZU;N1QMw*zWELHMP4xY|VZzerKzmI(4M0tD=g}ee|3gA=&)3 z7H_r5TcvYF)sBe59+DS_q{ShLH6Sp0g(e?M>t?9SXi67V>7po|M45vq2Xc|*E>LkY zCtguTP`Zg4FG&|58-i3*m}-uYO%bLoW{7pwT)Pf6MMq`_I6}F!|`&-&Y6ge%I2oiDgKWOV36htX;XgGJJpS?cUe_{LlaU z*B`rgZ_Nz$b=K8Jf?==E?>Us$7w`sxzEH>)3PUv<@(EbW21cTu5TNe9dfe_^5A1>wmkA&&PB$XrpWvw~aXE3Rxl5+Ef7$jL!+GN_?Lg()c&6j%i{rGl1HNl%F}VKppx zJr`NeLp2J}&BF8+Nmh$Avt5?ar^jA#uqR^rxrWjPw+W4={nM{`KOG)(`RN#xKz=@n zNz2aF=}=8|iDUg|@6Q~4a`V>*vqwk!&$TurIh`mhIrl^Y7Hj}N{EGSI1oqIFbB>+H z@kN$sRefbmQ>d!hQ_<*)#(l8{Ppq*lTJNc9F0W~IRW{p04JDCAJHWc4(H5#VfiPWO ztu2eGoRw;4rOFwTJEOAFsJJ-7w*)yxAKU0->b*2z$-tWxE|StkP?q84hd`^!@ybJ~ zJ$Q|epbL)CtVAVnx`MM z%{>G?$RYDmiuo#8hfz5^5(m%yG8guf-_1N6^h_~*d4VYrtmQW#sCQCO6vE<+XCFOlVE z3b<$UIX}a&KVHrS6>tpMa4G=|d5{n!2xt(;GZ|-bq6>KO=^PL(m}hg?=L`7fLC(QT zlBvqm`TPvF#t^8}2jen#C6L$=5tf8l7LT=}>4=w$KiFji*NCu2#8gmF0CxPh9D(SCq#U?zpt9PU4C4?3FBQ z1f*xWF+?+lDEc5-7r<-0c(oU&@!_=rye>#Igvh2aULVR+`mtI+%~~OF#Z6T`j^?3Y z?^twbrhWeY=tB^uU(7#!v;1y%^YgdoJ6~RW{_=Y7+kffn#{2!}+Xv4+>_2_GyYXS~ z<>#+k2Y-G1<6pP-{(Q6Z?c=w<-tPagx&7tI*6!x^!TQJjm3N<)-+Wqp_3hHa9yR*3P_b2iVF2sp~k{f7}?NW-tZ37jlS!U-;6xC!~}bGghjS@e?`AOus7p@H=Rqoo1A z{3Qhk;69zlIgtenn|>4u6##Ma_oL0gn1Oj8MG`?;IDsN15X2WLg_o$(97RbALvcKv zd@_Ru;=!3r`l$@+Y3Ry7_GNNmw$ho*P{NqX%T(pr0{-b-&Z#`^@l3{v3$Fu$J6|LIuXKmU>gP(FT+l#5eZN-G*W`y0ClYTJ5hTYF+nozeRCNL_27swGg> z8m?^%#ybM>PG5Z&kiW6pRnzIL?r_w!msYozRJ9jZwiQQPtracSXq&mB)f8#b2b*-k zCM{5)Nf&BS2b)Yay_WhxAazBDCep47cW5iR)R7KtWw$EQsR(z-L+ypZ4pE>@=xddP zx@6HFX}DJ!>IGHkYZv(1h5lB1^VOPbH%9J1ee`B$@^Am~@BjP%{I9?M{A2On-OjEKW075=F#`iG)Z4^53tweo$qiJAh9Xjt z1xf;6MG|RoJUNCV!?2}5`Al&hL!3hwWsvyUG;s!1gd_@)Bq5wANWt?Gv78IJtaDk6 zbJ_GWS+vtxGywFeG|H(A%F$FjbY4Uf_IMgCfha$o$^IE6TIBac3l;%&8~;smn%97&$Y(Wmk)slp`6fLo%Bt=+D1SNBTFF}clQn`jyjseLrT%te+Zjf99oTa%;C_J0ZP9aM8 zmU3gb0dTDH)hhk9GEbGrQNb+^a*6{4jYH+BIbz4dim@qn9LzyLl;U9}1b8U{?jWQ( z@bEHXs*8*)Cm}o(q=$y~Qc*q{+DAwGndyFJMv$Eq;%0~WxfO!Eh_IkSgo}!C6%t~N zl3cH&2jwgkgMrB;CnXb(ox%R?aPQs^M?t*K|L;F#|I^>HfA}@;=M&hAFlGTktu^^; z8oREHjt^WLZ|fau>gucS=!tjq#XI|J+xu$T`>NXdDq9AsItQa2gVDDBiuR#UYk#0+ z&{N;%j`#YS`n`?)<;{bhmZ7r7eph{;vthtl-(M2%v&DOD@jiRJzo@R)RNG^!?Xxrt znd=8E@j+|jkfrg8t@&zk^EFWIt;6=V(UQ&yAV>G4qi5Q8^`@(D&e=cf=$&^D-t-JE z)K4uZaB z{r&!%y+1#F`|I<;!OG+1@rhADXLW5|q_Qel5er0O{zxnwtLf zbTFP7E-Xr9>XNx;INu8Ao8fE<E{o+EIKnT4+V_EoiRGukQ;mk7y zbG4paRaut2G+k^(@r+2m2?)VALRho)$yE78tS}*;2T&%c?27U#Z7{Cz)~NioYJZ*5 z2LUa0#{kecRVkXQ=i7ouig0i%23CZH+wce*4ql8)wc%2Wafo6(q6Cky6A&c?gp-IW zC8Asuw2P8fMoo9qGJwjNSzczApOfq7TLXoLN{Ho4bB>a&~BJW@ut&U}C0k zY`S}NvTJ0r?dnA9)rp3|vAX`z`oWRB;1Z}_iV_YTbmu3QgY zTZoR{u9{q|oqo`G{ZakgQr*mQZKRV)5F$-L-e0pT64#5Wj>h z8PtPAeR=-rVDr<#*1?}|_rI;Z+*(?BbanV@OIvegbyX-@8Lq61RK~&;QI9uR<_VN} z{3T^ROR?K%aae3FtKDI;l_+!;u~H}1n5EhxRdJa>V`UcV7=>E8SVNI$2z&*WBQIb} z^B9FWR8baLkVfF5$b2-tFpVxthwjfJhf%~xwkk{GNLQ7j#O5rCDVb7u5yQV&z`l^j zJ`4OfopusUItIs{NF!g!=U&2#F5(4A6bV9PI)fJ<%b@-O!+>;q3B?-q1J2bZvGqv|bs|N13FL%4?u7y#K=2Yll0+;_#EUOsg~>!If}ufi z^qCS{rldGW;V96$v!x|DveG=IGf&~nDlE{D2@TnP{U}dWQr`2SeSqj!YJ}= zzJacH%gU?NfjWS){E%iKrLRUEsMQ4OK-kC>m=G*&5=G83d5@T|sU{5Ef`yy0sTLf< z3^fSQ09kQJD;{aXqd;S)W|ojM?36SYEz?EIc2F~&)J!)sr<|2v#>{uK^4;wGG7iSg z#g+4LZa&c^AUOr(G9k5G!t}}6K_xG&6NOExno|8hJhU(|u)1{j)vJvcFP^PFUU|6m z;O^qR`I|RKXXmes&kj$|4bRRGPC{7skI(jwO?Qn>wO^ZT8XRvPnrIvtZyK6x9iE0X zGSm3`blu=&-Oyys&}7xXM6_=#+CLs0oQMrgRsTLyeRZy8_}KP{ zt)|I4O;fiUr|#5G+-{h>+c0^rcJf}`)MEYAV)Oh`&z&a&i|asF9&Yq4u6Hf0uDt#H zX8#Z1xX-r1JO`Zk%f>boh}X70L!;iO?=*jT33A1uNrQa+>DwPGPuH%Fj>qa6s~VfD zo7Y)_Y)d)kTst0VlptAOP@P!b=iK zvJ|o+i6~7ZN&#NLk_*&soGCyoiWKO*L~{tEDa(`DGX>@hz9~~+$>5nllg85{m})pf zl}cB^X-YT^+Ut_V(^EB0IrIS8Do_jEF==^Beh9R}R}0|QM;fK&RXDXHm8Ajef-T`9 zv5>ASKVrZl3>dfplWHhH7_dkq7HPnMcMwJ#%7{goaA+eC7HJ`66dkIBmSd;qme6yG z$(g0}95)ARr{$H<^GX@{c4k2d3tPg%m2io64%yBnm+)zJKCPHfFBUMY0=7lKEfVw0 zLY_s$EzziI0=}7v-`Aczdj4YL+4FTk_Pq!9r>@@`p1wXdzi@SC4lq49F?)#hO!w$a z$F*so-if*1iTTd4+4j-dw$a&+vH8~F*}>UcW49m7-d~wtdVFK~$&E)(u0H~G?bho0 z?X}IjPhZ?we}3!f^BYh8|8<>D#_vBKSzH-eTphl*I=uLJ`2O1H($le}^|7U=6U*xp zD;tx`&_1=vhnoveUoXCR|LD!9^}}@W3WVd`FCb~GzuR4Zzx!lsXZ_vI>z#wQdtW~8 zA8haLK3G}p`F%K0T^Fcos%h=6ZSRdVw1t~HgDu@|0C8Q1tGcx~95=XQCQqfoU7>YF zl%+v=NkC-v39NpJBP=Te|GYYe#m_K%6}}pd+09fJQzS-mp$R846bN)!kui&-&X?IT z_u+NxQTcXoF-3YsFKKv6q*vj)xp@> z1gbKHYfRzjFJJ{{vYBAmI+w#coy9$qCp?=kJe$Wqo69|)&j+a&gx51U>ROq3;)Wyu6-GD!xfY0`w2Oj${;+D)*AhD)M#1Bj$`(M=wv*-Ozl$*K~j-YqB&%H1)gw^mXX6FMryt|*{c z>Zz6kR}R)|LiK=eir$Ul8j`^VKvyTz6iHP11)PYcaUIcN5PC>RZ2?jXDh8#)ppAqK zEe5T_qV>2mEjCS$%P`>6P52BmG1E-UHj{JAr0im9ZZR#_M#;6&@~qT63wV>93uL0^ z8>tv04QrrdO)R33O*L}pIyPOyVyamz4Tq`Xa+N&3l+EK&SQ0kZs?#;rR?kjPfQY&A z{MqBR$Jg&HPT#yWK7V6mc7AyF`q1Qj|JdBX#N5E-T>tpo;MDxU^!(7&+?DD1vFkUd zZr&Nces})fgIi0>_g9~+ZftJ6*?PUb{eE}v0~odT_dgzddAA2$qw)Q0;8#2QFQB8% zdoQ;4o&$XX+JF9E{fix_2TNb>9Oeh$%I|jptXq3uw!q}K_x1JOm)E=C{e3_)`0&fm z`?+V5}Z^whOEqxJSs zT~VOg>a8-ART|wM5&CzA>jcGh1FuzoW8o|~b zI++&6)T2O1Wg9M&6(ATS5(0$m~$Uc!qn0=vaZF5*BqkpNOJ76>llL@7)S2)^jT zQZS-{bb;WRK_o~9+XGbzgLpc$Rk{krHD*go^L1Xl(T~@A2x=E3m4m2plC)(MT{+F* zr5imAvyWp7@k=Xsr4{Vr5Ze~u6$e3V01c@W))hih!vf(O}RT3<^k%L8&02)tGcOHbaF;Q)AOL ze^&;mT3m)6pQ$Be7BR6#a-NBjXJg?^v;rd#9b;w@%^Z@MOSOr)4rO79qOeFR*7Mm) z231ZaODQA~i6WuWBs8j+!4fk$0y>9B=kw@XCYedaQ|Kg`TA`||t(}>fxq0XA&AW@Y zA3U61xHCR?dt_!|WbVf3^@XwP3;m-reIrwyLswgRdRu$?n!9`I+q)_oJ0kV1;dqO` zuGv@HRN2;3-}`&d$kfo({I&U;(|7MLuC2fLxby1M-m6c$uXgrcLFYFgy#D+J2rvqg z?$+M7H=x=3_V$q4Lw(!Y|Mv3p*Uk5P&+R%J!IHr8W|4{4(93V$4$;;lZG#ltI&sG`kMPmQ=NhLhT` z(jtu1S|GDwRSt~Gg;Bc;G$4-_p?LaKnjAq>09Qsblqk9q$y6hm8U#ayVrgMCHGmI< zS_D&%Wb0EI8sKo>l|oe{krc@!MG6I~i9{t@V9ivza@6HXG|eS~959_Jbs!jOkR{MO z6OyGvbM#O+78m6zoJ3QIY!2Xo6RTZhoeMZ9MdPCC-3*hLW${BY2iVpC2dD@%ex}9G zE(-AMVU`twSnRCi6^A*tAkQ8WIU-_L#bL~-l7g|%Tg@+x5H*f8z5&iur_fZe!>A27 pP9)0`K_$tPNb<7<0=m}u{{S$RS9%f`*}4D#002ovPDHLkV1jM2CwKq= diff --git a/images/rapidproxy.jpg b/images/rapidproxy.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dee0b35bd0b39f7629c4650983f4119a00d3ccc1 GIT binary patch literal 4611 zcmb`JcT`hLx4=&by@%cdNf4w6geFo1B25qk3%!F#yYwmwC`BNYP!v>9q>55P6QwI9 zbWlpD(gmc278H;dyz5){uJ50>-db;;GqZlP_nv+B%$b>UPVuL+0P|ICJ#7F40s(fU z3pkwzP3oagw%1LJwDk0m{EHp_#ZJzCjwBxsl8@~_WAl0m02lWF0F3!Z=kNpo-p2v}*VI2c zL@od@MFT+X*MD@oMt{Z&;`rq698Agu01Txx0B?I=`!fc>q(XM*4gl;F0RV$J0I&`M z0IlWUdHnI9@FPw1ANv2RPA35rKn@0<-Q-|$3UYD^N?IyPk}%NFQqwXqFf%hTFfl?{ zx!52qoGgq??0oE;+}ympyv%I;P<|dL7Y{GbSr8C81qCGqB|Q}tJr9Hl!t;NY(-r_i z4LAYYU=R!-gMh#g&}kdMN18J-@Y!1YkC1~Y07@z{5H-mv&kTUb$;be53Nq5;&pgh& z$tfU|EUauoGE^|6h7a7Box|QiSWXj@s&z9m7fb5KLyGrD{uA>o02l;Nk!(^BfQ$?T zCI^ARWK@6bATj^~W)YGhr_eCAXN5(w`Cuqp*-dh>Lv!1w697G_BN+q?0WJe4WL+#= z`&OO#+WSA_8%~_Rm|<6C8cntLif7UjDSt>fh8lR?A~cFA7Urpc?BSZp;2jC`(nlBO zMg%;8G>Rc3x%CsfitUgRVa`ZJvBPdFXC?ZVDLNfDHyXezr(G>hiLb6IZdwr+2=fD5!k{jzaO0=5MjCo8A?ZetgFW2hS7zS-;gWS2__9_)G!H z^fg>&kM?c`C$ifpDHnKiKHglI=(SFEw_CcbmV-R!MTpyR8wQ}2~s z7y|E=ZByOR0^|Fm>aw#R0JmQDe&{#_hD%R@-qOy8!Q)1<_s+vN)D)@bk)iuX+2RI8 z^6d^1_>x~c`YLKs@{g|kHl<$-kbi0W^+rp=#zkb{3)@%Ds@htWW+Tr}TJauA&mZe9zGwcl68KGJrH_uFTMu7d9zcKX&8H6GBKxb{1lkA zedT?zcFueMZq4*D*PhYl{VwZzzQQZ?#T=Pv)9y)t1vJKC%{uf1i#bFU%6Fd6G;Fl3Zx?jbwf}bQ^TtzGDY`oHTC{p; z>;|HhCmr3!ga$&uN@~hkbkrV98LI@aaB%hhFX;l2Dk*I9Rq+yvca=Vv^h~MGk1mDloC1Q9`k>z3>%PrPO^&y#74D$B zY}_W-_DczjuC}agyW>W48Dg0dwF+A1Wp^_cL%p!#qnp7!l047=ynxfBf(KFrUB`QoY7kcMeKb}#uS6%&Jj44cboYvFXf$`V?9gUZz=a!Rp8s^JSt zeO@~a%}nvjzNK8)U46b%h=(56Wy4mrPPdr>sopO=_7!GsBO(OC_J<~g)^S&&;1Pr1 zdw<)K*7WFt(!}v*PSGUXH?6DEQl8=7U&tat%7SM6&1n(Qu63K{PfKx(tm&p@U5oaq z4HaH*1}EMvym*hZ?zL16!wtLmeT8fN;{spmP~Z~pW9|{Oyla5%{Ui=TL)eA=|v;1+3cyD0-M@9Ynll{HxGMcvyH&g`zH`iXqR|MoKS_9(E1_%d$?a9 zCJoY;tS+zQf1G4 z?A8=l2@Ogq+G9)(qZH0OL0Zut!%*CcCSHDPY47^|#8#VvJ`5a&Q$I1#zLN|qm{k~l z)keIF$=zoVmPLQ}vqH|$Npb4mETDX`6)c%mdO(+}%_?L~I5-v}GcOT5ezbK!xVXH< zk8)BHwR!bPAmCl^Y z2g4+>3JxlM>^-p|U4r?%($#H_`-a$Tz#S{RuUMh1*(i9Ijh)nor+Ruk!%Gt0%dT5* z@wi!KwpdH4+pNzr5NF5c-7Z=Qgk$`QKZw9P(bqz+XZ~oh;ak``dT>jCb8g1wFYcTU z+w#SYKF_LAreLA==7*+UqC4jl-9H8S>Rl>3UIU;9>?Wba{#Txfnvt&~8mn2f%y;za zU)+#^BKv)Gw*xN{by~Q_1{U7$gH@~9|GG7{;@8WRS}f8`sUWm8vpt>C8Ou<%02^8N zar5)l-5p5v^Hm7;aIqg`fNT`V7H{-nO%W5D?si2TQ{5+0!5VLDxo#EVZPr^DhznkA zU&ZFDtR7`_KIW08)EKAs|wkvW)8w`;5E3cpk~71mSC6V4oTKb7C=};8-1N}X ziV95AW`)yIf3mVyB7d3Erp>ZIO^6nb?PGVfH56USN)$$CEG3HkO5xKv5?NpgM45b& z&f*yqKh}Cf$^Jp2R7u>wfT7Er@138SFh#Gs)y^m4Y8e73aA`X~nayS$E0KRB75!w| zDKw_XLV7cTb?xYCs-vs;Ip=I1xw=myj!PNSk4m4!XOD|dhFyEhi&Dn3FvI;c0wV%R z-&+e!{_yw)pLthH;qvq35>NEiK--_KnNvJYl5&RfHmx!r+xcG1rj^5uVv-IYp_^0K zTw6o+1;UKSq5y(=dh>9$Ym<#lci7XmcjtJPEgFZsj>TV9A-sd!>5f&IP>sKPUShH>_Pqt|^tv$ZpuAndT(;)7C=({S} zFpiDHpcr+Zjl!Ht`LE!e!}tPSieo!uq=hTw9SV# zB-@AIZm55YHXQm=UPHK$5nPxvs%!YYs(id~DE?Od=|7*|VI z%3!w%K*7`BpyO+5C$RIe*bT3hCzoztylkj85c%12ojI$&*s33`dQl{&^I3qaKxf;s zPND8*TPzecTjxFn4a~iidF&y4{Mc7gT!bJqoZr{xV_TZ=aqTx-?WIe)lNS1vw)rbc zDfQ(e)eS@$uHJe=%}Uo_Ew4Q)!e+bY$E|Q#BT4+(v&vI7G(&lWMf3YGM34E)Y$md* zw>O;QmKYrI!hV))~g5qmT zd}E9ab8>}XZ)&CTf3L%5@Ur<}#T3rLTI%)SG#+-rUt{fV8e=agoO>0C8$|jvsD7NW z7!^bB?{x$lm2@f^@Z22|=%dmwd(V(0<=97C=B*mJ&0BF#=I9g%o(Pw8%TW;SVEwW< z6`^~6&S&Hm^GNP(YD+~l+=;>c%JXmcLHpsi5LUL-yyCpEuST!pC*dk@zp9B0`Fb7OM3HCh4cEAr zvE>4KZrJ`b6bcI;(?Ci;GjZR$O=2!-tgILQ2$QTvRF-nS4!A@vDFBq zs+^qML*OK0Ew9Jn?j3QS@z8*+QM?*^W>g~nXtjF?1>3L7k&?B*wo99s@3PEccg);h z3SvgMtVw^T#xsr6M4hTmtnAY)g1@tr9hzA7wKk?PvMCRb)REmLQdHoW7paZ-(u|+S zI&{LyU&Kq%E;4xA#g)42`J~ikGyKZRM|fTNm9hH5kIAAHYs-H1d~Zi9CS!tsK6~Q` zy!LbY$4S2>eI$$e!^Li{mS@vRQc>v^tn+ufC3{6Gpw?w9ILi8Z$KhfLpJRia!^-*9 zE>RYMIOclkK~D$n6fh0)BCglcknu%Qbfx=5%KyB0fVlM}&nJEIwsuSxbJ Date: Sun, 12 Apr 2026 01:25:45 +0200 Subject: [PATCH 39/54] fix: prevent duplicate ID segments in full-path selector generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When generating full-path CSS/XPath selectors, elements with id attributes had their selector appended twice — once in the id branch (line 30) and again unconditionally (line 50). This produced selectors like 'body > #main > #main > #target > #target' instead of the correct 'body > #main > #target'. Move the append into the else branch so it only fires for elements without an id (elements with id already append in the if branch). Includes 2 regression tests. --- scrapling/core/mixins.py | 2 +- tests/parser/test_general.py | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/scrapling/core/mixins.py b/scrapling/core/mixins.py index c2e7420..5cbcba4 100644 --- a/scrapling/core/mixins.py +++ b/scrapling/core/mixins.py @@ -47,7 +47,7 @@ class SelectorsGeneration: if counter[target.tag] > 1: part += f":nth-of-type({counter[target.tag]})" if css else f"[{counter[target.tag]}]" - selectorPath.append(part) + selectorPath.append(part) target = target.parent if target is None or target.tag == "html": return " > ".join(reversed(selectorPath)) if css else "//" + "/".join(reversed(selectorPath)) diff --git a/tests/parser/test_general.py b/tests/parser/test_general.py index 26f6e6c..3961660 100644 --- a/tests/parser/test_general.py +++ b/tests/parser/test_general.py @@ -321,6 +321,45 @@ def test_selectors_generation(page): _traverse(page) +def test_full_path_selector_no_duplicate_ids(): + """Test that full path selectors don't duplicate id segments (regression test)""" + html = '

' + page = Selector(html) + target = page.css("#target").first + + # CSS full path should not duplicate id selectors + css_full = target.generate_full_css_selector + assert css_full.count("#target") == 1, f"Duplicate #target in CSS full path: {css_full}" + assert css_full.count("#main") == 1, f"Duplicate #main in CSS full path: {css_full}" + + # XPath full path should not duplicate id selectors + xpath_full = target.generate_full_xpath_selector + assert xpath_full.count("@id='target'") == 1, f"Duplicate @id='target' in XPath full path: {xpath_full}" + assert xpath_full.count("@id='main'") == 1, f"Duplicate @id='main' in XPath full path: {xpath_full}" + + # The generated CSS selector should actually select the correct element + result = page.css(css_full) + assert len(result) == 1 + assert result.first.text == "Hello" + + +def test_full_path_selector_mixed_id_and_no_id(): + """Test full path selectors with a mix of elements with and without ids""" + html = '

Text

' + page = Selector(html) + target = page.css("p").first + + css_full = target.generate_full_css_selector + # p has no id, so it should appear as a tag name; div has id + assert "#wrapper" in css_full + assert css_full.count("#wrapper") == 1 + + # Verify the selector works + result = page.css(css_full) + assert len(result) == 1 + assert result.first.text == "Text" + + # Miscellaneous Tests def test_getting_all_text(page): """Test getting all text from the page""" From 273c8c2fa02db3003db604c8b21447574440d3c0 Mon Sep 17 00:00:00 2001 From: sjhddh Date: Sun, 12 Apr 2026 09:46:55 +0200 Subject: [PATCH 40/54] fix: emit valid XPath node test for ID elements in full-path mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: In full-path XPath generation, elements with IDs were producing bare predicates like `[@id='x']` which creates invalid XPath steps like `//body/[@id='main']`. Now emits `*[@id='x']` for full-path mode (e.g. `//body/*[@id='main']/*[@id='target']`). Short-path XPath mode unchanged — still uses `//*[@id='x']` prefix. Also added XPath evaluation assertion to the regression test to verify the generated selector actually selects the correct element. Co-Authored-By: Claude Opus 4.6 --- scrapling/core/mixins.py | 7 ++++++- tests/parser/test_general.py | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/scrapling/core/mixins.py b/scrapling/core/mixins.py index 5cbcba4..868d620 100644 --- a/scrapling/core/mixins.py +++ b/scrapling/core/mixins.py @@ -26,7 +26,12 @@ class SelectorsGeneration: if target.parent: if target.attrib.get("id"): # id is enough - part = f"#{target.attrib['id']}" if css else f"[@id='{target.attrib['id']}']" + if css: + part = f"#{target.attrib['id']}" + elif full_path: + part = f"*[@id='{target.attrib['id']}']" + else: + part = f"[@id='{target.attrib['id']}']" selectorPath.append(part) if not full_path: return " > ".join(reversed(selectorPath)) if css else "//*" + "/".join(reversed(selectorPath)) diff --git a/tests/parser/test_general.py b/tests/parser/test_general.py index 3961660..293e93e 100644 --- a/tests/parser/test_general.py +++ b/tests/parser/test_general.py @@ -342,6 +342,11 @@ def test_full_path_selector_no_duplicate_ids(): assert len(result) == 1 assert result.first.text == "Hello" + # The generated XPath selector should also select the correct element + result = page.xpath(xpath_full) + assert len(result) == 1, f"XPath '{xpath_full}' selected {len(result)} elements, expected 1" + assert result.first.text == "Hello" + def test_full_path_selector_mixed_id_and_no_id(): """Test full path selectors with a mix of elements with and without ids""" From d952db8ef8fe3bf5a418b9b156c0e19c2a899cd1 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 12 Apr 2026 17:59:00 +0200 Subject: [PATCH 41/54] feat(browsers): add a new feature to block ads This is working by aborting all requests to known ads domains. --- scrapling/cli.py | 11 + scrapling/core/_shell_signatures.py | 2 + scrapling/engines/_browsers/_types.py | 1 + scrapling/engines/_browsers/_validators.py | 9 + scrapling/engines/toolbelt/ad_domains.py | 3537 ++++++++++++++++++++ scrapling/engines/toolbelt/navigation.py | 29 +- scrapling/fetchers/chrome.py | 2 + scrapling/fetchers/stealth_chrome.py | 2 + tests/fetchers/test_utils.py | 126 +- 9 files changed, 3688 insertions(+), 31 deletions(-) create mode 100644 scrapling/engines/toolbelt/ad_domains.py diff --git a/scrapling/cli.py b/scrapling/cli.py index 8ecee2d..9ca0dd4 100644 --- a/scrapling/cli.py +++ b/scrapling/cli.py @@ -309,6 +309,11 @@ def _common_browser_options(f): default=True, help="Run browser in headless mode (default: True)", ), + option( + "--block-ads/--no-block-ads", + default=False, + help="Block requests to known ad and tracker domains (default: False)", + ), ] for decorator in decorators: f = decorator(f) @@ -498,6 +503,7 @@ def __build_browser_kwargs( real_chrome, proxy, parsed_headers, + block_ads, ) -> Dict[str, Any]: """Build shared kwargs dict for browser-based commands.""" kwargs: Dict[str, Any] = { @@ -507,6 +513,7 @@ def __build_browser_kwargs( "timeout": timeout, "locale": locale, "real_chrome": real_chrome, + "block_ads": block_ads, } if wait > 0: kwargs["wait"] = wait @@ -538,6 +545,7 @@ def fetch( proxy, extra_headers, ai_targeted, + block_ads, ): """Opens up a browser and fetch content using DynamicFetcher.""" parsed_headers, _ = _ParseHeaders(extra_headers, False) @@ -552,6 +560,7 @@ def fetch( real_chrome, proxy, parsed_headers, + block_ads, ) from scrapling.fetchers import DynamicFetcher @@ -597,6 +606,7 @@ def stealthy_fetch( allow_webgl, hide_canvas, ai_targeted, + block_ads, ): """Opens up a browser with advanced stealth features and fetch content using StealthyFetcher.""" parsed_headers, _ = _ParseHeaders(extra_headers, False) @@ -611,6 +621,7 @@ def stealthy_fetch( real_chrome, proxy, parsed_headers, + block_ads, ) kwargs.update( { diff --git a/scrapling/core/_shell_signatures.py b/scrapling/core/_shell_signatures.py index 9778318..ce509b6 100644 --- a/scrapling/core/_shell_signatures.py +++ b/scrapling/core/_shell_signatures.py @@ -58,6 +58,7 @@ _FETCH_PARAMS = { "cdp_url": Optional[str], "useragent": Optional[str], "extra_flags": Optional[List[str]], + "block_ads": bool, } _STEALTHY_FETCH_PARAMS = { @@ -84,6 +85,7 @@ _STEALTHY_FETCH_PARAMS = { "cdp_url": Optional[str], "useragent": Optional[str], "extra_flags": Optional[List[str]], + "block_ads": bool, "allow_webgl": bool, "hide_canvas": bool, "block_webrtc": bool, diff --git a/scrapling/engines/_browsers/_types.py b/scrapling/engines/_browsers/_types.py index 2741407..932b2e2 100644 --- a/scrapling/engines/_browsers/_types.py +++ b/scrapling/engines/_browsers/_types.py @@ -88,6 +88,7 @@ class PlaywrightSession(TypedDict, total=False): useragent: Optional[str] extra_flags: Optional[List[str]] blocked_domains: Optional[Set[str]] + block_ads: bool retries: int retry_delay: int | float capture_xhr: str | None diff --git a/scrapling/engines/_browsers/_validators.py b/scrapling/engines/_browsers/_validators.py index 69b3766..8d47061 100644 --- a/scrapling/engines/_browsers/_validators.py +++ b/scrapling/engines/_browsers/_validators.py @@ -85,6 +85,7 @@ class PlaywrightConfig(Struct, kw_only=True, frozen=False, weakref=True): useragent: Optional[str] = None extra_flags: Optional[List[str]] = None blocked_domains: Optional[Set[str]] = None + block_ads: bool = False retries: RetriesCount = 3 retry_delay: Seconds = 1 capture_xhr: str | None = None @@ -127,6 +128,14 @@ class PlaywrightConfig(Struct, kw_only=True, frozen=False, weakref=True): if validation_msg: raise ValueError(validation_msg) + if self.block_ads: + from scrapling.engines.toolbelt.ad_domains import AD_DOMAINS + + if self.blocked_domains: + self.blocked_domains = self.blocked_domains | set(AD_DOMAINS) + else: + self.blocked_domains = set(AD_DOMAINS) + class StealthConfig(PlaywrightConfig, kw_only=True, frozen=False, weakref=True): allow_webgl: bool = True diff --git a/scrapling/engines/toolbelt/ad_domains.py b/scrapling/engines/toolbelt/ad_domains.py new file mode 100644 index 0000000..026a936 --- /dev/null +++ b/scrapling/engines/toolbelt/ad_domains.py @@ -0,0 +1,3537 @@ +""" +Built-in ad/tracker domain list for use with block_ads=True. + +Source: Peter Lowe's ad and tracking server list https://pgl.yoyo.org/adservers/ +Used config: https://pgl.yoyo.org/adservers/serverlist.php?hostformat=plain&showintro=0&startyear=2000&mimetype=plaintext +""" + +AD_DOMAINS: frozenset = frozenset( + { + "101com.com", + "180hits.de", + "180searchassistant.com", + "1rx.io", + "2020mustang.com", + "207.net", + "247media.com", + "24log.com", + "24pm-affiliation.com", + "2linkpath.com", + "2mdn.net", + "2o7.net", + "2znp09oa.com", + "30ads.com", + "3337723.com", + "33across.com", + "360yield.com", + "3lift.com", + "3o9s.short.gy", + "4d5.net", + "4info.com", + "4jnzhl0d0.com", + "50websads.com", + "518ad.com", + "6sc.co", + "777partner.com", + "77tracking.com", + "7bpeople.com", + "7cnq.net", + "82o9v830.com", + "a-ads.com", + "a.mktw.net", + "a.muloqot.uz", + "a.sakh.com", + "a.ucoz.net", + "a.ucoz.ru", + "a.vartoken.com", + "a.vdo.ai", + "a.vfghd.com", + "a.vfgtb.com", + "a.xanga.com", + "a11.click", + "a135.wftv.com", + "a5.overclockers.ua", + "aa-metrics.beauty.hotpepper.jp", + "aa-metrics.recruit-card.jp", + "aa-metrics.trip-ai.jp", + "aaddzz.com", + "aax-eu-dub.amazon.com", + "aaxads.com", + "abacho.net", + "abc-ads.com", + "ablink.comms.trainline.com", + "ablink.info.wise.com", + "ablink.news.emails-puregym.com", + "ablinks.mail.hinge.co", + "aboardlevel.com", + "absolutering.com", + "absorbingband.com", + "abstractedauthority.com", + "abtasty.com", + "ac.rnm.ca", + "accountsdoor.com", + "acebunny.com", + "acemlnb.com", + "acobt.tech", + "acridtwist.com", + "actionsplash.com", + "actonsoftware.com", + "actualdeals.com", + "actuallysheep.com", + "actuallysnake.com", + "acuityads.com", + "acuityplatform.com", + "acustomizedgift.com", + "ad-balancer.at", + "ad-balancer.net", + "ad-cupid.com", + "ad-delivery.net", + "ad-pay.de", + "ad-rotator.com", + "ad-score.com", + "ad-server.gulasidorna.se", + "ad-space.net", + "ad-up.com", + "ad.71i.de", + "ad.a8.net", + "ad.abcnews.com", + "ad.abctv.com", + "ad.aboutwebservices.com", + "ad.abum.com", + "ad.admitad.com", + "ad.allboxing.ru", + "ad.altervista.org", + "ad.amgdgt.com", + "ad.anuntis.com", + "ad.auditude.com", + "ad.bitmedia.io", + "ad.bizo.com", + "ad.bondage.com", + "ad.centrum.cz", + "ad.cgi.cz", + "ad.choiceradio.com", + "ad.cooks.com", + "ad.digitallook.com", + "ad.dnoticias.pt", + "ad.domainfactory.de", + "ad.exyws.org", + "ad.grafika.cz", + "ad.gt", + "ad.hbv.de", + "ad.hyena.cz", + "ad.iinfo.cz", + "ad.infoseek.com", + "ad.intl.xiaomi.com", + "ad.jetsoftware.com", + "ad.keenspace.com", + "ad.lgappstv.com", + "ad.liveinternet.ru", + "ad.lupa.cz", + "ad.mediastorm.hu", + "ad.mg", + "ad.missena.io", + "ad.musicmatch.com", + "ad.myapple.pl", + "ad.mynetreklam.com.streamprovider.net", + "ad.nachtagenten.de", + "ad.nettvservices.com", + "ad.nttnavi.co.jp", + "ad.nwt.cz", + "ad.period-calendar.com", + "ad.profiwin.de", + "ad.prv.pl", + "ad.reachlocal.com", + "ad.simgames.net", + "ad.style", + "ad.technoratimedia.com", + "ad.tv2.no", + "ad.universcine.com", + "ad.usatoday.com", + "ad.virtual-nights.com", + "ad.wavu.hu", + "ad.weatherbug.com", + "ad.wsod.com", + "ad.wz.cz", + "ad.xiaomi.com", + "ad.xmovies8.si", + "ad.xrea.com", + "ad.ztylez.com", + "ad0.bigmir.net", + "ad01.mediacorpsingapore.com", + "ad1.emule-project.org", + "ad1.kde.cz", + "ad2.iinfo.cz", + "ad2.lupa.cz", + "ad2.netriota.hu", + "ad2.nmm.de", + "ad2.xrea.com", + "ad3.iinfo.cz", + "ad3.xrea.com", + "ad4game.com", + "ad4mat.com", + "ad4mat.de", + "ad4mat.net", + "adabra.com", + "adaction.de", + "adadvisor.net", + "adalliance.io", + "adap.tv", + "adapt.tv", + "adbilty.me", + "adblade.com", + "adblade.org", + "adblockanalytics.com", + "adbooth.net", + "adbot.com", + "adbrite.com", + "adbroker.de", + "adbutler.com", + "adbuyer3.lycos.com", + "adcampo.com", + "adcannyads.com", + "adcash.com", + "adcast.deviantart.com", + "adcel.co", + "adcell.de", + "adcenter.net", + "adclick.com", + "adclient1.tucows.com", + "adclixx.net", + "adcolony.com", + "adcomplete.com", + "adconion.com", + "adcontent.gamespy.com", + "adcovery.com", + "adcycle.com", + "add.newmedia.cz", + "addfreestats.com", + "addme.com", + "addressfriend.com", + "ade.clmbtech.com", + "adecn.com", + "adeimptrck.com", + "ademails.com", + "adengage.com", + "adetracking.com", + "adexchangegate.com", + "adexchangeprediction.com", + "adexpose.com", + "adext.inkclub.com", + "adf.ly", + "adfeed.marchex.com", + "adflight.com", + "adforce.com", + "adform.com", + "adform.net", + "adformdsp.net", + "adhaven.com", + "adhese.be", + "adhese.com", + "adhigh.net", + "adhub.media", + "adhunter.media", + "adimage.guardian.co.uk", + "adimages.been.com", + "adimages.carsoup.com", + "adimages.go.com", + "adimages.homestore.com", + "adimages.omroepzeeland.nl", + "adimages.sanomawsoy.fi", + "adimg.com.com", + "adimg.uimserv.net", + "adimg1.chosun.com", + "adimgs.sapo.pt", + "adingo.jp", + "adinjector.net", + "adinterax.com", + "adisfy.com", + "adition.com", + "adition.de", + "adition.net", + "adizio.com", + "adjix.com", + "adjug.com", + "adjuggler.com", + "adjuggler.yourdictionary.com", + "adjust.com", + "adjustnetwork.com", + "adk2.com", + "adland.ru", + "adlegend.com", + "adlightning.com", + "adlog.com.com", + "adloox.com", + "adlooxtracking.com", + "adlure.net", + "adm.fwmrm.net", + "admailtiser.com", + "adman.gr", + "adman.otenet.gr", + "admanagement.ch", + "admanager.btopenworld.com", + "admanager.carsoup.com", + "admanmedia.com", + "admantx.com", + "admarketplace.net", + "admarvel.com", + "admaster.com.cn", + "admatchly.com", + "admedia.com", + "admeld.com", + "admeridianads.com", + "admex.com", + "admidadsp.com", + "adminder.com", + "adminshop.com", + "admixer.net", + "admized.com", + "admob.com", + "admonitor.com", + "adn.lrb.co.uk", + "adnami.io", + "adnet.asahi.com", + "adnet.biz", + "adnet.de", + "adnet.ru", + "adnetasia.com", + "adnetwork.net", + "adnetworkperformance.com", + "adnews.maddog2000.de", + "adnium.com", + "adnxs-simple.com", + "adnxs.com", + "adocean.pl", + "adonspot.com", + "adopsboost.com", + "adoptum.net", + "adoric-om.com", + "adorigin.com", + "adotmob.com", + "adpepper.dk", + "adpepper.nl", + "adperium.com", + "adpia.vn", + "adplxmd.com", + "adprofits.ru", + "adpushup.com", + "adrazzi.com", + "adreactor.com", + "adrecover.com", + "adrecreate.com", + "adremedy.com", + "adreporting.com", + "adrevolver.com", + "adriver.ru", + "adrolays.de", + "adrotate.de", + "adrotic.girlonthenet.com", + "adrta.com", + "ads-backend.chaincliq.com", + "ads-bilek.com", + "ads-dev.pinterest.com", + "ads-game-187f4.firebaseapp.com", + "ads-img.mozilla.org", + "ads-portal-cdn.vidaatv.net", + "ads-twitter.com", + "ads.365.mk", + "ads.5ci.lt", + "ads.73dpi.com", + "ads.aavv.com", + "ads.abovetopsecret.com", + "ads.aceweb.net", + "ads.acpc.cat", + "ads.acrosspf.com", + "ads.activestate.com", + "ads.adfox.ru", + "ads.administrator.de", + "ads.adred.de", + "ads.adstream.com.ro", + "ads.adultfriendfinder.com", + "ads.advance.net", + "ads.adverline.com", + "ads.alive.com", + "ads.alt.com", + "ads.amdmb.com", + "ads.amigos.com", + "ads.annabac.com", + "ads.apn.co.nz", + "ads.appsgeyser.com", + "ads.as4x.tmcs.net", + "ads.as4x.tmcs.ticketmaster.com", + "ads.asiafriendfinder.com", + "ads.avazu.net", + "ads.bb59.ru", + "ads.betfair.com", + "ads.bigchurch.com", + "ads.bigfoot.com", + "ads.bing.com", + "ads.bittorrent.com", + "ads.blog.com", + "ads.bluemountain.com", + "ads.boerding.com", + "ads.boylesports.com", + "ads.canalblog.com", + "ads.casinocity.com", + "ads.casumoaffiliates.com", + "ads.cbc.ca", + "ads.cc", + "ads.cc-dt.com", + "ads.centraliprom.com", + "ads.channel4.com", + "ads.cheabit.com", + "ads.citymagazine.si", + "ads.clasificadox.com", + "ads.co.com", + "ads.colombiaonline.com", + "ads.com.com", + "ads.comeon.com", + "ads.creative-serving.com", + "ads.cybersales.cz", + "ads.dada.it", + "ads.dailycamera.com", + "ads.deltha.hu", + "ads.dennisnet.co.uk", + "ads.desmoinesregister.com", + "ads.deviantart.com", + "ads.devmates.com", + "ads.digital-digest.com", + "ads.digitalmedianet.com", + "ads.digitalpoint.com", + "ads.doit.com.cn", + "ads.domeus.com", + "ads.eagletribune.com", + "ads.easy-forex.com", + "ads.economist.com", + "ads.elcarado.com", + "ads.electrocelt.com", + "ads.elitetrader.com", + "ads.emdee.ca", + "ads.emirates.net.ae", + "ads.epi.sk", + "ads.epltalk.com", + "ads.eu.msn.com", + "ads.fairfax.com.au", + "ads.fastcomgroup.it", + "ads.femmefab.nl", + "ads.ferianc.com", + "ads.filmup.com", + "ads.financialcontent.com", + "ads.flooble.com", + "ads.fool.com", + "ads.footymad.net", + "ads.forbes.net", + "ads.formit.cz", + "ads.fortunecity.com", + "ads.fotosidan.se", + "ads.friendfinder.com", + "ads.gamecity.net", + "ads.gamespyid.com", + "ads.gamigo.de", + "ads.gaming-universe.de", + "ads.gaming1.com", + "ads.getlucky.com", + "ads.gld.dk", + "ads.gmodules.com", + "ads.goyk.com", + "ads.gradfinder.com", + "ads.grindinggears.com", + "ads.gsm-exchange.com", + "ads.gsmexchange.com", + "ads.guardian.co.uk", + "ads.guardianunlimited.co.uk", + "ads.hbv.de", + "ads.hearstmags.com", + "ads.heartlight.org", + "ads.hollywood.com", + "ads.horsehero.com", + "ads.hsoub.com", + "ads.ibest.com.br", + "ads.ibryte.com", + "ads.icq.com", + "ads.ign.com", + "ads.imagistica.com", + "ads.imgur.com", + "ads.independent.com.mt", + "ads.infi.net", + "ads.internic.co.il", + "ads.ipowerweb.com", + "ads.itv.com", + "ads.jewishfriendfinder.com", + "ads.jobsite.co.uk", + "ads.justhungry.com", + "ads.kabooaffiliates.com", + "ads.kaktuz.net", + "ads.kelbymediagroup.com", + "ads.kinxxx.com", + "ads.kompass.com", + "ads.krawall.de", + "ads.leovegas.com", + "ads.lesbianpersonals.com", + "ads.liberte.pl", + "ads.linkedin.com", + "ads.livenation.com", + "ads.ma7.tv", + "ads.mail.bg", + "ads.massinfra.nl", + "ads.mcafee.com", + "ads.mediaodyssey.com", + "ads.mediasmart.es", + "ads.medienhaus.de", + "ads.meetcelebs.com", + "ads.mgnetwork.com", + "ads.miarroba.com", + "ads.mic.com", + "ads.mmania.com", + "ads.mobilebet.com", + "ads.mozilla.org", + "ads.msn.com", + "ads.multimania.lycos.fr", + "ads.muslimehelfen.org", + "ads.mvscoelho.com", + "ads.myadv.org", + "ads.ndtv1.com", + "ads.networksolutions.com", + "ads.newgrounds.com", + "ads.newmedia.cz", + "ads.newsint.co.uk", + "ads.newsquest.co.uk", + "ads.nj.com", + "ads.nola.com", + "ads.nordichardware.com", + "ads.nordichardware.se", + "ads.nyi.net", + "ads.nytimes.com", + "ads.nyx.cz", + "ads.nzcity.co.nz", + "ads.o2.pl", + "ads.oddschecker.com", + "ads.okcimg.com", + "ads.ole.com", + "ads.oneplace.com", + "ads.opensubtitles.org", + "ads.optusnet.com.au", + "ads.outpersonals.com", + "ads.oxyshop.cz", + "ads.passion.com", + "ads.paymonex.net", + "ads.pexi.nl", + "ads.pfl.ua", + "ads.phpclasses.org", + "ads.pinterest.com", + "ads.planet.nl", + "ads.pni.com", + "ads.pof.com", + "ads.powweb.com", + "ads.printscr.com", + "ads.prisacom.com", + "ads.prod.webservices.mozgcp.net", + "ads.program3.com", + "ads.psd2html.com", + "ads.pubmatic.com", + "ads.quoka.de", + "ads.radio1.lv", + "ads.recoletos.es", + "ads.rediff.com", + "ads.redlightcenter.com", + "ads.revjet.com", + "ads.reward-hunt.com", + "ads.samsung.com", + "ads.saymedia.com", + "ads.schmoozecom.net", + "ads.scifi.com", + "ads.seniorfriendfinder.com", + "ads.servebom.com", + "ads.shizmoo.com", + "ads.shopstyle.com", + "ads.sift.co.uk", + "ads.sjon.info", + "ads.smartclick.com", + "ads.socialtheater.com", + "ads.soft32.com", + "ads.soweb.gr", + "ads.space.com", + "ads.sun.com", + "ads.suomiautomaatti.com", + "ads.supplyframe.com", + "ads.syscdn.de", + "ads.themovienation.com", + "ads.thestar.com", + "ads.thrillsaffiliates.com", + "ads.tiktok.com", + "ads.tmcs.net", + "ads.todoti.com.br", + "ads.toplayaffiliates.com", + "ads.townhall.com", + "ads.travelaudience.com", + "ads.trinitymirror.co.uk", + "ads.tripod.com", + "ads.tripod.lycos.co.uk", + "ads.tripod.lycos.es", + "ads.tripod.lycos.it", + "ads.tripod.lycos.nl", + "ads.tso.dennisnet.co.uk", + "ads.twitter.com", + "ads.twojatv.info", + "ads.ultimate-guitar.com", + "ads.uncrate.com", + "ads.unison.bg", + "ads.usatoday.com", + "ads.uxs.at", + "ads.v-lazer.com", + "ads.verticalresponse.com", + "ads.vgchartz.com", + "ads.virtual-nights.com", + "ads.virtuopolitan.com", + "ads.vnumedia.com", + "ads.walkiberia.com", + "ads.watson.ch", + "ads.weather.ca", + "ads.web.de", + "ads.webinak.sk", + "ads.webmasterpoint.org", + "ads.whoishostingthis.com", + "ads.wiezoekje.nl", + "ads.wikia.nocookie.net", + "ads.wineenthusiast.com", + "ads.wwe.biz", + "ads.xhamster.com", + "ads.xtra.co.nz", + "ads.yahoo.com", + "ads.yimg.com", + "ads.yldmgrimg.net", + "ads.youtube.com", + "ads.yumenetworks.com", + "ads1-adnow.com", + "ads1.msn.com", + "ads1.virtual-nights.com", + "ads10.speedbit.com", + "ads180.com", + "ads2.brazzers.com", + "ads2.contentabc.com", + "ads2.femmefab.nl", + "ads2.gamecity.net", + "ads2.hsoub.com", + "ads2.virtual-nights.com", + "ads2.xnet.cz", + "ads2004.treiberupdate.de", + "ads24h.net", + "ads3-adnow.com", + "ads3.contentabc.com", + "ads3.gamecity.net", + "ads3.virtual-nights.com", + "ads4.gamecity.net", + "ads4.virtual-nights.com", + "ads4homes.com", + "ads5.virtual-nights.com", + "ads6.gamecity.net", + "ads7.gamecity.net", + "adsafeprotected.com", + "adsatt.abc.starwave.com", + "adsatt.abcnews.starwave.com", + "adsatt.espn.go.com", + "adsatt.espn.starwave.com", + "adsatt.go.starwave.com", + "adsby.bidtheatre.com", + "adsbydelema.com", + "adscale.de", + "adscholar.com", + "adscience.nl", + "adsco.re", + "adscpm.com", + "adsdaq.com", + "adsdk.yandex.ru", + "adsend.de", + "adsensecustomsearchads.com", + "adserve.ams.rhythmxchange.com", + "adserve.gkeurope.de", + "adserve.io", + "adserve.jbs.org", + "adserver.71i.de", + "adserver.adultfriendfinder.com", + "adserver.adverty.com", + "adserver.anawe.cz", + "adserver.ariase.org", + "adserver.bdoce.cl", + "adserver.betandwin.de", + "adserver.bing.com", + "adserver.bizedge.com", + "adserver.bizhat.com", + "adserver.break-even.it", + "adserver.cams.com", + "adserver.cdnstream.com", + "adserver.cherryfind.co.uk", + "adserver.com", + "adserver.diariodosertao.com.br", + "adserver.digitoday.com", + "adserver.echdk.pl", + "adserver.friendfinder.com", + "adserver.generationiron.com", + "adserver.hwupgrade.it", + "adserver.ilango.de", + "adserver.industryarena.com", + "adserver.info7.mx", + "adserver.irishwebmasterforum.com", + "adserver.janes.com", + "adserver.kontent.com", + "adserver.mobi", + "adserver.news.com.au", + "adserver.nydailynews.com", + "adserver.o2.pl", + "adserver.oddschecker.com", + "adserver.omroepzeeland.nl", + "adserver.otthonom.hu", + "adserver.pampa.com.br", + "adserver.piksel.mk", + "adserver.pl", + "adserver.portugalmail.net", + "adserver.pressboard.ca", + "adserver.sanomawsoy.fi", + "adserver.sciflicks.com", + "adserver.scr.sk", + "adserver.smgfiles.com", + "adserver.trojaner-info.de", + "adserver.tupolska.com", + "adserver.twitpic.com", + "adserver.virginmedia.com", + "adserver.waggonerguide.com", + "adserver01.de", + "adserverplus.com", + "adserverpub.com", + "adserversolutions.com", + "adserverxxl.de", + "adservice.google.com", + "adservice.google.com.mt", + "adserving.unibet.com", + "adservingfront.com", + "adservrs.com", + "adservrs.com.edgekey.net", + "adsfac.eu", + "adsfac.net", + "adsfac.us", + "adsflowprime.top", + "adshrink.it", + "adside.com", + "adsiduous.com", + "adskeeper.co.uk", + "adskeeper.com", + "adsklick.de", + "adsmart.net", + "adsmartracker.com", + "adsmetadata.startappservice.com", + "adsmogo.com", + "adsnative.com", + "adsoftware.com", + "adsolut.in", + "adspeed.net", + "adspirit.de", + "adsponse.de", + "adspredictiv.com", + "adspsp.com", + "adsroller.com", + "adsrv.deviantart.com", + "adsrv.eacdn.com", + "adsrv.iol.co.za", + "adsrv.kobi.tv", + "adsrv.moebelmarkt.tv", + "adsrv2.swidnica24.pl", + "adsrvr.org", + "adstacks.in", + "adstanding.com", + "adstat.4u.pl", + "adstest.weather.com", + "adstub.net", + "adsupply.com", + "adswizz.com", + "adsxyz.com", + "adsynergy.com", + "adsys.townnews.com", + "adsystem.simplemachines.org", + "adt598.com", + "adtech.com", + "adtech.de", + "adtechjp.com", + "adtechus.com", + "adtegrity.net", + "adthis.com", + "adthrive.com", + "adtiger.de", + "adtilt.com", + "adtng.com", + "adtology.com", + "adtoma.com", + "adtrace.org", + "adtrack.voicestar.com", + "adtraction.com", + "adtrade.net", + "adultadvertising.com", + "adv-adserver.com", + "adv.donejty.pl", + "adv.freeonline.it", + "adv.hwupgrade.it", + "adv.mpvc.it", + "adv.nexthardware.com", + "adv.webmd.com", + "adv.wp.pl", + "adv.yo.cz", + "advangelists.com", + "advariant.com", + "adventory.com", + "adventurousamount.com", + "advert.bayarea.com", + "advert.dyna.ultraweb.hu", + "adverticum.com", + "adverticum.net", + "advertipros.com", + "advertise.com", + "advertiserurl.com", + "advertising.com", + "advertisingbanners.com", + "advertisingbox.com", + "advertmarket.com", + "advertmedia.de", + "advertpro.ya.com", + "advertserve.com", + "advertwizard.com", + "advideo.uimserv.net", + "adview.com", + "advisormedia.cz", + "adviva.net", + "advnt.com", + "adwebone.com", + "adwhirl.com", + "adworldnetwork.com", + "adworx.at", + "adworx.nl", + "adx.gayboy.at", + "adxpansion.com", + "adxpose.com", + "adyoulike.com", + "adz.rashflash.com", + "adzbazar.com", + "adzerk.net", + "adzerk.s3.amazonaws.com", + "adzestocp.com", + "aerialapps.com", + "aerserv.com", + "af-ad.co.uk", + "affec.tv", + "affectionknit.com", + "affili.net", + "affiliate.1800flowers.com", + "affiliate.dtiserv.com", + "affiliate.travelnow.com", + "affiliate.treated.com", + "affiliatefuture.com", + "affiliates.allposters.com", + "affiliates.babylon.com", + "affiliates.globat.com", + "affiliates.streamray.com", + "affiliates.thinkhost.net", + "affiliates.thrixxx.com", + "affiliates.ultrahosting.com", + "affiliatetracking.com", + "affiliatetracking.net", + "affiliatewindow.com", + "afflnx.com", + "afftracking.justanswer.com", + "afraidlanguage.com", + "agencysignals.com", + "agkn.com", + "ah-ha.com", + "ahalogy.com", + "aheadday.com", + "aim4media.com", + "airplanecoffee.com", + "airpush.com", + "aistat.net", + "ak0gsh40.com", + "alchemist.go2cloud.org", + "alclick.com", + "alenty.com", + "alexa-sitestats.s3.amazonaws.com", + "algorix.co", + "aliasanvil.com", + "alikeaddition.com", + "alipromo.com", + "alluringbucket.com", + "aloofmetal.com", + "aloofvest.com", + "alphonso.tv", + "als-svc.nytimes.com", + "amazingcounters.com", + "amazon-adsystem.com", + "americash.com", + "amidsttruly.com", + "amung.us", + "analytics-production.hapyak.com", + "analytics.adpost.org", + "analytics.algoepico.net", + "analytics.bitrix.info", + "analytics.cloudron.io", + "analytics.ext.go-tellm.com", + "analytics.google.com", + "analytics.htmedia.in", + "analytics.icons8.com", + "analytics.inlinemanual.com", + "analytics.jst.ai", + "analytics.justuno.com", + "analytics.lucid.app", + "analytics.mailmunch.co", + "analytics.mobile.yandex.net", + "analytics.momentum-institut.at", + "analytics.myfinance.com", + "analytics.ostr.io", + "analytics.phando.com", + "analytics.picsart.com", + "analytics.pinterest.com", + "analytics.pointdrive.linkedin.com", + "analytics.poolshool.com", + "analytics.posttv.com", + "analytics.santander.co.uk", + "analytics.swiggy.com", + "analytics.tiktok.com", + "analytics.xelondigital.com", + "analytics.yahoo.com", + "analyticsapi.happypancake.net", + "ancientact.com", + "androiddownload.net", + "aniview.com", + "annonser.dagbladet.no", + "annoyedairport.com", + "annoyingacoustics.com", + "anrdoezrs.net", + "anstrex.com", + "anxiousapples.com", + "api.affiliations.site", + "api.amplitude.com", + "api.appmetrica.yandex.ru", + "api.eu.amplitude.com", + "api.intensifier.de", + "api.iterable.com", + "api.kameleoon.com", + "api.lab.amplitude.com", + "api.rudderlabs.com", + "api.swetrix.com", + "api2.amplitude.com", + "apolloprogram.io", + "app-analytics-v2.snapchat.com", + "app-analytics.snapchat.com", + "app-measurement.com", + "app.pendo.io", + "app2.salesmanago.pl", + "appboycdn.com", + "applovin.com", + "appsflyer.com", + "aps.hearstnp.com", + "apsalar.com", + "aptabase.com", + "apture.com", + "apu.samsungelectronics.com", + "aquaticowl.com", + "aralego.com", + "arc1.msn.com", + "archswimming.com", + "ard.xxxblackbook.com", + "aromamirror.com", + "as.webmd.com", + "as2.adserverhd.com", + "aserv.motorsgate.com", + "asewlfjqwlflkew.com", + "askdriver.com", + "aso1.net", + "assets1.exgfnetwork.com", + "assoc-amazon.com", + "aswpapius.com", + "aswpsdkus.com", + "async.gsyndication.com", + "at-adserver.alltop.com", + "at-o.net", + "atdmt.com", + "athena-ads.wikia.com", + "ato.mx", + "attractionbanana.com", + "attribution.report", + "atwola.com", + "auctionads.com", + "auctionads.net", + "aud.pubmatic.com", + "audience.media", + "audience2media.com", + "audienceinsights.com", + "audio.ad", + "audit.median.hu", + "audit.webinform.hu", + "augur.io", + "auto-bannertausch.de", + "avalonalbum.com", + "avazutracking.net", + "avenuea.com", + "avocet.io", + "awempire.com", + "awin1.com", + "awstrack.me", + "awzbijw.com", + "axonix.com", + "ay.delivery", + "ayads.co", + "b-s.tercept.com", + "b.videoamp.com", + "b3.videoamp.com", + "ba.afl.rakuten.co.jp", + "backbeatmedia.com", + "bagsurprise.com", + "banik.redigy.cz", + "banner.ambercoastcasino.com", + "banner.buempliz-online.ch", + "banner.cotedazurpalace.com", + "banner.easyspace.com", + "banner.elisa.net", + "banner.finzoom.ro", + "banner.goldenpalace.com", + "banner.inyourpocket.com", + "banner.linux.se", + "banner.media-system.de", + "banner.nixnet.cz", + "banner.noblepoker.com", + "banner.northsky.com", + "banner.reinstil.de", + "banner.tanto.de", + "banner.titan-dsl.de", + "banner10.zetasystem.dk", + "bannerads.de", + "bannerboxes.com", + "bannerconnect.com", + "bannerconnect.net", + "bannergrabber.internet.gr", + "bannerimage.com", + "bannermall.com", + "bannermanager.bnr.bg", + "bannerpower.com", + "banners.adultfriendfinder.com", + "banners.amigos.com", + "banners.asiafriendfinder.com", + "banners.babylon-x.com", + "banners.bol.com.br", + "banners.cams.com", + "banners.czi.cz", + "banners.dine.com", + "banners.direction-x.com", + "banners.freett.com", + "banners.friendfinder.com", + "banners.getiton.com", + "banners.iq.pl", + "banners.passion.com", + "banners.payserve.com", + "banners.resultonline.com", + "banners.sys-con.com", + "banners.thomsonlocal.com", + "banners.virtuagirlhd.com", + "bannerserver.com", + "bannershotlink.perfectgonzo.com", + "bannersng.yell.com", + "bannerspace.com", + "bannerswap.com", + "bannertrack.net", + "bannery.cz", + "bannieres.wdmedia.net", + "bans.bride.ru", + "baremetrics.com", + "barnesandnoble.bfast.com", + "basebanner.com", + "basketballbelieve.com", + "baskettexture.com", + "bastingart.com", + "bat.bing.com", + "bbelements.com", + "bbn.img.com.ua", + "beachfront.com", + "beacon.gu-web.net", + "beacon.netflix.com", + "beacons.gcp.gvt2.com", + "beacons.gvt2.com", + "bebi.com", + "beemray.com", + "begun.ru", + "behavioralengine.com", + "belstat.com", + "belstat.nl", + "benefits.sovendus.com", + "benfly.net", + "berp.com", + "bespoke.iln8.net", + "bestboundary.com", + "bestbuy.7tiv.net", + "bewilderedblade.com", + "bfmio.com", + "bhcumsc.com", + "bid.pubmatic.com", + "bidbarrel.cbsnews.com", + "bidclix.com", + "bidclix.net", + "bidpapers.com", + "bidr.io", + "bidsopt.com", + "bidswitch.net", + "bidtellect.com", + "bidvertiser.com", + "big-bang-ads.com", + "bigbangmedia.com", + "bigclicks.com", + "bigfishapparel.com", + "bigreal.org", + "bigworldfashion.com", + "billboard.cz", + "billtable.com", + "birthdaybelief.com", + "bitmedianetwork.com", + "bizible.com", + "bizographics.com", + "bizrate.com", + "bizzclick.com", + "bkrtx.com", + "bleachbubble.com", + "blingbucks.com", + "blis.com", + "blockadblock.com", + "blogads.com", + "blogcounter.de", + "blogherads.com", + "blogtoplist.se", + "blogtopsites.com", + "blueconic.com", + "blueconic.net", + "bluekai.com", + "bluelithium.com", + "bluenest.net", + "bluewhaleweb.com", + "blushingbeast.com", + "blushingbread.com", + "bm.annonce.cz", + "bn.bfast.com", + "bnrs.ilm.ee", + "boffoadsapi.com", + "boilingbeetle.com", + "boilingmadsoup.com", + "bongacash.com", + "boomads.com", + "boomtrain.com", + "boredcrown.com", + "boudja.com", + "bounceads.net", + "bounceexchange.com", + "bowie-cdn.fathomdns.com", + "box.anchorfree.net", + "bpath.com", + "bpu.samsungelectronics.com", + "braincash.com", + "brand-display.com", + "brandreachsys.com", + "brandybison.com", + "braze.eu", + "breadbalance.com", + "breakableinsurance.com", + "breaktime.com.tw", + "brealtime.com", + "bridgetrack.com", + "brightcom.com", + "brightinfo.com", + "brightmountainmedia.com", + "broadcastbed.com", + "broadstreetads.com", + "browser-http-intake.logs.datadoghq.com", + "browser-http-intake.logs.datadoghq.eu", + "bs.yandex.ru", + "btglss.net", + "btrll.com", + "bttrack.com", + "bubblyaction.com", + "buddycanvas.com", + "buysellads.com", + "buzzonclick.com", + "bwp.download.com", + "c.bigmir.net", + "c1exchange.com", + "c212.net", + "cakesdrum.com", + "calculatingcircle.com", + "calculatorstatement.com", + "call-ad-network-api.marchex.com", + "callousbrake.com", + "callrail.com", + "calmcactus.com", + "calypsocapsule.com", + "campaign.bharatmatrimony.com", + "caniamedia.com", + "capriciouscorn.com", + "captainbicycle.com", + "captainstick.com", + "carambo.la", + "carbonads.com", + "carbonads.net", + "cardmethod.com", + "caringcast.com", + "carsarace.com", + "cartstack.com", + "carvecakes.com", + "casalemedia.com", + "casalmedia.com", + "cash4members.com", + "cash4popup.de", + "cashcrate.com", + "cashengines.com", + "cashfiesta.com", + "cashpartner.com", + "cashstaging.me", + "casinopays.com", + "casinorewards.com", + "casinotraffic.com", + "cattlecommittee.com", + "causecherry.com", + "cautiouscredit.com", + "cbanners.virtuagirlhd.com", + "cbzxy.com", + "cdn.bannerflow.com", + "cdn.branch.io", + "cdn.freshmarketer.com", + "cdn.heapanalytics.com", + "cdn.keywee.co", + "cdn.mouseflow.com", + "cdn.onesignal.com", + "cdn.scarabresearch.com", + "cdn.segment.com", + "cdnondemand.org", + "cedato.com", + "celtra.com", + "centerpointmedia.com", + "cetrk.com", + "cgicounter.puretec.de", + "chairscrack.com", + "channelintelligence.com", + "chargecracker.com", + "chart.dk", + "chartbeat.com", + "chartbeat.net", + "chartboost.com", + "checkstat.nl", + "cherriescare.com", + "childlikecrowd.com", + "chubbycreature.com", + "citrusad.net", + "cityads.telus.net", + "cj.com", + "cjbmanagement.com", + "cjlog.com", + "cl.turkishairlines.com", + "cl0udh0st1ng.com", + "clck.ru", + "clevernt.com", + "click-1.pl", + "click.airmalta-mail.com", + "click.aliexpress.com", + "click.allkeyshop.com", + "click.bkdpt.com", + "click.cartsguru.io", + "click.ccg.nintendo.com", + "click.cision.com", + "click.classmates.com", + "click.comm.rcibank.co.uk", + "click.crm.ba.com", + "click.digital.metaquestmail.com", + "click.discord.com", + "click.e.bbcmail.co.uk", + "click.e.progressive.com", + "click.e.zoom.us", + "click.em.blizzard.com", + "click.email.bbc.com", + "click.email.lhh.com", + "click.email.microsoftemail.com", + "click.email.sonos.com", + "click.emails.argos.co.uk", + "click.emails.tuclothing.sainsburys.co.uk", + "click.fool.com", + "click.hookupinyourcity.com", + "click.hooligapps.com", + "click.i.southwesternrailway.com", + "click.infoblox.com", + "click.justwatch.com", + "click.kmindex.ru", + "click.liftoff.io", + "click.mail.hotels.com", + "click.mail.salesforce.com", + "click.mailing.ticketmaster.com", + "click.mkt.grab.com", + "click.news.vans.com", + "click.nl.npr.org", + "click.nvgaming.nvidia.com", + "click.redditmail.com", + "click.uber.com", + "click.v.visionlab.es", + "click2freemoney.com", + "click360v2-ingest.azurewebsites.net", + "click4.pro", + "clickadddilla.com", + "clickadz.com", + "clickagents.com", + "clickbank.com", + "clickbooth.com", + "clickboothlnk.com", + "clickbrokers.com", + "clickcease.com", + "clickcompare.co.uk", + "clickdensity.com", + "clickedyclick.com", + "clickfuse.com", + "clickhereforcellphones.com", + "clickngo.pro", + "clickngo.top", + "clickonometrics.pl", + "clicks.checkatrade.com", + "clicks.deliveroo.co.uk", + "clicks.equantum.com", + "clicks.eventbrite.com", + "clicks.monzo.com", + "clickserve.cc-dt.com", + "clicksinfo.thefork.co.uk", + "clicktag.de", + "clickthruserver.com", + "clickthrutraffic.com", + "clicktrack.pubmatic.com", + "clicktrack.ziyu.net", + "clicktracks.com", + "clicktrade.com", + "clickxchange.com", + "clickyab.com", + "clickz.com", + "clientgear.com", + "clientmetrics-pa.googleapis.com", + "clikerz.net", + "cliksolution.com", + "clixgalore.com", + "clk1005.com", + "clk1011.com", + "clk1015.com", + "clkrev.com", + "clksite.com", + "cloudflareinsights.com", + "cloudguppy.com", + "clrstm.com", + "cluster.adultworld.com", + "clustrmaps.com", + "cmp.dmgmediaprivacy.co.uk", + "cmvrclicks000.com", + "cnomy.com", + "cnt1.pocitadlo.cz", + "cnvlink.com", + "cny.yoyo.org", + "codeadnetwork.com", + "cognitiv.ai", + "cointraffic.io", + "coldbalance.com", + "collector-dev.cdp-dev.cnn.com", + "collector.cdp.cnn.com", + "colonize.com", + "colorfulpet.com", + "coloroptimizer.com", + "comfortablecheese.com", + "commissionmonster.com", + "communications.melitaltd.com", + "compactbanner.com", + "comparereaction.com", + "compilework.com", + "comprabanner.it", + "conditionchange.com", + "conductrics.com", + "confiant-integrations.net", + "confidencetalks.com", + "configv2.unityads.unity3d.com", + "connatix.com", + "connectad.io", + "connextra.com", + "consciouscheese.com", + "consensad.com", + "consensu.org", + "contadores.miarroba.com", + "content.acc-hd.de", + "content.ad", + "content22.online.citi.com", + "contextweb.com", + "contrack.link", + "converge-digital.com", + "conversantmedia.com", + "conversionbet.com", + "conversionruler.com", + "convertingtraffic.com", + "convrse.media", + "cookies.cmpnet.com", + "coolkidsdistrict.com", + "cootlogix.com", + "copperstills.net", + "copycarpenter.com", + "copyrightaccesscontrols.com", + "coremetrics.com", + "cosmosjackson.com", + "count.rin.ru", + "count.west263.com", + "counted.com", + "counter.bloke.com", + "counter.cnw.cz", + "counter.cz", + "counter.dreamhost.com", + "counter.mirohost.net", + "counter.mojgorod.ru", + "counter.rambler.ru", + "counter.search.bg", + "counter.snackly.co", + "counting.kmindex.ru", + "coupling-media.de", + "cowbelltime.com", + "coxmt.com", + "cozyhillside.com", + "cpalead.com", + "cpays.com", + "cpmstar.com", + "cpu.samsungelectronics.com", + "cpx.to", + "cpxinteractive.com", + "cqcounter.com", + "crabbychin.com", + "craktraffic.com", + "crashchance.com", + "crashlytics.com", + "crashlyticsreports-pa.googleapis.com", + "cratecamera.com", + "crawlability.com", + "crazyegg.com", + "crazypopups.com", + "creatives.livejasmin.com", + "creatopy.net", + "crimsonmeadow.com", + "criteo.com", + "criteo.net", + "critictruck.com", + "crowdedmass.com", + "crowdgravity.com", + "crsspxl.com", + "crta.dailymail.co.uk", + "crtv.mate1.com", + "crwdcntrl.net", + "crypto-loot.org", + "crystalboulevard.com", + "cs.co", + "curbminers.com", + "curiousmetal.com", + "curtaincows.com", + "curveshore.com", + "cushiondrum.com", + "customad.cnn.com", + "customads.co", + "customerevents.netflix.com", + "customers.kameleoon.com", + "cutechin.com", + "cxense.com", + "cyberbounty.com", + "cyclinghere.com", + "d-collect.jennifersoft.com", + "d-collector.jennifersoft.com", + "d.adroll.com", + "d1f0tbk1v3e25u.cloudfront.net", + "d2cmedia.ca", + "d81mfvml8p5ml.cloudfront.net", + "dabiaozhi.com", + "dacdn.visualwebsiteoptimizer.com", + "dacdn.vwo.com", + "dakic-ia-300.com", + "damageddistance.com", + "damdoor.com", + "dapper.net", + "data.namesakeoscilloscopemarquis.com", + "datapickles.com", + "datenow.link", + "daybreaklights.com", + "dc-storm.com", + "de17a.com", + "dealdotcom.com", + "decenterads.com", + "decisivebase.com", + "decisivedrawer.com", + "decknetwork.net", + "deepintent.com", + "delicatecascade.com", + "deloo.de", + "demandbase.com", + "demdex.net", + "deployads.com", + "desertgates.com", + "desiredirt.com", + "detailedgovernment.com", + "detectdiscovery.com", + "dev.visualwebsiteoptimizer.com", + "dewdroplagoon.com", + "dianomi.com", + "dicecandies.com", + "didtheyreadit.com", + "digestiondrawer.com", + "digital-ads.s3.amazonaws.com", + "direct-events-collector.spot.im", + "direct-promo.pro", + "directaclick.com", + "directorym.com", + "discountclick.com", + "discreetfield.com", + "displayvertising.com", + "disqusads.com", + "dist.belnk.com", + "distillery.wistia.com", + "districtm.ca", + "districtm.io", + "dmp.mall.tv", + "dmtracker.com", + "dmtracking.alibaba.com", + "dmtracking2.alibaba.com", + "dnsdelegation.io", + "do-global.com", + "dockdigestion.com", + "dogcollarfavourbluff.com", + "domaining.in", + "domdex.com", + "dotmetrics.net", + "dotomi.com", + "doubleclick.com", + "doubleclick.de", + "doubleclick.net", + "doublepimp.com", + "doubleverify.com", + "dpbolvw.net", + "dpu.samsungelectronics.com", + "dq95d35.com", + "dragonbats.com", + "drumcash.com", + "dsp.colpirio.com", + "dsp.io", + "dstillery.com", + "dustyhammer.com", + "dyntrk.com", + "e-eu.customeriomail.com", + "e-m.fr", + "e-planning.net", + "e.customeriomail.com", + "e.kde.cz", + "e37364.dscd.akamaiedge.net", + "eadexchange.com", + "eas.almamedia.fi", + "easyhits4u.com", + "ebayadvertising.com", + "ebuzzing.com", + "echoacloud.com", + "ecircle-ag.com", + "ecleneue.com", + "eclick.vn", + "eclicks.deliveroo.com", + "eclkspbn.com", + "ecoupons.com", + "edaa.eu", + "edgexads.com", + "eighthlayer.net", + "eiv.baidu.com", + "elasticchange.com", + "elderlytown.com", + "elitedollars.com", + "em1.yoursantander.co.uk", + "email-link.adtidy.info", + "email-link.adtidy.net", + "email-link.adtidy.org", + "email-links.crowdfireapp.com", + "email-open.adtidy.net", + "email-open.adtidy.org", + "email-trk.ihg-businessedge.com", + "email.mg1.substack.com", + "emailer.stockbit.com", + "emaillinks.soundiiz.com", + "emebo.io", + "emerse.com", + "emetriq.de", + "emjcd.com", + "eml.blackduck.com", + "emltrk.com", + "emodoinc.com", + "emptyescort.com", + "emxdigital.com", + "energeticladybug.com", + "engage.tines.com", + "engage.windows.com", + "engagebdr.com", + "engageya.com", + "engine.espace.netavenir.com", + "engineertrick.com", + "enginenetwork.com", + "enormousearth.com", + "enquisite.com", + "ensighten.com", + "entercasino.com", + "entrecard.s3.amazonaws.com", + "enviousthread.com", + "epom.com", + "epp.bih.net.ba", + "eqads.com", + "eqy.link", + "erne.co", + "ero-advertising.com", + "essaygiants.com", + "esty.com", + "et.educationdynamics.com", + "et.nytimes.com", + "etahub.com", + "etargetnet.com", + "etracker.com", + "etracker.de", + "eu-adcenter.net", + "eule1.pmu.fr", + "eulerian.net", + "eurekster.com", + "euros4click.de", + "eusta.de", + "evadav.com", + "evadavdsp.pro", + "evencoating.com", + "eventexistence.com", + "events-eu.freshsuccess.com", + "events-us.freshsuccess.com", + "everestads.net", + "everesttech.net", + "evergage.com", + "eversales.space", + "evs.sgmt.loom.com", + "evyy.net", + "exampleshake.com", + "exchange-it.com", + "exchangead.com", + "exchangeclicksonline.com", + "exelate.com", + "exelator.com", + "exhibitsneeze.com", + "exit76.com", + "exitexchange.com", + "exitfuel.com", + "exoclick.com", + "exosrv.com", + "experianmarketingservices.digital", + "explorads.com", + "exponea.com", + "exponential.com", + "exportdialog.com", + "express-submit.de", + "extractobservation.com", + "extreme-dm.com", + "extremetracking.com", + "eyeota.net", + "eyeviewads.com", + "eyewonder.com", + "ezula.com", + "f.pie.org", + "f7ds.liberation.fr", + "fabric.io", + "fadedsnow.com", + "fairfeeling.com", + "fakedisguise.com", + "fallaciousfifth.com", + "fallingshoals.com", + "fam-ad.com", + "farethief.com", + "farmergoldfish.com", + "fast-redirecting.com", + "fastclick.com", + "fastclick.com.edgesuite.net", + "fastclick.net", + "fastly-insights.com", + "faultycanvas.com", + "fave.co", + "fc.webmasterpro.de", + "feedbackresearch.com", + "feedjit.com", + "feedmob.com", + "femalecook.com", + "figsprotein.com", + "figurehunter.net", + "fillthemap.com", + "fimserve.com", + "findcommerce.com", + "findyourcasino.com", + "fireads.online", + "fireads.org", + "fireworkadservices.com", + "fireworkanalytics.com", + "fireworks-advertising.com", + "firstlightera.com", + "fishingtoolsbox.com", + "fixedfold.com", + "fjordsand.com", + "flairadscpc.com", + "flakyfeast.com", + "flashtalking.com", + "flashtexting.com", + "fleshlightcash.com", + "flexbanner.com", + "flimsycircle.com", + "flimsythought.com", + "floodprincipal.com", + "flourishinginnovation.com", + "floweryflavor.com", + "flowgo.com", + "flurry.com", + "fontserif.com", + "foo.cosmocode.de", + "foresee.com", + "forex-affiliate.net", + "forkcdn.com", + "fourlevelsgame.com", + "fpctraffic.com", + "fpjs.io", + "fqtag.com", + "fraysystems.com", + "free-counter.co.uk", + "freebanner.com", + "freecounterstat.com", + "freedomgrail.com", + "freelogs.com", + "freepay.com", + "freestats.com", + "freestats.tv", + "freewebcounter.com", + "freewheel.com", + "freewheel.tv", + "freezingbuilding.com", + "frequentflesh.com", + "freshrelevance.com", + "fronttoad.com", + "frtyj.com", + "frtyk.com", + "fudgegenie.com", + "fullrestore.net", + "fullstory.com", + "functionalcrown.com", + "functionalfeather.com", + "funklicks.com", + "funnelytics.io", + "funtoyplanet.com", + "furryfork.com", + "fusionads.net", + "fusionquest.com", + "futuristicfifth.com", + "futuristicframe.com", + "fuzzybasketball.com", + "fwcdn1.com", + "fwcdn2.com", + "fxstyle.net", + "g2.gumgum.com", + "ga.clearbit.com", + "gadsbee.com", + "galaxien.com", + "game-advertising-online.com", + "gamesites100.net", + "gamesites200.com", + "gammamaximum.com", + "gaug.es", + "gavvia.com", + "gearwom.de", + "geo.digitalpoint.com", + "geobanner.adultfriendfinder.com", + "georiot.com", + "geovisite.com", + "getclicky.com", + "getintent.com", + "getmyads.com", + "getxmlisi.com", + "giddycoat.com", + "glasscoyote.com", + "glisteningsign.com", + "globalismedia.com", + "gloriousbeef.com", + "gmads.net", + "gml.email", + "go-clicks.de", + "go-link.network", + "go-mpulse.net", + "go-rank.de", + "go.clickwww.com", + "go.dhs.gov", + "go.eu.sparkpostmail1.com", + "go.first.org", + "go.icann.org", + "go.scmagazine.com", + "go.usa.gov", + "go.xlirdr.com", + "go2affise.com", + "godseedband.com", + "goingplatinum.com", + "goldstats.com", + "gondolagnome.com", + "goodcontentservice.top", + "google-analytics.com", + "googleadservices.com", + "googleanalytics.com", + "googlesyndication.com", + "googletagmanager.com", + "googletagservices.com", + "goqon.com", + "gostats.com", + "gothamads.com", + "gotoyahoo.com", + "gotraffic.net", + "gp.dejanews.com", + "grandfatherguitar.com", + "granlite.com", + "grapeshot.co.uk", + "greystripe.com", + "groovespacing.com", + "grouchybrothers.com", + "groundtruth.com", + "growthrx.in", + "gscontxt.net", + "guardeddirection.com", + "guardedschool.com", + "gumyfui.com", + "gunggo.com", + "h-bid.com", + "h-trck.com", + "h0.t.hubspotemail.net", + "haikusoap.com", + "halcyoncanyon.com", + "halocolor.com", + "haltingbadge.com", + "hammerhearing.com", + "hamsterspot.com", + "handsomehose.com", + "handyfireman.com", + "handyincrease.com", + "haplesshydrant.com", + "harrenmedia.com", + "harrenmedianetwork.com", + "hayweb.net", + "hb.afl.rakuten.co.jp", + "hb.vntsm.com", + "hbb.afl.rakuten.co.jp", + "hbopenbid.pubmatic.com", + "heap.com", + "heimi-lwx.com", + "hellobar.com", + "helpcollar.com", + "hentaicounter.com", + "herbalaffiliateprogram.com", + "hexcan.com", + "hexusads.fluent.ltd.uk", + "heyos.com", + "hf5rbejvpwds.com", + "hfc195b.com", + "hgads.com", + "highnoongear.com", + "hightrafficads.com", + "hilariouszinc.com", + "histats.com", + "historytrade.com", + "hit-parade.com", + "hit.ua", + "hit.webcentre.lycos.co.uk", + "hitbox.com", + "hitcounters.miarroba.com", + "hitlist.ru", + "hitlounge.com", + "hitometer.com", + "hits-i.iubenda.com", + "hits.europuls.eu", + "hits.informer.com", + "hits.puls.lv", + "hits.sh", + "hits.theguardian.com", + "hits4me.com", + "hitslink.com", + "hittail.com", + "hlok.qertewrt.com", + "hocgeese.com", + "hollowafterthought.com", + "homelycrown.com", + "homepageking.de", + "honorableland.com", + "hostedads.realitykings.com", + "hotjar.com", + "hotlog.ru", + "hotrank.com.tw", + "hoverowl.com", + "hs-analytics.net", + "hs-banner.com", + "hsadspixel.net", + "hsleadflows.net", + "hsn.uqhv.net", + "htlbid.com", + "httpool.com", + "hubspotlinks.com", + "hueads.com", + "hueadsortb.com", + "hueadsxml.com", + "hurricanedigitalmedia.com", + "hustlercoach.com", + "hydraconcept.com", + "hydramedia.com", + "hyperbanner.net", + "hypertracker.com", + "hyprmx.com", + "hystericalcloth.com", + "i-i.lt", + "i305175.net", + "ia.iinfo.cz", + "iad.anm.co.uk", + "iadnet.com", + "ibillboard.com", + "icptrack.com", + "icywinter.com", + "id5-sync.com", + "idealadvertising.net", + "idevaffiliate.com", + "idtargeting.com", + "ientrymail.com", + "iesnare.com", + "ifa.tube8live.com", + "ilbanner.com", + "ilead.itrack.it", + "illustriousoatmeal.com", + "image2.pubmatic.com", + "image3.pubmatic.com", + "image4.pubmatic.com", + "image6.pubmatic.com", + "imagecash.net", + "images-pw.secureserver.net", + "img.prohardver.hu", + "imgpromo.easyrencontre.com", + "immensehoney.com", + "imonomy.com", + "imp.i312864.net", + "impossibleexpansion.com", + "imprese.cz", + "impressionmedia.cz", + "impressionmonster.com", + "improvedigital.com", + "imrworldwide.com", + "inclk.com", + "incognitosearches.com", + "incoming-telemetry.thunderbird.net", + "incoming.telemetry.mozilla.org", + "indexexchange.com", + "indexstats.com", + "indexww.com", + "indieclick.com", + "industrybrains.com", + "inetlog.ru", + "infinite-ads.com", + "infinityads.com", + "infoevent.startappservice.com", + "infolinks.com", + "inmobi.com", + "inner-active.com", + "inner-active.mobi", + "innovid.com", + "inquisitiveinvention.com", + "insgly.net", + "insidepsych.net", + "insightexpress.com", + "insightexpressai.com", + "inskinad.com", + "inspectlet.com", + "install.365-stream.com", + "instantmadness.com", + "insticator.com", + "intelliads.com", + "intelligenceadx.com", + "interactive.forthnet.gr", + "intercom-clicks.com", + "intergi.com", + "internalcondition.com", + "internetfuel.com", + "interreklame.de", + "ioam.de", + "ip.ro", + "ip193.cn", + "iperceptions.com", + "ipredictive.com", + "ipstack.com", + "irchan.com", + "ireklama.cz", + "is-tracking-pixel-api-prod.appspot.com", + "islandwebhelp.com", + "itop.cz", + "its-that-easy.com", + "ivwbox.de", + "ivykiosk.com", + "iyfbodn.com", + "iyfnzgb.com", + "j93557g.com", + "jads.co", + "jamexport.com", + "jcount.com", + "jdoqocy.com", + "jewelrysprings.com", + "jinkads.de", + "joetec.net", + "joyoussurprise.com", + "js-agent.newrelic.com", + "js-api.otherlevels.com", + "js-tags.otherlevels.com", + "js.iterable.com", + "js.users.51.la", + "jsecoin.com", + "jsrdn.com", + "jubilantglimmer.com", + "juiceblocks.com", + "juicyads.com", + "juicyads.me", + "jumptap.com", + "jungroup.com", + "justicejudo.com", + "justpremium.com", + "justrelevant.com", + "justwowjars.com", + "k.iinfo.cz", + "kameleoon.eu", + "kanoodle.com", + "kargo.com", + "kernellife.com", + "kickoffo.site", + "kindads.com", + "kindlereunion.com", + "kissmetrics.com", + "kittentacos.com", + "kittycatking.com", + "klclick.com", + "klclick1.com", + "kliks.nl", + "knitstamp.com", + "knorex.com", + "knottyswing.com", + "komoona.com", + "kompasads.com", + "kontera.com", + "kost.tv", + "kpu.samsungelectronics.com", + "krxd.net", + "ktu.sv2.biz", + "kueezrtb.com", + "kvsadman.com", + "l1.britannica.com", + "lakesecure.com", + "lameletters.com", + "landkarts.com", + "larati.net", + "largebrass.com", + "laughcloth.com", + "launchbit.com", + "layer-ad.de", + "layer-ads.de", + "lazybumblebee.com", + "lbn.ru", + "lead02.com", + "leadboltads.net", + "leadclick.com", + "leadinfo.net", + "leadingedgecash.com", + "leadplace.fr", + "leadspace.com", + "leadzupc.com", + "leaplunchroom.com", + "leftliquid.com", + "lemmatechnologies.com", + "lemnisk.co", + "lever-analytics.com", + "lfeeder.com", + "lfstmedia.com", + "lgsmartad.com", + "li.alibris.com", + "li.azstarnet.com", + "li.dailycaller.com", + "li.gatehousemedia.com", + "li.gq.com", + "li.hearstmags.com", + "li.livingsocial.com", + "li.mw.drhinternet.net", + "li.onetravel.com", + "li.patheos.com", + "li.pmc.com", + "li.realtor.com", + "li.ziffimages.com", + "liadm.com", + "libraryfacts.com", + "lifeimpressions.net", + "liftdna.com", + "ligatus.com", + "ligatus.de", + "lightspeedcash.com", + "lightstep.medium.systems", + "lijit.com", + "limecodesign.com", + "link-booster.de", + "link.axios.com", + "link.beelivery.com", + "link.email.davidlloydclubs.co.uk", + "link.email.usmagazine.com", + "link.go.chase", + "link.sbstck.com", + "link.team.hyperoptic.com", + "link.theatlantic.com", + "link.uk.expediamail.com", + "linkbuddies.com", + "linkexchange.com", + "linkprice.com", + "linkrain.com", + "linkreferral.com", + "links-ranking.de", + "links.email.crunchbase.com", + "links.housekeep.com", + "links.prosservice.fr", + "links.zoopla.co.uk", + "linksoutside.com", + "linkstable.com", + "linkstorms.com", + "linkswaper.com", + "linksynergy.com", + "linktarget.com", + "linkvertise.com", + "liquidad.narrowcastmedia.com", + "litix.io", + "live.trmzum.com", + "liveadexchanger.com", + "liveintent.com", + "livelylaugh.com", + "liverail.com", + "livingsleet.com", + "lizardslaugh.com", + "lkqd.com", + "lnks.gd", + "loading321.com", + "loadsurprise.com", + "locked4.com", + "lockerdome.com", + "locolava.com", + "log.btopenworld.com", + "log.logrocket.io", + "log.pinterest.com", + "log.videocampaign.co", + "logger.snackly.co", + "logs.roku.com", + "logs.spilgames.com", + "logsss.com", + "logua.com", + "look.djfiln.com", + "look.ichlnk.com", + "look.opskln.com", + "look.ufinkln.com", + "loopme.com", + "loudlunch.com", + "lowest-prices.eu", + "lucidmedia.com", + "luckyorange.com", + "ludicrousarch.com", + "lyricshook.com", + "lytics.io", + "lzjl.com", + "m.trb.com", + "m2.ai", + "m32.media", + "m4n.nl", + "m6r.eu", + "mackeeperapp.mackeeper.com", + "madclient.uimserv.net", + "madcpms.com", + "madinad.com", + "madisonavenue.com", + "madvertise.de", + "magicadz.co", + "magicaljoin.com", + "magsrv.com", + "mail-ads.google.com", + "maltiverse.lt.acemlnc.com", + "manageadv.cblogs.eu", + "mantisadnetwork.com", + "mapcommand.com", + "marinsm.com", + "markedmeasure.com", + "marketing.888.com", + "marketing.desertcart.com", + "marketing.net.brillen.de", + "marketing.net.home24.de", + "marketing.net.occhiali24.it", + "marketing.nyi.net", + "marketing.osijek031.com", + "marketingcloudapis.com", + "marketingsolutions.yahoo.com", + "marketo.com", + "marlowpillow.sjv.io", + "marriedbelief.com", + "mas.sector.sk", + "matchcraft.com", + "matheranalytics.com", + "mathtag.com", + "matomo.activate.cz", + "matomo.crossiety.app", + "mautic.com", + "max.i12.de", + "maximiser.net", + "maxonclick.com", + "mbs.megaroticlive.com", + "mcdlks.com", + "mcs-va.tiktok.com", + "mcs-va.tiktokv.com", + "meadowlullaby.com", + "measlymiddle.com", + "measure.office.com", + "measuremap.com", + "meatydime.com", + "media-adrunner.mycomputer.com", + "media.funpic.de", + "media.net", + "media01.eu", + "media6degrees.com", + "mediaarea.eu", + "mediabridge.cc", + "mediafuse.com", + "mediageneral.com", + "mediaiqdigital.com", + "mediamath.com", + "mediamgr.ugo.com", + "mediaplazza.com", + "mediaplex.com", + "mediascale.de", + "mediaserver.bwinpartypartners.it", + "mediasmart.io", + "mediasquare.fr", + "mediatext.com", + "mediavine.com", + "mediavoice.com", + "mediax.angloinfo.com", + "mediaz.angloinfo.com", + "medleyads.com", + "medyanetads.com", + "meetrics.net", + "megacash.de", + "megastats.com", + "megawerbung.de", + "meltmilk.com", + "memorizeneck.com", + "mercuryace.com", + "merequartz.com", + "messagelists.com", + "metadsp.co.uk", + "metaffiliation.com", + "metajaws.com", + "metanetwork.com", + "methodcash.com", + "metrics-logger.spot.im", + "metrics.api.drift.com", + "metrics.articulate.com", + "metrics.cnn.com", + "metrics.foxnews.com", + "metrics.getrockerbox.com", + "metrics.gfycat.com", + "metrics.govexec.com", + "metrics.icloud.com", + "metrics.mzstatic.com", + "metrilo.com", + "mfadsrvr.com", + "mg2connext.com", + "mgid.com", + "microstatic.pl", + "microticker.com", + "milotree.com", + "mineinvoice.com", + "minewhat.com", + "minibilling.com", + "mintegral.com", + "mintfunnel.co", + "mittencattle.com", + "mix2ads.com", + "mixedreading.com", + "mixpanel.com", + "mkto-ab410147.com", + "mktoresp.com", + "ml314.com", + "mlm.de", + "mlsend.com", + "mltrk.io", + "mmismm.com", + "mmstat.com", + "mmtro.com", + "mntzrlt.net", + "moartraffic.com", + "moat.com", + "moatads.com", + "moatpixel.com", + "mobclix.com", + "mobfox.com", + "mobileanalytics.us-east-1.amazonaws.com", + "mobilefuse.com", + "modernpricing.com", + "mon-va.byteoversea.com", + "mon.byteoversea.com", + "monarchads.com", + "monetate.net", + "monetizer101.com", + "monkeyapes.com", + "monsterpops.com", + "mookie1.com", + "mopub.com", + "morefolks.com", + "motionlessmeeting.com", + "motionspots.com", + "mousestats.com", + "movad.net", + "movemeal.com", + "mparticle.com", + "mpstat.us", + "mr-rank.de", + "mrskincash.com", + "mstrlytcs.com", + "mtrcs.samba.tv", + "mtree.com", + "munchkin.marketo.net", + "mundanenail.com", + "mushroomgods.com", + "musiccounter.ru", + "muteknife.com", + "muwmedia.com", + "mxptint.net", + "myads.company", + "myads.net", + "myads.telkomsel.com", + "myaffiliateprogram.com", + "mybbc-analytics.files.bbci.co.uk", + "mybloglog.com", + "mybuys.com", + "mycounter.ua", + "mydas.mobi", + "mylead-tracking.tracknow.info", + "mylead.global", + "mylink-today.com", + "mypagerank.net", + "mypowermall.com", + "mysketchpad.com", + "mystat-in.net", + "mystat.pl", + "mytop-in.net", + "n69.com", + "naj.sk", + "nakedly.ai", + "nappyattack.com", + "nappyneck.com", + "nastydollars.com", + "nativeroll.tv", + "navegg.com", + "navigator.io", + "navrcholu.cz", + "ncaudienceexchange.com", + "ndparking.com", + "nebulacrescent.com", + "nedstatbasic.net", + "needyneedle.com", + "neighborlywatch.com", + "nend.net", + "neocounter.neoworx-blog-tools.net", + "nervoussummer.com", + "net-filter.com", + "netaffiliation.com", + "netagent.cz", + "netclickstats.com", + "netdirect.nl", + "netech.postaffiliatepro.com", + "netmera-web.com", + "netmera.com", + "netmng.com", + "netpool.netbookia.net", + "netshelter.net", + "neudesicmediagroup.com", + "newads.bangbros.com", + "newnet.qsrch.com", + "newnudecash.com", + "news-cdn.site", + "newsadsppush.com", + "newsbotnet.com", + "newt1.adultadworld.com", + "newt1.adultworld.com", + "nexac.com", + "nexage.com", + "ng3.ads.warnerbros.com", + "nitroclicks.com", + "nmtracking.netflix.com", + "noiselessplough.com", + "nondescriptcrowd.com", + "nondescriptnote.com", + "nondescriptstocking.com", + "novem.pl", + "nowaymail.com", + "npttech.com", + "nr-data.net", + "nr.mmcdn.com", + "nr.static.mmcdn.com", + "ns1p.net", + "ntv.io", + "ntvk1.ru", + "nullitics.com", + "nuseek.com", + "nzaza.com", + "o2.mouseflow.com", + "o333o.com", + "oafishobservation.com", + "oas.benchmark.fr", + "oas.repubblica.it", + "oas.roanoke.com", + "oas.toronto.com", + "oas.uniontrib.com", + "oascentral.chicagobusiness.com", + "oascentral.fortunecity.com", + "oascentral.register.com", + "objecthero.com", + "obscenesidewalk.com", + "oceancloudhosts.com", + "oclasrv.com", + "odbierz-bony.ovp.pl", + "oewa.at", + "offaces-butional.com", + "offer.fyber.com", + "offer.sponsorpay.com", + "offerforge.com", + "offermatica.com", + "offshoregeology.com", + "ogads-pa.googleapis.com", + "oglasi.posjetnica.com", + "ogury.com", + "ojrq.net", + "omg10.com", + "omnijay.com", + "omniture.com", + "omtrdc.net", + "onaudience.com", + "onclickads.net", + "onegg.site", + "onestat.com", + "onestatfree.com", + "online-metrix.net", + "online.miarroba.com", + "onlinecash.com", + "onlinecashmethod.com", + "onlinerewardcenter.com", + "onlinestarten.net", + "onscroll.com", + "onthe.io", + "opads.us", + "open.oneplus.net", + "openad.tf1.fr", + "openad.travelnow.com", + "openads.friendfinder.com", + "openads.org", + "openadsnetwork.com", + "openbid.pubmatic.com", + "openx.angelsgroup.org.uk", + "openx.cairo360.com", + "openx.net", + "openx.skinet.cz", + "openx.smcaen.fr", + "openx2.kytary.cz", + "operationchicken.com", + "opienetwork.com", + "opmnstr.com", + "oppuz.com", + "optimallimit.com", + "optimizely.com", + "optimost.com", + "optionsnomad.com", + "optmd.com", + "optmnstr.com", + "optmstr.com", + "optnmstr.com", + "optnx.com", + "orbsrv.com", + "orientedargument.com", + "orionember.com", + "ota.cartrawler.com", + "otto-images.developershed.com", + "ourdreamstaticpages.pages.dev", + "outbrain.com", + "overconfidentfood.com", + "overkick.com", + "overture.com", + "ow.pubmatic.com", + "owebmoney.ru", + "owlsr.us", + "owneriq.net", + "oxado.com", + "oxcash.com", + "oxen.hillcountrytexas.com", + "p-n.io", + "p7cloud.net", + "paa-reporting-advertising.amazon", + "page-checker.eu", + "pagead.l.google.com", + "pagefair.com", + "pagerank-ranking.de", + "pageranktop.com", + "painstakingpickle.com", + "paleleaf.com", + "panickypancake.com", + "panoramicplane.com", + "parachutehome.sjv.io", + "parchedsofa.com", + "parentpicture.com", + "parsely.com", + "parsimoniouspolice.com", + "partner-ads.com", + "partner.pelikan.cz", + "partnerad.l.google.com", + "partnerads.ysm.yahoo.com", + "partnercash.de", + "partnerhut.com", + "partnerlinks.io", + "partners.priceline.com", + "partplanes.com", + "passeura.com", + "paychat.fuse-cloud.com", + "paycounter.com", + "paypopup.com", + "pbnet.ru", + "pbterra.com", + "pc-tc.s3-eu-west-1.amazonaws.com", + "pcash.imlive.com", + "peep-auktion.de", + "peer39.com", + "pennyweb.com", + "pepperjamnetwork.com", + "percentmobile.com", + "perfectaudience.com", + "perfiliate.com", + "performancerevenue.com", + "performancerevenues.com", + "performancing.com", + "permutive.com", + "personagraph.com", + "pgl.example.com", + "pgl.example0101", + "pgmediaserve.com", + "pgpartner.com", + "pheedo.com", + "phoenix-adrunner.mycomputer.com", + "piano.io", + "pimproll.com", + "ping.ublock.org", + "pipedream.wistia.com", + "pippio.com", + "piquantpigs.com", + "pix.spot.im", + "pixel.condenastdigital.com", + "pixel.keywee.co", + "pixel.sojern.com", + "pixel.watch", + "pixel.yabidos.com", + "placed.com", + "placeframe.com", + "placidactivity.com", + "plausible.avris.it", + "plausibleio.workers.dev", + "play4traffic.com", + "playhaven.com", + "pleasantpump.com", + "plista.com", + "plotrabbit.com", + "pltraffic8.com", + "pluckypocket.com", + "plugrush.com", + "pocketfaucet.com", + "poemprompt.com", + "pointlessprofit.com", + "pointroll.com", + "pokkt.com", + "polishedfolly.com", + "polo.feathr.co", + "popads.net", + "popcash.net", + "popmixradio.com", + "popmyads.com", + "popplantation.com", + "popub.com", + "popunder.ru", + "popunhot1.blogspot.com", + "popup.msn.com", + "popupmoney.com", + "popupnation.com", + "popuptraffic.com", + "porngraph.com", + "porntrack.com", + "possibleboats.com", + "possiblepencil.com", + "post.spmailtechno.com", + "postback.iqm.com", + "postrelease.com", + "ppc.adhere.marchex.com", + "pr-star.de", + "praddpro.de", + "prchecker.info", + "prebid.org", + "predictad.com", + "premium-offers.com", + "presetrabbits.com", + "prettyeasycafe.com", + "previousplayground.com", + "prf.hn", + "priceypies.com", + "pricklydebt.com", + "prideproms.com", + "primetime.net", + "privatecash.com", + "prmtracking.com", + "pro-market.net", + "probablepartner.com", + "processplantation.com", + "proext.com", + "profero.com", + "profitrumour.com", + "programattik.com", + "projectwonderful.com", + "promo.badoink.com", + "promobenef.com", + "promos.bwin.it", + "promos.fling.com", + "promote.pair.com", + "pronetadvertising.com", + "propellerads.com", + "propellerclick.com", + "proper.io", + "props.id", + "protectcrev.com", + "protectpool.com", + "protectsubrev.com", + "protestcopy.com", + "proton-tm.com", + "protraffic.com", + "provenpixel.com", + "prpops.com", + "prsitecheck.com", + "prufenzo.xyz", + "pstmrk.it", + "pub.chez.com", + "pub.club-internet.fr", + "pub.hardware.fr", + "pub.network", + "pub.realmedia.fr", + "pubdirecte.com", + "publicidad.elmundo.es", + "publicidees.com", + "publicsofa.com", + "pubmine.com", + "pubnative.net", + "puffyloss.com", + "puffypaste.com", + "puffypull.com", + "puffypurpose.com", + "pureclarity.net", + "pushance.com", + "pushengage.com", + "pushno.com", + "pushtrack.co", + "px.dynamicyield.com", + "px.gfycat.com", + "pxf.io", + "pxl-mailtracker.com", + "pxl.iqm.com", + "pymx5.com", + "q.azcentral.com", + "q1connect.com", + "qa-analytics.com", + "qctop.com", + "ql.tc", + "qnsr.com", + "qrlsx.com", + "quantcast.com", + "quantcount.com", + "quantserve.com", + "quantummetric.com", + "quarterserver.de", + "quickkoala.io", + "quietknowledge.com", + "quiltruler.com", + "quinst.com", + "quirkysugar.com", + "quisma.com", + "quizzicalzephyr.com", + "r.drinksdirect.net", + "r.logrocket.io", + "r.marketing.dubaisothebys.com", + "r.msn.com", + "r.scoota.co", + "r.sibmail.havasit.com", + "r1.arts-mail.com", + "r1.visualwebsiteoptimizer.com", + "r2.visualwebsiteoptimizer.com", + "r3.visualwebsiteoptimizer.com", + "rabbitrifle.com", + "rackforstorage.com", + "radar.cedexis.com", + "radiate.com", + "radiateprose.com", + "rads.realadmin.pl", + "railwayreason.com", + "rambunctiousflock.com", + "rampidads.com", + "randkuj.xyz", + "rankchamp.de", + "ranking-charts.de", + "ranking-hits.de", + "ranking-links.de", + "rankingscout.com", + "rankyou.com", + "rapidcounter.com", + "raresummer.com", + "rate.ru", + "ratings.lycos.com", + "rayjump.com", + "rcadserver.com", + "re-direct.pl", + "reachjunction.com", + "reactx.com", + "readingguilt.com", + "readymoon.com", + "realcastmedia.com", + "realclever.com", + "realclix.com", + "realmedia-a800.d4p.net", + "realsrv.com", + "realtechnetwork.com", + "realtracker.com", + "rebelhen.com", + "rebelswing.com", + "rec5.visualwebsiteoptimizer.com", + "recapture.io", + "receptivereaction.com", + "recoco.it", + "reconditerake.com", + "record.bonniergaming.com", + "record.mrwin.com", + "redirectingat.com", + "redirectvoluum.com", + "redrection.pro", + "redshell.io", + "reduxmedia.com", + "referralware.com", + "referrer.disqus.com", + "regularplants.com", + "reklam.rfsl.se", + "reklama.mironet.cz", + "reklamcsere.hu", + "reklamdsp.com", + "relmaxtop.com", + "rememberdiscussion.com", + "remox.com", + "report-1.appmetrica.webvisor.com", + "report-2.appmetrica.webvisor.com", + "report-partners.appmetrica.yandex.net", + "report.ap.yandex-net.ru", + "report.appmetrica.yandex.net", + "republika.onet.pl", + "resalag.com", + "rescuerhino.com", + "resonantbrush.com", + "resonate.com", + "responsiveads.com", + "restrainstorm.com", + "retargeter.com", + "rev.iq", + "revcatch.com", + "revcontent.com", + "reveal.clearbit.com", + "revenuedirect.com", + "revenuehits.com", + "revive.haskovo.net", + "revive.netriota.hu", + "revive.plays.bg", + "revprotect.com", + "revstats.com", + "rexadvert.xyz", + "reyden-x.com", + "rhombusads.com", + "rhythmone.com", + "richaudience.com", + "richmails.com", + "richstring.com", + "rightstats.com", + "ringplant.com", + "ringsrecord.com", + "ritzyrepresentative.com", + "rlcdn.com", + "rle.ru", + "rmads.msn.com", + "rmedia.boston.com", + "roaddynamics.com", + "roar.com", + "robotreplay.com", + "rockabox.co", + "rockagainst.com", + "rockstarwriter.com", + "rok.com.com", + "rollconnection.com", + "rose.ixbt.com", + "rotabanner.com", + "roxr.net", + "rpt-ads.vidaahub.com", + "rqtrk.eu", + "rs6.net", + "rsc-ads.vidaahub.com", + "rsc-mntz.vidaahub.com", + "rta.dailymail.co.uk", + "rtb.gumgum.com", + "rtbadzesto.com", + "rtbflairads.com", + "rtbplatform.net", + "rtbpop.com", + "rtbpopd.com", + "rtmark.net", + "rtxplatform.com", + "ru4.com", + "rubiconproject.com", + "rum-http-intake.logs.datadoghq.com", + "rum-http-intake.logs.datadoghq.eu", + "runads.com", + "rundsp.com", + "ruralrobin.com", + "s.adroll.com", + "s.dmmew.com", + "s20dh7e9dh.com", + "s2d6.com", + "sabio.us", + "sadloaf.com", + "safeoffers.pro", + "sail-horizon.com", + "samplesamba.com", + "samsungacr.com", + "samsungads.com", + "sanalytics.disneyplus.com", + "sanity-dataplane.rudderstack.com", + "savoryorange.com", + "sbird.xyz", + "sbx.pagesjaunes.fr", + "sc-analytics.appspot.com", + "scambiobanner.aruba.it", + "scanscout.com", + "scarcesign.com", + "scaredsnakes.com", + "scaredsong.com", + "scarfsmash.com", + "scatteredheat.com", + "scintillatingscissors.com", + "scintillatingsilver.com", + "scissorsstatement.com", + "scopelight.com", + "scorecardresearch.com", + "scratch2cash.com", + "screechingfurniture.com", + "screechingstocking.com", + "screechingstove.com", + "scrubswim.com", + "seadform.net", + "searchmarketing.com", + "searchramp.com", + "secre.jp", + "secretspiders.com", + "secure.webconnect.net", + "securedopen-bp.com", + "securemetrics.apple.com", + "securemetrics.apple.com.cn", + "sedoparking.com", + "sedotracker.com", + "segment-cdn.producthunt.com", + "selectivesummer.com", + "semasio.net", + "sendmepixel.com", + "seraphichorizon.com", + "serendipityecho.com", + "serv0.com", + "servclick1move.com", + "serve.tercept.com", + "servedby-buysellads.com", + "servedbyadbutler.com", + "servedbyopenx.com", + "servethis.com", + "services.hearstmags.com", + "sessioncam.com", + "sexcounter.com", + "sexlist.com", + "sextracker.com", + "shadowmade.com", + "shakegoldfish.com", + "shareasale.com", + "sharethrough.com", + "shd247.click", + "sher.index.hu", + "shesubscriptions.com", + "shinystat.com", + "shinystat.it", + "shiveringspot.com", + "shiverscissors.com", + "shockinggrass.com", + "shoppingads.com", + "showads.pubmatic.com", + "shredform.com", + "shrillspoon.com", + "shxtrk.com", + "sicksmash.com", + "sidebar.angelfire.com", + "signalayer.com", + "signalszone.com", + "sillyscrew.com", + "silvermob.com", + "simpleanalytics.io", + "simplesafari.com", + "simpli.fi", + "simulateswing.com", + "sincerebuffalo.com", + "sinoa.com", + "sitedataprocessing.com", + "siteimproveanalytics.com", + "siteimproveanalytics.io", + "siteintercept.qualtrics.com", + "sitemeter.com", + "sixscissors.com", + "sixsigmatraffic.com", + "sizmek.com", + "skimresources.com", + "skisofa.com", + "skroutza.skroutz.gr", + "skylink.vn", + "slimesupplies.net", + "slopeaota.com", + "smaato.com", + "smart-data-systems.com", + "smart-traffik.com", + "smart-traffik.io", + "smart4ads.com", + "smartadserver.com", + "smartclip.net", + "smartlook.com", + "smartstream.tv", + "smartyads.com", + "smashquartz.com", + "smashsurprise.com", + "smetrics.10daily.com.au", + "smetrics.bestbuy.com", + "smetrics.ctv.ca", + "smetrics.fedex.com", + "smetrics.foxnews.com", + "smetrics.walgreens.com", + "smetrics.washingtonpost.com", + "smilewanted.com", + "smilingcattle.com", + "smoggysnakes.com", + "smrtb.com", + "snapads.com", + "snazzypoodle.com", + "snoobi.com", + "socialspark.com", + "softclick.com.br", + "soggysponge.com", + "soicos.com", + "sombersea.com", + "sombersquirrel.com", + "sombersurprise.com", + "somniture.stuff.co.nz", + "somoaudience.com", + "sonobi.com", + "sortable.com", + "sourcepoint.vice.com", + "sovrn.com", + "sp-a-q-f.ib-game.jp", + "spacash.com", + "spaceleadster.com", + "sparklingshelf.com", + "sparkstudios.com", + "speakol.com", + "specificmedia.co.uk", + "specificpop.com", + "speedomizer.com", + "speedshiftmedia.com", + "spellingthoughts.com", + "spezialreporte.de", + "spiffymachine.com", + "spinbox.techtracker.com", + "spinbox.versiontracker.com", + "spinnaker-js.com", + "spirebaboon.com", + "sponsorads.de", + "sponsorpro.de", + "spookysleet.com", + "spotlessstamp.com", + "spotscenered.info", + "spotx.tv", + "spotxchange.com", + "springbot.com", + "springserve.com", + "sprysummit.com", + "spulse.net", + "spylog.com", + "spywarelabs.com", + "spywords.com", + "srvmath.com", + "srvtrck.com", + "srwww1.com", + "sshowads.pubmatic.com", + "sskzlabs.com", + "st.dynamicyield.com", + "st.pubmatic.com", + "stack-sonar.com", + "stackadapt.com", + "stakingsmile.com", + "stalesummer.com", + "starffa.com", + "starkscale.com", + "starrynets.com", + "starsmarter.com", + "startapp.com", + "stat-track.com", + "stat.cliche.se", + "stat.dyna.ultraweb.hu", + "stat.pl", + "stat.webmedia.pl", + "stat.xiaomi.com", + "stat.zenon.net", + "stat24.com", + "stat24.meta.ua", + "statcounter.com", + "statdynamic.com", + "static-tracking.klaviyo.com", + "static.fmpub.net", + "static.itrack.it", + "static.kameleoon.com", + "staticads.btopenworld.com", + "statistik-gallup.net", + "statm.the-adult-company.com", + "stats.blogger.com", + "stats.hyperinzerce.cz", + "stats.merriam-webster.com", + "stats.mirrorfootball.co.uk", + "stats.nextgen-email.com", + "stats.olark.com", + "stats.pusher.com", + "stats.rdphv.net", + "stats.self.com", + "stats.stb-ottow.de", + "stats.townnews.com", + "stats.wordpress.com", + "stats.wp.com", + "stats2.self.com", + "stats4all.com", + "statserv.net", + "statsie.com", + "statxpress.com", + "steadfastsound.com", + "steadfastsystem.com", + "steelhouse.com", + "steelhousemedia.com", + "stickyadstv.com", + "stiffgame.com", + "stimulatingsneeze.com", + "stomachscience.com", + "stopstomach.com", + "storetail.io", + "storygize.net", + "strack.pubmatic.com", + "straightnest.com", + "stretchsquirrel.com", + "studycooking.com", + "stupendoussleet.com", + "stupendoussnow.com", + "subscribe.hearstmags.com", + "succeedscene.com", + "successbuffet.com", + "sugoicounter.com", + "sulkycook.com", + "summerobject.com", + "sumo.com", + "sumome.com", + "sunsetcampfires.com", + "superawesome.tv", + "superchichair.com", + "superclix.de", + "superficialsquare.com", + "supersonicads.com", + "superstats.com", + "supertop.ru", + "supertop100.com", + "supply.colossusssp.com", + "supportwaves.com", + "surfmusik-adserver.de", + "surveygizmobeacon.s3.amazonaws.com", + "sw88.espn.com", + "swan-swan-goose.com", + "swankysquare.com", + "swingslip.com", + "swordgoose.com", + "synonymoussticks.com", + "systemssummit.com", + "t.appsflyer.com", + "t.bawafx.com", + "t.carta.com", + "t.co", + "t.eloqua.com", + "t.email.superdrug.com", + "t.en25.com", + "t.firstpromoter.com", + "t.insigit.com", + "t.irtyd.com", + "t.leady.com", + "t.mmtrkr.com", + "t.news.browns-restaurants.co.uk", + "t.notif-colissimo-laposte.info", + "t.pie.org", + "t.podcast.co", + "t.pubmatic.com", + "t.salesmatemail.com", + "t.vacations.disneydestinations.com", + "t.visit.disneydestinations.com", + "t.visitorqueue.com", + "t.x.co", + "t1.rorystravelclub.co.uk", + "t1.rorytravelclub-news.co.uk", + "taboola.com", + "tag-demo.mention-me.com", + "tag.mention-me.com", + "tagcommander.com", + "tagger.opecloud.com", + "tags.tiqcdn.com", + "tagtoo.com", + "tailsweep.com", + "tailsweep.se", + "takethatad.com", + "tamgrt.com", + "tangibleteam.com", + "tangyamount.com", + "tapad.com", + "tapfiliate.com", + "tapinfluence.com", + "tapjoy.com", + "tappx.com", + "targad.de", + "target.microsoft.com", + "targeting.api.drift.com", + "targeting.nzme.arcpublishing.com", + "targeting.voxus.tv", + "targetingnow.com", + "targetnet.com", + "targetpoint.com", + "tatsumi-sys.jp", + "tawdryson.com", + "tcads.net", + "teads.tv", + "tealeaf.com", + "tealium.cbsnews.com", + "tealium.com", + "tealiumiq.com", + "tedioustooth.com", + "teenrevenue.com", + "telaria.com", + "telemetry.dropbox.com", + "telemetry.goodlifefitness.com", + "telemetry.malwarebytes.com", + "telemetry.v.dropbox.com", + "temelio.com", + "tend.io", + "tendertest.com", + "ter-jrnl-oc.vidaahub.com", + "terriblethumb.com", + "text-link-ads.com", + "textad.sexsearch.com", + "textads.biz", + "textlinks.com", + "tfag.de", + "the-ozone-project.com", + "theadex.com", + "theadhost.com", + "theadsparks.com", + "thebugs.ws", + "thecrazychili.com", + "themangotea.com", + "themoneytizer.com", + "therapistla.com", + "thewavebeats.com", + "thinkitten.com", + "thirdparty.bnc.lt", + "thirdrespect.com", + "thomastorch.com", + "throtle.io", + "thruport.com", + "thunderhead.com", + "tia.timeinc.net", + "ticketaunt.com", + "ticklesign.com", + "ticksel.com", + "tics.techdirt.com", + "tidaltv.com", + "tidysprite.com", + "tinybar.com", + "tinybluewhale.com", + "tinytendency.com", + "tiresomethunder.com", + "tkbo.com", + "tls.telemetry.swe.quicinc.com", + "tlvmedia.com", + "tm.br.de", + "tnkexchange.com", + "tns-counter.ru", + "toolforthought.com", + "top-casting-termine.de", + "top-site-list.com", + "top.list.ru", + "top.mail.ru", + "top100-images.rambler.ru", + "top100.mafia.ru", + "top123.ro", + "top20free.com", + "toplist.cz", + "toplist.pornhost.com", + "toplista.mw.hu", + "toplistcity.com", + "topsir.com", + "topsite.lv", + "topsites.com.br", + "topstats.com", + "totemcash.com", + "touchclarity.com", + "tour.brazzers.com", + "tr.api.fanbyte.com", + "track-eu.customer.io", + "track.adform.net", + "track.anchorfree.com", + "track.canva.com", + "track.contently.com", + "track.customer.io", + "track.effiliation.com", + "track.flexlinks.com", + "track.flexlinkspro.com", + "track.lettingaproperty.com", + "track.mailalert.io", + "track.mailerlite.com", + "track.miro.com", + "track.nationalgunrights.org", + "track.privacyatclearbit.com", + "track.pubmatic.com", + "track.segmetrics.io", + "track.smtpmessage.com", + "track.software-codes.com", + "track.spe.schoolmessenger.com", + "track.ultravpn.com", + "track.unear.net", + "track.vcdc.com", + "track.viewdeos.com", + "track1.viewdeos.com", + "trackalyzer.com", + "trackedlink.net", + "trackedweb.net", + "tracker.bannerflow.com", + "tracker.cdnbye.com", + "tracker.icerocket.com", + "tracker.metricswave.com", + "tracker.mmdlv.it", + "tracker.samplicio.us", + "tracking.epicgames.com", + "tracking.hyros.com", + "tracking.ibxlink.com", + "tracking.intentsify.io", + "tracking.intl.miui.com", + "tracking.jiffyworld.com", + "tracking.markethero.io", + "tracking.miui.com", + "tracking.netalerts.io", + "tracking.olx-st.com", + "tracking.orixa-media.com", + "tracking.shopstyle.com", + "tracking.thinkabt.com", + "tracking.wetter.at", + "tracking01.walmart.com", + "tracking101.com", + "tracking22.com", + "trackingsoft.com", + "trackmysales.com", + "tradeadexchange.com", + "tradedoubler.com", + "traffic-exchange.com", + "traffic.hyteck.de", + "trafficfactory.biz", + "trafficforce.com", + "trafficholder.com", + "traffichunt.com", + "trafficjunky.net", + "trafficleader.com", + "trafficrouter.io", + "trafficshop.com", + "trafficspaces.net", + "trafficstrategies.com", + "trafficswarm.com", + "trafficz.com", + "traffiq.com", + "trafic.ro", + "traktrafficflow.com", + "tranquilplume.com", + "travis.bosscasinos.com", + "trck.a8.net", + "trck.mtrgt.id", + "trcklion.com", + "treasuredata.com", + "trekdata.com", + "tremendoustime.com", + "tremorhub.com", + "trendcounter.com", + "trendmd.com", + "trialfire.com", + "tribalfusion.com", + "triplelift.com", + "triptease.io", + "trk.4ff.pro", + "trk.bc.shutterfly.com", + "trk.pinterest.com", + "trk.sayerfinancial.com", + "trk.techtarget.com", + "trk1.avdlink.net", + "trk42.net", + "trkn.us", + "trkoptimizer.com", + "trkpnt.ongage.net", + "trmit.com", + "truckstomatoes.com", + "truculentrate.com", + "truehits.net", + "truehits1.gits.net.th", + "truehits2.gits.net.th", + "trust.titanhq.com", + "trustx.org", + "tsyndicate.com", + "tsyndicate.net", + "tubemogul.com", + "tumbleicicle.com", + "turboadv.com", + "turn.com", + "twelvedawn.com", + "twittad.com", + "twyn.com", + "tynt.com", + "typicalteeth.com", + "tyroo.com", + "uarating.com", + "ucfunnel.com", + "udkcrj.com", + "udncoeln.com", + "uib.ff.avast.com", + "ukoffzeh.com", + "ultimateclixx.com", + "ultramercial.com", + "ultraoranges.com", + "unaccountablepie.com", + "unarmedindustry.com", + "unbecominglamp.com", + "understoodocean.com", + "undertone.com", + "unidentifiedanalytics.web.app", + "unloadyourself.com", + "unruly.co", + "unrulymedia.com", + "untd.com", + "unusualtitle.com", + "unwieldyhealth.com", + "unwieldyimpulse.com", + "upgradeyoga.com", + "upu.samsungelectronics.com", + "urbanlaurel.com", + "url9467.comms-2.zoopla.co.uk", + "urlcash.net", + "us.a1.yimg.com", + "userreplay.com", + "userreplay.net", + "users.maxcluster.net", + "utils.mediageneral.net", + "utl-1.com", + "uu.domainforlite.com", + "v1.cnzz.com", + "v1adserver.com", + "valerie.forbes.com", + "validclick.com", + "valuead.com", + "valueclick.com", + "valueclickmedia.com", + "valuecommerce.com", + "vanfireworks.com", + "vcommission.com", + "veille-referencement.com", + "velismedia.com", + "venetrigni.com", + "vengefulgrass.com", + "ventivmedia.com", + "venturead.com", + "vericlick.com", + "vertamedia.com", + "verticalmass.com", + "vervewireless.com", + "vgnp3trk.com", + "vibrantsundown.com", + "vid.pubmatic.com", + "vidcpm.com", + "video-stats.video.google.com", + "videoadex.com", + "videoadstech.org", + "videoegg.com", + "videostats.kakao.com", + "vidora.com", + "view4cash.de", + "viglink.com", + "vilenexus.com", + "virtualvincent.com", + "visiblemeasures.com", + "visistat.com", + "visitbox.de", + "visual-pagerank.fr", + "visualrevenue.com", + "vivads.net", + "vivtracking.com", + "vmmpxl.com", + "voicefive.com", + "volatilevessel.com", + "voluum.com", + "voluumtrk2.com", + "vpon.com", + "vrs.cz", + "vtracy.de", + "vungle.com", + "w55c.net", + "wa.and.co.uk", + "waardex.com", + "warmafterthought.com", + "washbanana.com", + "wdads.sx.atl.publicus.com", + "wdfl.co", + "web-stat.com", + "web.informer.com", + "web2.deja.com", + "webads.co.nz", + "webads.nl", + "webanalytics.zohodcm.com", + "webcash.nl", + "webcontentassessor.com", + "webcounter.cz", + "webcounter.goweb.de", + "webgains.com", + "weborama.com", + "weborama.fr", + "webpower.com", + "webreseau.com", + "webseoanalytics.com", + "webstat.channel4.com", + "webstat.com", + "webstat.net", + "webtrackerplus.com", + "webtraffic.se", + "webtraxx.de", + "webxcdn.com", + "welved.com", + "werbung.meteoxpress.com", + "wetrack.it", + "whaleads.com", + "wheredoyoucomefrom.ovh", + "whirlwealth.com", + "whiskyqueue.com", + "whisperingcascade.com", + "whisperingcrib.com", + "whisperingsummit.com", + "whoisonline.net", + "wholepagecache.com", + "wickedreports.com", + "widget.educationdynamics.com", + "widget.privy.com", + "wikia-ads.wikia.com", + "wikiquotations.com", + "win.iqm.com", + "window.nixnet.cz", + "wintricksbanner.googlepages.com", + "wirecomic.com", + "wirypaste.com", + "wisepops.com", + "witch-counter.de", + "wittypopcorn.com", + "wizaly.com", + "wl.spotify.com", + "wlmarketing.com", + "wondoads.de", + "woopra.com", + "worldrealize.com", + "worldwide-cash.net", + "worriednumber.com", + "wowfunnow.com", + "writerhubs.com", + "wt-eu02.net", + "wt.bankmillennium.pl", + "www-banner.chat.ru", + "www-google-analytics.l.google.com", + "www.dnps.com", + "www.kaplanindex.com", + "www.photo-ads.co.uk", + "www8.glam.com", + "wwwpromoter.com", + "x-traceur.com", + "x6.yakiuchi.com", + "xad.com", + "xapads.com", + "xchange.ro", + "xertive.com", + "xfreeservice.com", + "xg4ken.com", + "xiti.com", + "xplusone.com", + "xponsor.com", + "xpu.samsungelectronics.com", + "xq1.net", + "xtendmedia.com", + "xtracker.logimeter.com", + "xxxcounter.com", + "xxxmyself.com", + "y.ibsys.com", + "yab-adimages.s3.amazonaws.com", + "yadro.ru", + "yandexmetrica.com", + "yepads.com", + "yesads.com", + "yesadvertising.com", + "yieldads.com", + "yieldlab.net", + "yieldmanager.net", + "yieldmo.com", + "yieldoptimizer.com", + "yieldtraffic.com", + "yldbt.com", + "ymetrica1.com", + "yoads.net", + "yoggrt.com", + "youcandrawanything.com", + "youradexchange.com", + "ypu.samsungelectronics.com", + "zangocash.com", + "zanox-affiliate.de", + "zanox.com", + "zantracker.com", + "zarget.com", + "zdbb.net", + "zedo.com", + "zemanta.com", + "zencudo.co.uk", + "zenkreka.com", + "zenzuu.com", + "zephyrlabyrinth.com", + "zeus.developershed.com", + "zeusclicks.com", + "zeydoo.com", + "zion-telemetry.api.cnn.io", + "zippingcare.com", + "zlp6s.pw", + "zm232.com", + "zmedia.com", + "zonewedgeshaft.com", + "zpu.samsungelectronics.com", + "zqtk.net", + "zzhc.vnet.cn", + } +) diff --git a/scrapling/engines/toolbelt/navigation.py b/scrapling/engines/toolbelt/navigation.py index 9b233d8..01bee07 100644 --- a/scrapling/engines/toolbelt/navigation.py +++ b/scrapling/engines/toolbelt/navigation.py @@ -19,6 +19,27 @@ class ProxyDict(Struct): password: str = "" +def _is_domain_blocked(hostname: str, domains: frozenset) -> bool: + """Check if a hostname matches any blocked domain using O(1) frozenset lookups. + + Walks up the hostname's suffix chain: for "tracker.ads.doubleclick.net", + checks "tracker.ads.doubleclick.net", "ads.doubleclick.net", "doubleclick.net". + + :param hostname: The hostname to check. + :param domains: A frozenset of blocked domain names. + :return: True if the hostname or any of its parent domains is in the blocked set. + """ + if hostname in domains: + return True + idx = hostname.find(".") + while idx != -1: + suffix = hostname[idx + 1 :] + if "." in suffix and suffix in domains: + return True + idx = hostname.find(".", idx + 1) + return False + + def create_intercept_handler(disable_resources: bool, blocked_domains: Optional[Set[str]] = None) -> Callable: """Create a route handler that blocks both resource types and specific domains. @@ -27,7 +48,7 @@ def create_intercept_handler(disable_resources: bool, blocked_domains: Optional[ :return: A sync route handler function. """ disabled_resources = EXTRA_RESOURCES if disable_resources else set() - domains = blocked_domains or set() + domains = frozenset(blocked_domains) if blocked_domains else frozenset() def handler(route: Route): if route.request.resource_type in disabled_resources: @@ -35,7 +56,7 @@ def create_intercept_handler(disable_resources: bool, blocked_domains: Optional[ route.abort() elif domains: hostname = urlparse(route.request.url).hostname or "" - if any(hostname == d or hostname.endswith("." + d) for d in domains): + if _is_domain_blocked(hostname, domains): log.debug(f'Blocking request to blocked domain "{hostname}" ({route.request.url})') route.abort() else: @@ -54,7 +75,7 @@ def create_async_intercept_handler(disable_resources: bool, blocked_domains: Opt :return: An async route handler function. """ disabled_resources = EXTRA_RESOURCES if disable_resources else set() - domains = blocked_domains or set() + domains = frozenset(blocked_domains) if blocked_domains else frozenset() async def handler(route: async_Route): if route.request.resource_type in disabled_resources: @@ -62,7 +83,7 @@ def create_async_intercept_handler(disable_resources: bool, blocked_domains: Opt await route.abort() elif domains: hostname = urlparse(route.request.url).hostname or "" - if any(hostname == d or hostname.endswith("." + d) for d in domains): + if _is_domain_blocked(hostname, domains): log.debug(f'Blocking request to blocked domain "{hostname}" ({route.request.url})') await route.abort() else: diff --git a/scrapling/fetchers/chrome.py b/scrapling/fetchers/chrome.py index 594b178..0c06aa1 100644 --- a/scrapling/fetchers/chrome.py +++ b/scrapling/fetchers/chrome.py @@ -15,6 +15,7 @@ class DynamicFetcher(BaseFetcher): :param headless: Run the browser in headless/hidden (default), or headful/visible mode. :param disable_resources: Drop requests for unnecessary resources for a speed boost. :param blocked_domains: A set of domain names to block requests to. Subdomains are also matched (e.g., ``"example.com"`` blocks ``"sub.example.com"`` too). + :param block_ads: Block requests to ~3,500 known ad/tracking domains. Can be combined with ``blocked_domains``. :param useragent: Pass a useragent string to be used. Otherwise the fetcher will generate a real Useragent of the same browser and use it. :param cookies: Set cookies for the next request. :param network_idle: Wait for the page until there are no network connections for at least 500 ms. @@ -55,6 +56,7 @@ class DynamicFetcher(BaseFetcher): :param headless: Run the browser in headless/hidden (default), or headful/visible mode. :param disable_resources: Drop requests for unnecessary resources for a speed boost. :param blocked_domains: A set of domain names to block requests to. Subdomains are also matched (e.g., ``"example.com"`` blocks ``"sub.example.com"`` too). + :param block_ads: Block requests to ~3,500 known ad/tracking domains. Can be combined with ``blocked_domains``. :param useragent: Pass a useragent string to be used. Otherwise the fetcher will generate a real Useragent of the same browser and use it. :param cookies: Set cookies for the next request. :param network_idle: Wait for the page until there are no network connections for at least 500 ms. diff --git a/scrapling/fetchers/stealth_chrome.py b/scrapling/fetchers/stealth_chrome.py index 1cf89f3..0c7134c 100644 --- a/scrapling/fetchers/stealth_chrome.py +++ b/scrapling/fetchers/stealth_chrome.py @@ -20,6 +20,7 @@ class StealthyFetcher(BaseFetcher): :param disable_resources: Drop requests for unnecessary resources for a speed boost. Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`. :param blocked_domains: A set of domain names to block requests to. Subdomains are also matched (e.g., ``"example.com"`` blocks ``"sub.example.com"`` too). + :param block_ads: Block requests to ~3,500 known ad/tracking domains. Can be combined with ``blocked_domains``. :param useragent: Pass a useragent string to be used. Otherwise the fetcher will generate a real Useragent of the same browser and use it. :param cookies: Set cookies for the next request. :param network_idle: Wait for the page until there are no network connections for at least 500 ms. @@ -69,6 +70,7 @@ class StealthyFetcher(BaseFetcher): :param disable_resources: Drop requests for unnecessary resources for a speed boost. Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`. :param blocked_domains: A set of domain names to block requests to. Subdomains are also matched (e.g., ``"example.com"`` blocks ``"sub.example.com"`` too). + :param block_ads: Block requests to ~3,500 known ad/tracking domains. Can be combined with ``blocked_domains``. :param useragent: Pass a useragent string to be used. Otherwise the fetcher will generate a real Useragent of the same browser and use it. :param cookies: Set cookies for the next request. :param network_idle: Wait for the page until there are no network connections for at least 500 ms. diff --git a/tests/fetchers/test_utils.py b/tests/fetchers/test_utils.py index bebb5fd..942835f 100644 --- a/tests/fetchers/test_utils.py +++ b/tests/fetchers/test_utils.py @@ -5,11 +5,9 @@ from scrapling.engines.toolbelt.navigation import ( construct_proxy_dict, create_intercept_handler, create_async_intercept_handler, + _is_domain_blocked, ) -from scrapling.engines.toolbelt.fingerprints import ( - get_os_name, - generate_headers -) +from scrapling.engines.toolbelt.fingerprints import get_os_name, generate_headers @pytest.fixture @@ -148,31 +146,19 @@ class TestConstructProxyDict: """Test a basic proxy string""" result = construct_proxy_dict("http://proxy.example.com:8080") - expected = { - "server": "http://proxy.example.com:8080", - "username": "", - "password": "" - } + expected = {"server": "http://proxy.example.com:8080", "username": "", "password": ""} assert result == expected def test_proxy_string_with_auth(self): """Test proxy string with authentication""" result = construct_proxy_dict("http://user:pass@proxy.example.com:8080") - expected = { - "server": "http://proxy.example.com:8080", - "username": "user", - "password": "pass" - } + expected = {"server": "http://proxy.example.com:8080", "username": "user", "password": "pass"} assert result == expected def test_proxy_dict_input(self): """Test proxy dictionary input""" - input_dict = { - "server": "http://proxy.example.com:8080", - "username": "user", - "password": "pass" - } + input_dict = {"server": "http://proxy.example.com:8080", "username": "user", "password": "pass"} result = construct_proxy_dict(input_dict) assert result == input_dict @@ -182,11 +168,7 @@ class TestConstructProxyDict: input_dict = {"server": "http://proxy.example.com:8080"} result = construct_proxy_dict(input_dict) - expected = { - "server": "http://proxy.example.com:8080", - "username": "", - "password": "" - } + expected = {"server": "http://proxy.example.com:8080", "username": "", "password": ""} assert result == expected def test_invalid_proxy_string(self): @@ -240,7 +222,7 @@ class TestResponse: cookies={"session": "abc123"}, headers={"Content-Type": "text/html"}, request_headers={"User-Agent": "Test"}, - encoding="utf-8" + encoding="utf-8", ) assert response.url == "https://example.com" @@ -250,7 +232,7 @@ class TestResponse: def test_response_with_bytes_content(self): """Test Response with 'bytes' content""" - content_bytes = "Test".encode('utf-8') + content_bytes = "Test".encode("utf-8") response = Response( url="https://example.com", @@ -259,7 +241,7 @@ class TestResponse: reason="OK", cookies={}, headers={}, - request_headers={} + request_headers={}, ) # Should handle 'bytes' content properly @@ -268,6 +250,7 @@ class TestResponse: class _MockRequest: """Minimal mock for Playwright's Request object.""" + def __init__(self, url: str, resource_type: str = "document"): self.url = url self.resource_type = resource_type @@ -275,6 +258,7 @@ class _MockRequest: class _MockRoute: """Minimal mock for Playwright's sync Route object.""" + def __init__(self, url: str, resource_type: str = "document"): self.request = _MockRequest(url, resource_type) self.aborted = False @@ -289,6 +273,7 @@ class _MockRoute: class _AsyncMockRoute: """Minimal mock for Playwright's async Route object.""" + def __init__(self, url: str, resource_type: str = "document"): self.request = _MockRequest(url, resource_type) self.aborted = False @@ -411,3 +396,90 @@ class TestCreateAsyncInterceptHandler: route = _AsyncMockRoute("https://notexample.com/page") await handler(route) assert route.continued + + +class TestIsDomainBlocked: + """Test the frozenset-based domain matching helper.""" + + def test_exact_match(self): + domains = frozenset({"doubleclick.net"}) + assert _is_domain_blocked("doubleclick.net", domains) is True + + def test_subdomain_match(self): + domains = frozenset({"doubleclick.net"}) + assert _is_domain_blocked("ads.doubleclick.net", domains) is True + + def test_deep_subdomain_match(self): + domains = frozenset({"doubleclick.net"}) + assert _is_domain_blocked("tracker.ads.doubleclick.net", domains) is True + + def test_no_partial_match(self): + domains = frozenset({"doubleclick.net"}) + assert _is_domain_blocked("notdoubleclick.net", domains) is False + + def test_no_match(self): + domains = frozenset({"doubleclick.net"}) + assert _is_domain_blocked("example.com", domains) is False + + def test_empty_domains(self): + assert _is_domain_blocked("example.com", frozenset()) is False + + def test_multiple_domains(self): + domains = frozenset({"ads.com", "tracker.io", "doubleclick.net"}) + assert _is_domain_blocked("cdn.ads.com", domains) is True + assert _is_domain_blocked("tracker.io", domains) is True + assert _is_domain_blocked("safe.example.com", domains) is False + + +class TestAdDomains: + """Test the built-in ad domain list.""" + + def test_ad_domains_is_frozenset(self): + from scrapling.engines.toolbelt.ad_domains import AD_DOMAINS + + assert isinstance(AD_DOMAINS, frozenset) + + def test_ad_domains_has_entries(self): + from scrapling.engines.toolbelt.ad_domains import AD_DOMAINS + + assert len(AD_DOMAINS) > 1000 + + def test_ad_domains_contains_known_entries(self): + from scrapling.engines.toolbelt.ad_domains import AD_DOMAINS + + assert "doubleclick.net" in AD_DOMAINS + assert "googlesyndication.com" in AD_DOMAINS + + +class TestBlockAdsConfig: + """Test that block_ads merges ad domains into blocked_domains at config level.""" + + def test_block_ads_populates_blocked_domains(self): + from scrapling.engines._browsers._validators import PlaywrightConfig + + config = PlaywrightConfig(block_ads=True) + assert config.blocked_domains is not None + assert len(config.blocked_domains) > 1000 + assert "doubleclick.net" in config.blocked_domains + + def test_block_ads_false_leaves_blocked_domains_none(self): + from scrapling.engines._browsers._validators import PlaywrightConfig + + config = PlaywrightConfig(block_ads=False) + assert config.blocked_domains is None + + def test_block_ads_merges_with_user_domains(self): + from scrapling.engines._browsers._validators import PlaywrightConfig + + user_domains = {"my-custom-block.com"} + config = PlaywrightConfig(block_ads=True, blocked_domains=user_domains) + assert config.blocked_domains is not None + assert "my-custom-block.com" in config.blocked_domains + assert "doubleclick.net" in config.blocked_domains + + def test_block_ads_does_not_modify_original_set(self): + from scrapling.engines._browsers._validators import PlaywrightConfig + + user_domains = {"my-custom-block.com"} + _ = PlaywrightConfig(block_ads=True, blocked_domains=user_domains) + assert len(user_domains) == 1 From 0678ed1406f0e49d292f3974a69f79f924cb30b7 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 12 Apr 2026 18:00:41 +0200 Subject: [PATCH 42/54] fix(shell): add missing parameters to the shell signature --- scrapling/core/_shell_signatures.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scrapling/core/_shell_signatures.py b/scrapling/core/_shell_signatures.py index ce509b6..afa482d 100644 --- a/scrapling/core/_shell_signatures.py +++ b/scrapling/core/_shell_signatures.py @@ -2,6 +2,7 @@ from scrapling.core._types import ( Any, Dict, List, + Set, Tuple, Sequence, Callable, @@ -58,7 +59,12 @@ _FETCH_PARAMS = { "cdp_url": Optional[str], "useragent": Optional[str], "extra_flags": Optional[List[str]], + "blocked_domains": Optional[Set[str]], "block_ads": bool, + "retries": int, + "retry_delay": int | float, + "capture_xhr": str | None, + "executable_path": Optional[str], } _STEALTHY_FETCH_PARAMS = { @@ -85,7 +91,12 @@ _STEALTHY_FETCH_PARAMS = { "cdp_url": Optional[str], "useragent": Optional[str], "extra_flags": Optional[List[str]], + "blocked_domains": Optional[Set[str]], "block_ads": bool, + "retries": int, + "retry_delay": int | float, + "capture_xhr": str | None, + "executable_path": Optional[str], "allow_webgl": bool, "hide_canvas": bool, "block_webrtc": bool, From be28fe16ec1501e1f534b007b5263ac06b301ebe Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 12 Apr 2026 18:03:03 +0200 Subject: [PATCH 43/54] feat(browsers): add new feature to enable DNS-over-HTTP to prevent DNS leaks --- scrapling/cli.py | 11 +++++++++++ scrapling/core/_shell_signatures.py | 2 ++ scrapling/engines/_browsers/_base.py | 7 +++++++ scrapling/engines/_browsers/_types.py | 1 + scrapling/engines/_browsers/_validators.py | 1 + scrapling/fetchers/chrome.py | 2 ++ scrapling/fetchers/stealth_chrome.py | 2 ++ 7 files changed, 26 insertions(+) diff --git a/scrapling/cli.py b/scrapling/cli.py index 9ca0dd4..3327dcb 100644 --- a/scrapling/cli.py +++ b/scrapling/cli.py @@ -309,6 +309,11 @@ def _common_browser_options(f): default=True, help="Run browser in headless mode (default: True)", ), + option( + "--dns-over-https/--no-dns-over-https", + default=False, + help="Route DNS through Cloudflare's DoH to prevent DNS leaks when using proxies (default: False)", + ), option( "--block-ads/--no-block-ads", default=False, @@ -503,6 +508,7 @@ def __build_browser_kwargs( real_chrome, proxy, parsed_headers, + dns_over_https, block_ads, ) -> Dict[str, Any]: """Build shared kwargs dict for browser-based commands.""" @@ -513,6 +519,7 @@ def __build_browser_kwargs( "timeout": timeout, "locale": locale, "real_chrome": real_chrome, + "dns_over_https": dns_over_https, "block_ads": block_ads, } if wait > 0: @@ -545,6 +552,7 @@ def fetch( proxy, extra_headers, ai_targeted, + dns_over_https, block_ads, ): """Opens up a browser and fetch content using DynamicFetcher.""" @@ -560,6 +568,7 @@ def fetch( real_chrome, proxy, parsed_headers, + dns_over_https, block_ads, ) from scrapling.fetchers import DynamicFetcher @@ -606,6 +615,7 @@ def stealthy_fetch( allow_webgl, hide_canvas, ai_targeted, + dns_over_https, block_ads, ): """Opens up a browser with advanced stealth features and fetch content using StealthyFetcher.""" @@ -621,6 +631,7 @@ def stealthy_fetch( real_chrome, proxy, parsed_headers, + dns_over_https, block_ads, ) kwargs.update( diff --git a/scrapling/core/_shell_signatures.py b/scrapling/core/_shell_signatures.py index afa482d..b2340fb 100644 --- a/scrapling/core/_shell_signatures.py +++ b/scrapling/core/_shell_signatures.py @@ -65,6 +65,7 @@ _FETCH_PARAMS = { "retry_delay": int | float, "capture_xhr": str | None, "executable_path": Optional[str], + "dns_over_https": bool, } _STEALTHY_FETCH_PARAMS = { @@ -97,6 +98,7 @@ _STEALTHY_FETCH_PARAMS = { "retry_delay": int | float, "capture_xhr": str | None, "executable_path": Optional[str], + "dns_over_https": bool, "allow_webgl": bool, "hide_canvas": bool, "block_webrtc": bool, diff --git a/scrapling/engines/_browsers/_base.py b/scrapling/engines/_browsers/_base.py index 2d42b08..c9d390c 100644 --- a/scrapling/engines/_browsers/_base.py +++ b/scrapling/engines/_browsers/_base.py @@ -455,6 +455,13 @@ class BaseSessionMixin: if config.extra_flags or extra_flags: flags = list(set(tuple(flags) + tuple(config.extra_flags or extra_flags or ()))) + if config.dns_over_https: + doh_flag = "--dns-over-https-templates=https://cloudflare-dns.com/dns-query" + if isinstance(flags, list): + flags.append(doh_flag) + else: + flags = list(flags) + [doh_flag] + self._browser_options.update( { "args": flags, diff --git a/scrapling/engines/_browsers/_types.py b/scrapling/engines/_browsers/_types.py index 932b2e2..5030480 100644 --- a/scrapling/engines/_browsers/_types.py +++ b/scrapling/engines/_browsers/_types.py @@ -93,6 +93,7 @@ class PlaywrightSession(TypedDict, total=False): retry_delay: int | float capture_xhr: str | None executable_path: Optional[str] + dns_over_https: bool class PlaywrightFetchParams(TypedDict, total=False): diff --git a/scrapling/engines/_browsers/_validators.py b/scrapling/engines/_browsers/_validators.py index 8d47061..f7bb34e 100644 --- a/scrapling/engines/_browsers/_validators.py +++ b/scrapling/engines/_browsers/_validators.py @@ -90,6 +90,7 @@ class PlaywrightConfig(Struct, kw_only=True, frozen=False, weakref=True): retry_delay: Seconds = 1 capture_xhr: str | None = None executable_path: Optional[str] = None + dns_over_https: bool = False def __post_init__(self): # pragma: no cover """Custom validation after msgspec validation""" diff --git a/scrapling/fetchers/chrome.py b/scrapling/fetchers/chrome.py index 0c06aa1..f7795b7 100644 --- a/scrapling/fetchers/chrome.py +++ b/scrapling/fetchers/chrome.py @@ -16,6 +16,7 @@ class DynamicFetcher(BaseFetcher): :param disable_resources: Drop requests for unnecessary resources for a speed boost. :param blocked_domains: A set of domain names to block requests to. Subdomains are also matched (e.g., ``"example.com"`` blocks ``"sub.example.com"`` too). :param block_ads: Block requests to ~3,500 known ad/tracking domains. Can be combined with ``blocked_domains``. + :param dns_over_https: Route DNS queries through Cloudflare's DNS-over-HTTPS to prevent DNS leaks when using proxies. :param useragent: Pass a useragent string to be used. Otherwise the fetcher will generate a real Useragent of the same browser and use it. :param cookies: Set cookies for the next request. :param network_idle: Wait for the page until there are no network connections for at least 500 ms. @@ -57,6 +58,7 @@ class DynamicFetcher(BaseFetcher): :param disable_resources: Drop requests for unnecessary resources for a speed boost. :param blocked_domains: A set of domain names to block requests to. Subdomains are also matched (e.g., ``"example.com"`` blocks ``"sub.example.com"`` too). :param block_ads: Block requests to ~3,500 known ad/tracking domains. Can be combined with ``blocked_domains``. + :param dns_over_https: Route DNS queries through Cloudflare's DNS-over-HTTPS to prevent DNS leaks when using proxies. :param useragent: Pass a useragent string to be used. Otherwise the fetcher will generate a real Useragent of the same browser and use it. :param cookies: Set cookies for the next request. :param network_idle: Wait for the page until there are no network connections for at least 500 ms. diff --git a/scrapling/fetchers/stealth_chrome.py b/scrapling/fetchers/stealth_chrome.py index 0c7134c..70ce937 100644 --- a/scrapling/fetchers/stealth_chrome.py +++ b/scrapling/fetchers/stealth_chrome.py @@ -21,6 +21,7 @@ class StealthyFetcher(BaseFetcher): Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`. :param blocked_domains: A set of domain names to block requests to. Subdomains are also matched (e.g., ``"example.com"`` blocks ``"sub.example.com"`` too). :param block_ads: Block requests to ~3,500 known ad/tracking domains. Can be combined with ``blocked_domains``. + :param dns_over_https: Route DNS queries through Cloudflare's DNS-over-HTTPS to prevent DNS leaks when using proxies. :param useragent: Pass a useragent string to be used. Otherwise the fetcher will generate a real Useragent of the same browser and use it. :param cookies: Set cookies for the next request. :param network_idle: Wait for the page until there are no network connections for at least 500 ms. @@ -71,6 +72,7 @@ class StealthyFetcher(BaseFetcher): Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`. :param blocked_domains: A set of domain names to block requests to. Subdomains are also matched (e.g., ``"example.com"`` blocks ``"sub.example.com"`` too). :param block_ads: Block requests to ~3,500 known ad/tracking domains. Can be combined with ``blocked_domains``. + :param dns_over_https: Route DNS queries through Cloudflare's DNS-over-HTTPS to prevent DNS leaks when using proxies. :param useragent: Pass a useragent string to be used. Otherwise the fetcher will generate a real Useragent of the same browser and use it. :param cookies: Set cookies for the next request. :param network_idle: Wait for the page until there are no network connections for at least 500 ms. From 131b729f228d30b5882caff051620ea44eaad0ac Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 12 Apr 2026 18:07:38 +0200 Subject: [PATCH 44/54] docs: adding the new features --- docs/fetching/dynamic.md | 2 ++ docs/fetching/stealthy.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/fetching/dynamic.md b/docs/fetching/dynamic.md index 5e118f6..e8ce9db 100644 --- a/docs/fetching/dynamic.md +++ b/docs/fetching/dynamic.md @@ -88,6 +88,8 @@ Scrapling provides many options with this fetcher and its session classes. To ma | additional_args | Additional arguments to be passed to Playwright's context as additional settings, and they take higher priority than Scrapling's settings. | ✔️ | | selector_config | A dictionary of custom parsing arguments to be used when creating the final `Selector`/`Response` class. | ✔️ | | blocked_domains | A set of domain names to block requests to. Subdomains are also matched (e.g., `"example.com"` blocks `"sub.example.com"` too). | ✔️ | +| block_ads | Block requests to ~3,500 known ad/tracking domains. Can be combined with `blocked_domains`. | ✔️ | +| dns_over_https | Route DNS queries through Cloudflare's DNS-over-HTTPS to prevent DNS leaks when using proxies. | ✔️ | | proxy_rotator | A `ProxyRotator` instance for automatic proxy rotation. Cannot be combined with `proxy`. | ✔️ | | retries | Number of retry attempts for failed requests. Defaults to 3. | ✔️ | | retry_delay | Seconds to wait between retry attempts. Defaults to 1. | ✔️ | diff --git a/docs/fetching/stealthy.md b/docs/fetching/stealthy.md index 22b4aec..5b7c042 100644 --- a/docs/fetching/stealthy.md +++ b/docs/fetching/stealthy.md @@ -69,6 +69,8 @@ Scrapling provides many options with this fetcher and its session classes. Befor | additional_args | Additional arguments to be passed to Playwright's context as additional settings, and they take higher priority than Scrapling's settings. | ✔️ | | selector_config | A dictionary of custom parsing arguments to be used when creating the final `Selector`/`Response` class. | ✔️ | | blocked_domains | A set of domain names to block requests to. Subdomains are also matched (e.g., `"example.com"` blocks `"sub.example.com"` too). | ✔️ | +| block_ads | Block requests to ~3,500 known ad/tracking domains. Can be combined with `blocked_domains`. | ✔️ | +| dns_over_https | Route DNS queries through Cloudflare's DNS-over-HTTPS to prevent DNS leaks when using proxies. | ✔️ | | proxy_rotator | A `ProxyRotator` instance for automatic proxy rotation. Cannot be combined with `proxy`. | ✔️ | | retries | Number of retry attempts for failed requests. Defaults to 3. | ✔️ | | retry_delay | Seconds to wait between retry attempts. Defaults to 1. | ✔️ | From ad6fd5284553f4160f9e2aa6f002147b64e6064c Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 12 Apr 2026 18:11:02 +0200 Subject: [PATCH 45/54] perf: force ad blocking on the MCP server and when the AI mode is activated on CLI --- scrapling/cli.py | 2 ++ scrapling/core/ai.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/scrapling/cli.py b/scrapling/cli.py index 3327dcb..47056d9 100644 --- a/scrapling/cli.py +++ b/scrapling/cli.py @@ -53,6 +53,8 @@ def __Request_and_Save( if not output_path.is_absolute(): output_path = Path.cwd() / output_file + if ai_targeted: + kwargs.setdefault("block_ads", True) response = fetcher_func(url, **kwargs) Convertor.write_content_to_file(response, str(output_path), css_selector, main_content_only=ai_targeted) log.info(f"Content successfully saved to '{output_path}'") diff --git a/scrapling/core/ai.py b/scrapling/core/ai.py index 9b33821..060cb20 100644 --- a/scrapling/core/ai.py +++ b/scrapling/core/ai.py @@ -183,6 +183,7 @@ class ScraplingMCPServer: cookies=cookies, cdp_url=cdp_url, headless=headless, + block_ads=True, max_pages=max_pages, useragent=useragent, timezone_id=timezone_id, @@ -569,6 +570,7 @@ class ScraplingMCPServer: cookies=cookies, cdp_url=cdp_url, headless=headless, + block_ads=True, max_pages=len(urls), useragent=useragent, timezone_id=timezone_id, @@ -777,6 +779,7 @@ class ScraplingMCPServer: timeout=timeout, cookies=cookies, headless=headless, + block_ads=True, useragent=useragent, timezone_id=timezone_id, real_chrome=real_chrome, From 9a7fb0b426010f7a95e0c14f3fdb273f1afeae5e Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 12 Apr 2026 18:20:56 +0200 Subject: [PATCH 46/54] docs: adding the new features and updating the translated pages --- README.md | 3 ++- docs/README_AR.md | 3 ++- docs/README_CN.md | 3 ++- docs/README_DE.md | 3 ++- docs/README_ES.md | 3 ++- docs/README_FR.md | 3 ++- docs/README_JP.md | 3 ++- docs/README_KR.md | 3 ++- docs/README_RU.md | 3 ++- docs/ai/mcp-server.md | 1 + docs/cli/extract-commands.md | 6 +++++- docs/index.md | 3 ++- 12 files changed, 26 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 408cfef..7bf2952 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,8 @@ MySpider().start() - **Anti-bot Bypass**: Advanced stealth capabilities with `StealthyFetcher` and fingerprint spoofing. Can easily bypass all types of Cloudflare's Turnstile/Interstitial with automation. - **Session Management**: Persistent session support with `FetcherSession`, `StealthySession`, and `DynamicSession` classes for cookie and state management across requests. - **Proxy Rotation**: Built-in `ProxyRotator` with cyclic or custom rotation strategies across all session types, plus per-request proxy overrides. -- **Domain Blocking**: Block requests to specific domains (and their subdomains) in browser-based fetchers. +- **Domain & Ad Blocking**: Block requests to specific domains (and their subdomains) or enable built-in ad blocking (~3,500 known ad/tracker domains) in browser-based fetchers. +- **DNS Leak Prevention**: Optional DNS-over-HTTPS support to route DNS queries through Cloudflare's DoH, preventing DNS leaks when using proxies. - **Async Support**: Complete async support across all fetchers and dedicated async session classes. ### Adaptive Scraping & AI Integration diff --git a/docs/README_AR.md b/docs/README_AR.md index c0de787..ed23dd9 100644 --- a/docs/README_AR.md +++ b/docs/README_AR.md @@ -219,7 +219,8 @@ MySpider().start() - **تجاوز مكافحة الروبوتات**: قدرات تخفي متقدمة مع `StealthyFetcher` وانتحال fingerprint. يمكنه تجاوز جميع أنواع Turnstile/Interstitial من Cloudflare بسهولة بالأتمتة. - **إدارة الجلسات**: دعم الجلسات المستمرة مع فئات `FetcherSession` و`StealthySession` و`DynamicSession` لإدارة ملفات تعريف الارتباط والحالة عبر الطلبات. - **تدوير Proxy**: `ProxyRotator` مدمج مع استراتيجيات التدوير الدوري أو المخصصة عبر جميع أنواع الجلسات، بالإضافة إلى تجاوزات Proxy لكل طلب. -- **حظر النطاقات**: حظر الطلبات إلى نطاقات محددة (ونطاقاتها الفرعية) في الجوالب المعتمدة على المتصفح. +- **حظر النطاقات والإعلانات**: حظر الطلبات إلى نطاقات محددة (ونطاقاتها الفرعية) أو تفعيل حظر الإعلانات المدمج (~3,500 نطاق إعلانات/تتبع معروف) في الجوالب المعتمدة على المتصفح. +- **منع تسرب DNS**: دعم اختياري لـ DNS-over-HTTPS لتوجيه استعلامات DNS عبر Cloudflare DoH، مما يمنع تسرب DNS عند استخدام Proxy. - **دعم Async**: دعم async كامل عبر جميع الجوالب وفئات الجلسات async المخصصة. ### الاستخراج التكيفي والتكامل مع الذكاء الاصطناعي diff --git a/docs/README_CN.md b/docs/README_CN.md index 6cb9d4e..7222081 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -219,7 +219,8 @@ MySpider().start() - **反机器人绕过**:使用 `StealthyFetcher` 的高级隐秘功能和 fingerprint 伪装。可以轻松自动绕过所有类型的 Cloudflare Turnstile/Interstitial。 - **Session 管理**:使用 `FetcherSession`、`StealthySession` 和 `DynamicSession` 类实现持久化 Session 支持,用于跨请求的 cookie 和状态管理。 - **Proxy 轮换**:内置 `ProxyRotator`,支持轮询或自定义策略,适用于所有 Session 类型,并支持按请求覆盖 Proxy。 -- **域名屏蔽**:在基于浏览器的 Fetcher 中屏蔽对特定域名(及其子域名)的请求。 +- **域名和广告屏蔽**:在基于浏览器的 Fetcher 中屏蔽对特定域名(及其子域名)的请求,或启用内置广告屏蔽(约 3,500 个已知广告/追踪域名)。 +- **DNS 泄漏防护**:可选的 DNS-over-HTTPS 支持,通过 Cloudflare 的 DoH 路由 DNS 查询,防止使用代理时的 DNS 泄漏。 - **Async 支持**:所有 Fetcher 和专用 async Session 类的完整 async 支持。 ### 自适应抓取和 AI 集成 diff --git a/docs/README_DE.md b/docs/README_DE.md index 82d336f..f6f0fc9 100644 --- a/docs/README_DE.md +++ b/docs/README_DE.md @@ -219,7 +219,8 @@ MySpider().start() - **Anti-Bot-Umgehung**: Erweiterte Stealth-Fähigkeiten mit `StealthyFetcher` und Fingerprint-Spoofing. Kann alle Arten von Cloudflares Turnstile/Interstitial einfach mit Automatisierung umgehen. - **Session-Verwaltung**: Persistente Session-Unterstützung mit den Klassen `FetcherSession`, `StealthySession` und `DynamicSession` für Cookie- und Zustandsverwaltung über Anfragen hinweg. - **Proxy-Rotation**: Integrierter `ProxyRotator` mit zyklischen oder benutzerdefinierten Rotationsstrategien über alle Session-Typen hinweg, plus Proxy-Überschreibungen pro Anfrage. -- **Domain-Blockierung**: Anfragen an bestimmte Domains (und deren Subdomains) in browserbasierten Fetchern blockieren. +- **Domain- & Werbeblockierung**: Anfragen an bestimmte Domains (und deren Subdomains) blockieren oder die integrierte Werbeblockierung (~3.500 bekannte Werbe-/Tracker-Domains) in browserbasierten Fetchern aktivieren. +- **DNS-Leak-Prävention**: Optionale DNS-over-HTTPS-Unterstützung zur Weiterleitung von DNS-Anfragen über Cloudflares DoH, um DNS-Leaks bei der Verwendung von Proxys zu verhindern. - **Async-Unterstützung**: Vollständige async-Unterstützung über alle Fetcher und dedizierte async Session-Klassen hinweg. ### Adaptives Scraping & KI-Integration diff --git a/docs/README_ES.md b/docs/README_ES.md index 58dccb8..31e4683 100644 --- a/docs/README_ES.md +++ b/docs/README_ES.md @@ -219,7 +219,8 @@ MySpider().start() - **Evasión Anti-bot**: Capacidades de sigilo avanzadas con `StealthyFetcher` y falsificación de fingerprint. Puede evadir fácilmente todos los tipos de Turnstile/Interstitial de Cloudflare con automatización. - **Gestión de Session**: Soporte de sesión persistente con las clases `FetcherSession`, `StealthySession` y `DynamicSession` para la gestión de cookies y estado entre solicitudes. - **Rotación de Proxy**: `ProxyRotator` integrado con estrategias de rotación cíclica o personalizadas en todos los tipos de sesión, además de sobrescrituras de Proxy por solicitud. -- **Bloqueo de Dominios**: Bloquea solicitudes a dominios específicos (y sus subdominios) en fetchers basados en navegador. +- **Bloqueo de Dominios y Anuncios**: Bloquea solicitudes a dominios específicos (y sus subdominios) o activa el bloqueo de anuncios integrado (~3,500 dominios de anuncios/rastreadores conocidos) en fetchers basados en navegador. +- **Prevención de Fugas DNS**: Soporte opcional de DNS-over-HTTPS para enrutar consultas DNS a través del DoH de Cloudflare, previniendo fugas DNS al usar proxies. - **Soporte Async**: Soporte async completo en todos los fetchers y clases de sesión async dedicadas. ### Scraping Adaptativo e Integración con IA diff --git a/docs/README_FR.md b/docs/README_FR.md index a452210..a687136 100644 --- a/docs/README_FR.md +++ b/docs/README_FR.md @@ -219,7 +219,8 @@ MySpider().start() - **Contournement anti-bot** : Capacités de furtivité avancées avec `StealthyFetcher` et usurpation d'empreinte. Peut facilement contourner tous les types de Turnstile/Interstitial de Cloudflare avec l'automatisation. - **Gestion de sessions** : Support de sessions persistantes avec les classes `FetcherSession`, `StealthySession` et `DynamicSession` pour la gestion des cookies et de l'état entre les requêtes. - **Rotation de proxy** : `ProxyRotator` intégré avec des stratégies de rotation cycliques ou personnalisées sur tous les types de sessions, plus des surcharges de proxy par requête. -- **Blocage de domaines** : Bloquez les requêtes vers des domaines spécifiques (et leurs sous-domaines) dans les fetchers basés sur navigateur. +- **Blocage de domaines et publicités** : Bloquez les requêtes vers des domaines spécifiques (et leurs sous-domaines) ou activez le blocage de publicités intégré (~3 500 domaines publicitaires/traceurs connus) dans les fetchers basés sur navigateur. +- **Prévention des fuites DNS** : Support optionnel de DNS-over-HTTPS pour router les requêtes DNS via le DoH de Cloudflare, empêchant les fuites DNS lors de l'utilisation de proxies. - **Support async** : Support async complet sur tous les fetchers et classes de sessions async dédiées. ### Scraping adaptatif & Intégration IA diff --git a/docs/README_JP.md b/docs/README_JP.md index 9a2a29d..a2f0a3f 100644 --- a/docs/README_JP.md +++ b/docs/README_JP.md @@ -219,7 +219,8 @@ MySpider().start() - **アンチボット回避**:`StealthyFetcher` と fingerprint 偽装による高度なステルス機能。自動化で Cloudflare の Turnstile/Interstitial のすべてのタイプを簡単に回避。 - **Session 管理**:リクエスト間で Cookie と状態を管理するための `FetcherSession`、`StealthySession`、`DynamicSession` クラスによる永続的な Session サポート。 - **Proxy 回転**:すべての Session タイプに対応したラウンドロビンまたはカスタム戦略の組み込み `ProxyRotator`、さらにリクエストごとの Proxy オーバーライド。 -- **ドメインブロック**:ブラウザベースの Fetcher で特定のドメイン(およびそのサブドメイン)へのリクエストをブロック。 +- **ドメイン&広告ブロック**:ブラウザベースの Fetcher で特定のドメイン(およびそのサブドメイン)へのリクエストをブロック、または内蔵広告ブロック(約3,500の既知の広告/トラッカードメイン)を有効化。 +- **DNS リーク防止**:Proxy 使用時の DNS リークを防ぐため、Cloudflare の DoH 経由で DNS クエリをルーティングするオプションの DNS-over-HTTPS サポート。 - **async サポート**:すべての Fetcher および専用 async Session クラス全体での完全な async サポート。 ### 適応型スクレイピングと AI 統合 diff --git a/docs/README_KR.md b/docs/README_KR.md index 6d79389..886aea4 100644 --- a/docs/README_KR.md +++ b/docs/README_KR.md @@ -219,7 +219,8 @@ MySpider().start() - **안티봇 우회**: `StealthyFetcher`와 fingerprint 위장을 통한 고급 스텔스 기능. 자동화로 모든 유형의 Cloudflare Turnstile/Interstitial을 손쉽게 우회합니다. - **세션 관리**: `FetcherSession`, `StealthySession`, `DynamicSession` 클래스로 요청 간 쿠키와 상태를 관리하는 영속적 세션을 지원합니다. - **프록시 로테이션**: 모든 세션 타입에 대응하는 순환 또는 커스텀 전략의 내장 `ProxyRotator`와 요청별 프록시 오버라이드를 제공합니다. -- **도메인 차단**: 브라우저 기반 Fetcher에서 특정 도메인(및 하위 도메인)으로의 요청을 차단합니다. +- **도메인 및 광고 차단**: 브라우저 기반 Fetcher에서 특정 도메인(및 하위 도메인)으로의 요청을 차단하거나 내장 광고 차단(약 3,500개의 알려진 광고/트래커 도메인)을 활성화합니다. +- **DNS 유출 방지**: 프록시 사용 시 DNS 유출을 방지하기 위해 Cloudflare DoH를 통해 DNS 쿼리를 라우팅하는 선택적 DNS-over-HTTPS 지원. - **비동기 지원**: 모든 Fetcher와 전용 비동기 세션 클래스에서 완전한 비동기를 지원합니다. ### 적응형 스크레이핑 & AI 통합 diff --git a/docs/README_RU.md b/docs/README_RU.md index 0fa251f..031c229 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -222,7 +222,8 @@ MySpider().start() - **Обход анти-ботов**: Расширенные возможности скрытности с `StealthyFetcher` и подмену fingerprint'ов. Может легко обойти все типы Cloudflare Turnstile/Interstitial с помощью автоматизации. - **Управление сессиями**: Поддержка постоянных сессий с классами `FetcherSession`, `StealthySession` и `DynamicSession` для управления cookie и состоянием между запросами. - **Ротация Proxy**: Встроенный `ProxyRotator` с циклической или пользовательскими стратегиями для всех типов сессий, а также переопределение Proxy для каждого запроса. -- **Блокировка доменов**: Блокируйте запросы к определённым доменам (и их поддоменам) в браузерных Fetcher'ах. +- **Блокировка доменов и рекламы**: Блокируйте запросы к определённым доменам (и их поддоменам) или включите встроенную блокировку рекламы (~3 500 известных рекламных/трекерных доменов) в браузерных Fetcher'ах. +- **Защита от утечки DNS**: Опциональная поддержка DNS-over-HTTPS для маршрутизации DNS-запросов через Cloudflare DoH, предотвращая утечку DNS при использовании прокси. - **Поддержка async**: Полная async-поддержка во всех Fetcher'ах и выделенных async-классах сессий. ### Адаптивный скрапинг и интеграция с ИИ diff --git a/docs/ai/mcp-server.md b/docs/ai/mcp-server.md index 27156af..08c7b61 100644 --- a/docs/ai/mcp-server.md +++ b/docs/ai/mcp-server.md @@ -33,6 +33,7 @@ The Scrapling MCP Server provides nine powerful tools for web scraping: - **Browser Impersonation**: Mimic real browsers with TLS fingerprinting, real browser headers matching that version, and more - **Parallel Processing**: Scrape multiple URLs concurrently for efficiency - **Session Persistence**: Reuse browser sessions across multiple requests for better performance +- **Ad Blocking**: All browser-based tools automatically block requests to ~3,500 known ad and tracker domains, saving tokens and speeding up page loads - **Prompt Injection Protection**: Automatic sanitization of hidden content (CSS-hidden elements, aria-hidden, zero-width characters, HTML comments, template tags) that could be used for prompt injection attacks #### But why use Scrapling MCP Server instead of other available tools? diff --git a/docs/cli/extract-commands.md b/docs/cli/extract-commands.md index fb0545c..671cdcc 100644 --- a/docs/cli/extract-commands.md +++ b/docs/cli/extract-commands.md @@ -24,7 +24,7 @@ The extract command is a set of simple terminal tools that: !!! tip "AI-Targeted Mode" - All extract commands support an `--ai-targeted` flag. When enabled, it extracts only the main body content, strips noise tags (script, style, noscript, svg), removes hidden elements that could be used for prompt injection (CSS-hidden, aria-hidden, template tags), strips zero-width unicode characters, and removes HTML comments. This is ideal when the output is destined for an AI model. + All extract commands support an `--ai-targeted` flag. When enabled, it extracts only the main body content, strips noise tags (script, style, noscript, svg), removes hidden elements that could be used for prompt injection (CSS-hidden, aria-hidden, template tags), strips zero-width unicode characters, and removes HTML comments. For browser commands (`fetch`/`stealthy-fetch`), it also automatically enables ad blocking. This is ideal when the output is destined for an AI model. ## Quick Start @@ -291,6 +291,8 @@ We will go through each command in detail below. --real-chrome/--no-real-chrome If you have a Chrome browser installed on your device, enable this, and the Fetcher will launch an instance of your browser and use it. (default: False) --proxy TEXT Proxy URL in format "http://username:password@host:port" -H, --extra-headers TEXT Extra headers in format "Key: Value" (can be used multiple times) + --dns-over-https / --no-dns-over-https Route DNS through Cloudflare's DoH to prevent DNS leaks when using proxies (default: False) + --block-ads / --no-block-ads Block requests to known ad and tracker domains (default: False) --ai-targeted Extract only main content and sanitize hidden elements for AI consumption (default: False) --help Show this message and exit. ``` @@ -337,6 +339,8 @@ We will go through each command in detail below. --hide-canvas / --show-canvas Add noise to canvas operations (default: False) --proxy TEXT Proxy URL in format "http://username:password@host:port" -H, --extra-headers TEXT Extra headers in format "Key: Value" (can be used multiple times) + --dns-over-https / --no-dns-over-https Route DNS through Cloudflare's DoH to prevent DNS leaks when using proxies (default: False) + --block-ads / --no-block-ads Block requests to known ad and tracker domains (default: False) --ai-targeted Extract only main content and sanitize hidden elements for AI consumption (default: False) --help Show this message and exit. ``` diff --git a/docs/index.md b/docs/index.md index ae41d6c..b1cf181 100644 --- a/docs/index.md +++ b/docs/index.md @@ -109,7 +109,8 @@ MySpider().start() - **Anti-bot Bypass**: Advanced stealth capabilities with `StealthyFetcher` and fingerprint spoofing. Can easily bypass all types of Cloudflare's Turnstile/Interstitial with automation. - **Session Management**: Persistent session support with `FetcherSession`, `StealthySession`, and `DynamicSession` classes for cookie and state management across requests. - **Proxy Rotation**: Built-in `ProxyRotator` with cyclic or custom rotation strategies across all session types, plus per-request proxy overrides. -- **Domain Blocking**: Block requests to specific domains (and their subdomains) in browser-based fetchers. +- **Domain & Ad Blocking**: Block requests to specific domains (and their subdomains) or enable built-in ad blocking (~3,500 known ad/tracker domains) in browser-based fetchers. +- **DNS Leak Prevention**: Optional DNS-over-HTTPS support to route DNS queries through Cloudflare's DoH, preventing DNS leaks when using proxies. - **Async Support**: Complete async support across all fetchers and dedicated async session classes. ### Adaptive Scraping & AI Integration From 887eeee4c2d68b31c5462ec7549f50a4a57f69be Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 12 Apr 2026 18:47:13 +0200 Subject: [PATCH 47/54] build: pump up version and deps --- pyproject.toml | 8 ++++---- scrapling/__init__.py | 2 +- server.json | 4 ++-- setup.cfg | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6ac5d9c..e8a5500 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "scrapling" # Static version instead of a dynamic version so we can get better layer caching while building docker, check the docker file to understand -version = "0.4.5" +version = "0.4.6" description = "Scrapling is an undetectable, powerful, flexible, high-performance Python library that makes Web Scraping easy and effortless as it should be!" readme = {file = "README.md", content-type = "text/markdown"} license = {file = "LICENSE"} @@ -61,7 +61,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "lxml>=6.0.2", + "lxml>=6.0.3", "cssselect>=1.4.0", "orjson>=3.11.8", "tld>=0.13.2", @@ -77,12 +77,12 @@ fetchers = [ "patchright==1.58.2", "browserforge>=1.2.4", "apify-fingerprint-datapoints>=0.12.0", - "msgspec>=0.20.0", + "msgspec>=0.21.0", "anyio>=4.12.1", "protego>=0.6.0", ] ai = [ - "mcp>=1.26.0", + "mcp>=1.27.0", "markdownify>=1.2.0", "scrapling[fetchers]", ] diff --git a/scrapling/__init__.py b/scrapling/__init__.py index a121db4..c0c66ff 100644 --- a/scrapling/__init__.py +++ b/scrapling/__init__.py @@ -1,5 +1,5 @@ __author__ = "Karim Shoair (karim.shoair@pm.me)" -__version__ = "0.4.5" +__version__ = "0.4.6" __copyright__ = "Copyright (c) 2024 Karim Shoair" from typing import Any, TYPE_CHECKING diff --git a/server.json b/server.json index d1a95ad..36f60e7 100644 --- a/server.json +++ b/server.json @@ -14,12 +14,12 @@ "mimeType": "image/png" } ], - "version": "0.4.5", + "version": "0.4.6", "packages": [ { "registryType": "pypi", "identifier": "scrapling", - "version": "0.4.5", + "version": "0.4.6", "runtimeHint": "uvx", "packageArguments": [ { diff --git a/setup.cfg b/setup.cfg index 48cddb3..0794d59 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = scrapling -version = 0.4.5 +version = 0.4.6 author = Karim Shoair author_email = karim.shoair@pm.me description = Scrapling is an undetectable, powerful, flexible, high-performance Python library that makes Web Scraping easy and effortless as it should be! From 4279ca8cbc4441a974c301042dc23493b96491db Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 12 Apr 2026 18:47:26 +0200 Subject: [PATCH 48/54] docs(agent): update skill with the latest changes --- agent-skill/Scrapling-Skill.zip | Bin 81526 -> 82913 bytes agent-skill/Scrapling-Skill/SKILL.md | 10 ++++++---- .../Scrapling-Skill/examples/README.md | 2 +- .../references/fetching/dynamic.md | 2 ++ .../references/fetching/stealthy.md | 2 ++ .../Scrapling-Skill/references/mcp-server.md | 4 ++++ 6 files changed, 15 insertions(+), 5 deletions(-) diff --git a/agent-skill/Scrapling-Skill.zip b/agent-skill/Scrapling-Skill.zip index c43d0df74a3310ba0889e6bc2f6d30f1c55b9d78..b8766e29220768219f31dd7a871e37031c2651c9 100644 GIT binary patch delta 25331 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 2. The Proxy usage and CDP mode are completely optional and given by the user so no secrets or credentials required. Depending on the user usage. > 3. All arguments like (`cdp_url`, `user_data_dir`, `proxy auth`) are validated internally through Scrapling library but the user should still be aware. -**IMPORTANT**: While using the commandline scraping commands, you MUST use the commandline argument `--ai-targeted` to protect from Prompt Injection! +**IMPORTANT**: While using the commandline scraping commands, you MUST use the commandline argument `--ai-targeted` to protect from Prompt Injection! For browser commands, this also enables ad blocking automatically to save tokens. ## Setup (once) Create a virtual Python environment through any way available, like `venv`, then inside the environment do: -`pip install "scrapling[all]>=0.4.5"` +`pip install "scrapling[all]>=0.4.6"` Then do this to download all the browsers' dependencies: @@ -156,7 +156,9 @@ Both (`fetch` / `stealthy-fetch`) share options: | --wait-selector | TEXT | CSS selector to wait for before proceeding | | --proxy | TEXT | Proxy URL in format "http://username:password@host:port" | | -H, --extra-headers | TEXT | Extra headers in format "Key: Value" (can be used multiple times) | -| --ai-targeted | None | Extract only main content and sanitize hidden elements for AI consumption (default: False) | +| --dns-over-https / --no-dns-over-https | None | Route DNS through Cloudflare's DoH to prevent DNS leaks when using proxies (default: False) | +| --block-ads / --no-block-ads | None | Block requests to ~3,500 known ad and tracker domains (default: False) | +| --ai-targeted | None | Extract only main content and sanitize hidden elements for AI consumption (default: False). Also enables ad blocking automatically. | This option is specific to `fetch` only: diff --git a/agent-skill/Scrapling-Skill/examples/README.md b/agent-skill/Scrapling-Skill/examples/README.md index 344dc0f..4f645cd 100644 --- a/agent-skill/Scrapling-Skill/examples/README.md +++ b/agent-skill/Scrapling-Skill/examples/README.md @@ -9,7 +9,7 @@ All examples collect **all 100 quotes across 10 pages**. Make sure Scrapling is installed: ```bash -pip install "scrapling[all]>=0.4.5" +pip install "scrapling[all]>=0.4.6" scrapling install --force ``` diff --git a/agent-skill/Scrapling-Skill/references/fetching/dynamic.md b/agent-skill/Scrapling-Skill/references/fetching/dynamic.md index 109fde6..f623521 100644 --- a/agent-skill/Scrapling-Skill/references/fetching/dynamic.md +++ b/agent-skill/Scrapling-Skill/references/fetching/dynamic.md @@ -76,6 +76,8 @@ All arguments for `DynamicFetcher` and its session classes: | additional_args | Additional arguments to be passed to Playwright's context as additional settings, and they take higher priority than Scrapling's settings. | ✔️ | | selector_config | A dictionary of custom parsing arguments to be used when creating the final `Selector`/`Response` class. | ✔️ | | blocked_domains | A set of domain names to block requests to. Subdomains are also matched (e.g., `"example.com"` blocks `"sub.example.com"` too). | ✔️ | +| block_ads | Block requests to ~3,500 known ad/tracking domains. Can be combined with `blocked_domains`. | ✔️ | +| dns_over_https | Route DNS queries through Cloudflare's DNS-over-HTTPS to prevent DNS leaks when using proxies. | ✔️ | | proxy_rotator | A `ProxyRotator` instance for automatic proxy rotation. Cannot be combined with `proxy`. | ✔️ | | retries | Number of retry attempts for failed requests. Defaults to 3. | ✔️ | | retry_delay | Seconds to wait between retry attempts. Defaults to 1. | ✔️ | diff --git a/agent-skill/Scrapling-Skill/references/fetching/stealthy.md b/agent-skill/Scrapling-Skill/references/fetching/stealthy.md index aea8475..7f3c896 100644 --- a/agent-skill/Scrapling-Skill/references/fetching/stealthy.md +++ b/agent-skill/Scrapling-Skill/references/fetching/stealthy.md @@ -58,6 +58,8 @@ Scrapling provides many options with this fetcher and its session classes. Befor | additional_args | Additional arguments to be passed to Playwright's context as additional settings, and they take higher priority than Scrapling's settings. | ✔️ | | selector_config | A dictionary of custom parsing arguments to be used when creating the final `Selector`/`Response` class. | ✔️ | | blocked_domains | A set of domain names to block requests to. Subdomains are also matched (e.g., `"example.com"` blocks `"sub.example.com"` too). | ✔️ | +| block_ads | Block requests to ~3,500 known ad/tracking domains. Can be combined with `blocked_domains`. | ✔️ | +| dns_over_https | Route DNS queries through Cloudflare's DNS-over-HTTPS to prevent DNS leaks when using proxies. | ✔️ | | proxy_rotator | A `ProxyRotator` instance for automatic proxy rotation. Cannot be combined with `proxy`. | ✔️ | | retries | Number of retry attempts for failed requests. Defaults to 3. | ✔️ | | retry_delay | Seconds to wait between retry attempts. Defaults to 1. | ✔️ | diff --git a/agent-skill/Scrapling-Skill/references/mcp-server.md b/agent-skill/Scrapling-Skill/references/mcp-server.md index 7bbc86b..a860f0a 100644 --- a/agent-skill/Scrapling-Skill/references/mcp-server.md +++ b/agent-skill/Scrapling-Skill/references/mcp-server.md @@ -164,6 +164,10 @@ When `main_content_only=true` (the default), the server automatically sanitizes Keep `main_content_only=true` for maximum protection. +## Ad blocking + +All browser-based tools (`fetch`, `bulk_fetch`, `stealthy_fetch`, `bulk_stealthy_fetch`) and persistent sessions (`open_session`) automatically block requests to ~3,500 known ad and tracker domains. This is always enabled in the MCP server to save tokens and speed up page loads. No configuration needed. + ## Setup Start the server (stdio transport, used by most MCP clients): From d19e861e45b078dfa78eb00deba69f61c039a28a Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 13 Apr 2026 01:08:53 +0200 Subject: [PATCH 49/54] fix: correct Seconds type alias to accept float values The Seconds type was defined as `Annotated[int, float, Meta(ge=0)]` which per PEP 593 treated float as metadata, not a type. This caused passing float values like wait=1.5 to be rejected. Fixed by using Annotated[float, Meta(ge=0)] since int is a subtype of float. Correct fix for #240 Co-Authored-By: Cocoon-Break <54054995+kuishou68@users.noreply.github.com> --- scrapling/engines/_browsers/_validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapling/engines/_browsers/_validators.py b/scrapling/engines/_browsers/_validators.py index f7bb34e..504ba78 100644 --- a/scrapling/engines/_browsers/_validators.py +++ b/scrapling/engines/_browsers/_validators.py @@ -53,7 +53,7 @@ def _is_invalid_cdp_url(cdp_url: str) -> bool | str: # Type aliases for cleaner annotations PagesCount = Annotated[int, Meta(ge=1, le=50)] RetriesCount = Annotated[int, Meta(ge=1, le=10)] -Seconds = Annotated[int, float, Meta(ge=0)] +Seconds = Annotated[float, Meta(ge=0)] class PlaywrightConfig(Struct, kw_only=True, frozen=False, weakref=True): From 5e13d3ece6d93d519f4a8cee83e95d7816400879 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 13 Apr 2026 03:57:27 +0200 Subject: [PATCH 50/54] feat(browsers): add a pre-navigation hook to allow page setup Solves #238 --- scrapling/core/_shell_signatures.py | 2 ++ scrapling/engines/_browsers/_controllers.py | 24 +++++++++++++++++---- scrapling/engines/_browsers/_stealth.py | 24 +++++++++++++++++---- scrapling/engines/_browsers/_types.py | 2 ++ scrapling/engines/_browsers/_validators.py | 4 ++++ scrapling/fetchers/chrome.py | 6 ++++-- scrapling/fetchers/stealth_chrome.py | 6 ++++-- 7 files changed, 56 insertions(+), 12 deletions(-) diff --git a/scrapling/core/_shell_signatures.py b/scrapling/core/_shell_signatures.py index b2340fb..00f1fe9 100644 --- a/scrapling/core/_shell_signatures.py +++ b/scrapling/core/_shell_signatures.py @@ -47,6 +47,7 @@ _FETCH_PARAMS = { "wait": int | float, "timezone_id": str | None, "page_action": Optional[Callable], + "page_setup": Optional[Callable], "proxy": Optional[str | Dict[str, str] | Tuple], "extra_headers": Optional[Dict[str, str]], "timeout": int | float, @@ -80,6 +81,7 @@ _STEALTHY_FETCH_PARAMS = { "wait": int | float, "timezone_id": str | None, "page_action": Optional[Callable], + "page_setup": Optional[Callable], "proxy": Optional[str | Dict[str, str] | Tuple], "extra_headers": Optional[Dict[str, str]], "timeout": int | float, diff --git a/scrapling/engines/_browsers/_controllers.py b/scrapling/engines/_browsers/_controllers.py index ce4b643..edabb17 100644 --- a/scrapling/engines/_browsers/_controllers.py +++ b/scrapling/engines/_browsers/_controllers.py @@ -47,7 +47,8 @@ class DynamicSession(SyncSession, DynamicSessionMixin): :param network_idle: Wait for the page until there are no network connections for at least 500 ms. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000 :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object. - :param page_action: Added for automation. A function that takes the `page` object and does the automation you need. + :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need. + :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. :param wait_selector: Wait for a specific CSS selector to be in a specific state. :param init_script: An absolute path to a JavaScript file to be executed on page creation for all pages in this session. :param locale: Specify user locale, for example, `en-GB`, `de-DE`, etc. Locale will affect navigator.language value, Accept-Language request header value as well as number and date formatting @@ -105,7 +106,8 @@ class DynamicSession(SyncSession, DynamicSessionMixin): :param google_search: Enabled by default, Scrapling will set a Google referer header. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000 :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object. - :param page_action: Added for automation. A function that takes the `page` object and does the automation you need. + :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need. + :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. :param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._ :param disable_resources: Drop requests for unnecessary resources for a speed boost. Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`. @@ -152,6 +154,12 @@ class DynamicSession(SyncSession, DynamicSessionMixin): ), ) + if params.page_setup: + try: + params.page_setup(page) + except Exception as e: # pragma: no cover + log.error(f"Error executing page_setup: {e}") + try: first_response = page.goto(url, referer=referer) self._wait_for_page_stability(page, params.load_dom, params.network_idle) @@ -228,7 +236,8 @@ class AsyncDynamicSession(AsyncSession, DynamicSessionMixin): :param load_dom: Enabled by default, wait for all JavaScript on page(s) to fully load and execute. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000 :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object. - :param page_action: Added for automation. A function that takes the `page` object and does the automation you need. + :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need. + :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. :param wait_selector: Wait for a specific CSS selector to be in a specific state. :param init_script: An absolute path to a JavaScript file to be executed on page creation for all pages in this session. :param locale: Specify user locale, for example, `en-GB`, `de-DE`, etc. Locale will affect navigator.language value, Accept-Language request header value as well as number and date formatting @@ -285,7 +294,8 @@ class AsyncDynamicSession(AsyncSession, DynamicSessionMixin): :param google_search: Enabled by default, Scrapling will set a Google referer header. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000 :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object. - :param page_action: Added for automation. A function that takes the `page` object and does the automation you need. + :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need. + :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. :param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._ :param disable_resources: Drop requests for unnecessary resources for a speed boost. Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`. @@ -333,6 +343,12 @@ class AsyncDynamicSession(AsyncSession, DynamicSessionMixin): ), ) + if params.page_setup: + try: + await params.page_setup(page) + except Exception as e: # pragma: no cover + log.error(f"Error executing page_setup: {e}") + try: first_response = await page.goto(url, referer=referer) await self._wait_for_page_stability(page, params.load_dom, params.network_idle) diff --git a/scrapling/engines/_browsers/_stealth.py b/scrapling/engines/_browsers/_stealth.py index f06c62e..efa195e 100644 --- a/scrapling/engines/_browsers/_stealth.py +++ b/scrapling/engines/_browsers/_stealth.py @@ -47,7 +47,8 @@ class StealthySession(SyncSession, StealthySessionMixin): :param network_idle: Wait for the page until there are no network connections for at least 500 ms. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000 :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object. - :param page_action: Added for automation. A function that takes the `page` object and does the automation you need. + :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need. + :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. :param wait_selector: Wait for a specific CSS selector to be in a specific state. :param init_script: An absolute path to a JavaScript file to be executed on page creation for all pages in this session. :param locale: Specify user locale, for example, `en-GB`, `de-DE`, etc. Locale will affect navigator.language value, Accept-Language request header value as well as number and date formatting @@ -187,7 +188,8 @@ class StealthySession(SyncSession, StealthySessionMixin): :param google_search: Enabled by default, Scrapling will set a Google referer header. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000 :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object. - :param page_action: Added for automation. A function that takes the `page` object and does the automation you need. + :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need. + :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. :param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._ :param disable_resources: Drop requests for unnecessary resources for a speed boost. Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`. @@ -235,6 +237,12 @@ class StealthySession(SyncSession, StealthySessionMixin): ), ) + if params.page_setup: + try: + params.page_setup(page) + except Exception as e: # pragma: no cover + log.error(f"Error executing page_setup: {e}") + try: first_response = page.goto(url, referer=referer) self._wait_for_page_stability(page, params.load_dom, params.network_idle) @@ -315,7 +323,8 @@ class AsyncStealthySession(AsyncSession, StealthySessionMixin): :param network_idle: Wait for the page until there are no network connections for at least 500 ms. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000 :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object. - :param page_action: Added for automation. A function that takes the `page` object and does the automation you need. + :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need. + :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. :param wait_selector: Wait for a specific CSS selector to be in a specific state. :param init_script: An absolute path to a JavaScript file to be executed on page creation for all pages in this session. :param locale: Specify user locale, for example, `en-GB`, `de-DE`, etc. Locale will affect navigator.language value, Accept-Language request header value as well as number and date formatting @@ -454,7 +463,8 @@ class AsyncStealthySession(AsyncSession, StealthySessionMixin): :param google_search: Enabled by default, Scrapling will set a Google referer header. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000 :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object. - :param page_action: Added for automation. A function that takes the `page` object and does the automation you need. + :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need. + :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. :param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._ :param disable_resources: Drop requests for unnecessary resources for a speed boost. Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`. @@ -503,6 +513,12 @@ class AsyncStealthySession(AsyncSession, StealthySessionMixin): ), ) + if params.page_setup: + try: + await params.page_setup(page) + except Exception as e: # pragma: no cover + log.error(f"Error executing page_setup: {e}") + try: first_response = await page.goto(url, referer=referer) await self._wait_for_page_stability(page, params.load_dom, params.network_idle) diff --git a/scrapling/engines/_browsers/_types.py b/scrapling/engines/_browsers/_types.py index 5030480..b436c59 100644 --- a/scrapling/engines/_browsers/_types.py +++ b/scrapling/engines/_browsers/_types.py @@ -74,6 +74,7 @@ class PlaywrightSession(TypedDict, total=False): wait: int | float timezone_id: str | None page_action: Optional[Callable] + page_setup: Optional[Callable] proxy: Optional[str | Dict[str, str] | Tuple] proxy_rotator: Optional[ProxyRotator] extra_headers: Optional[Dict[str, str]] @@ -105,6 +106,7 @@ class PlaywrightFetchParams(TypedDict, total=False): disable_resources: bool wait_selector: Optional[str] page_action: Optional[Callable] + page_setup: Optional[Callable] selector_config: Optional[Dict] extra_headers: Optional[Dict[str, str]] wait_selector_state: SelectorWaitStates diff --git a/scrapling/engines/_browsers/_validators.py b/scrapling/engines/_browsers/_validators.py index 504ba78..77dc81d 100644 --- a/scrapling/engines/_browsers/_validators.py +++ b/scrapling/engines/_browsers/_validators.py @@ -71,6 +71,7 @@ class PlaywrightConfig(Struct, kw_only=True, frozen=False, weakref=True): wait: Seconds = 0 timezone_id: str | None = "" page_action: Optional[Callable] = None + page_setup: Optional[Callable] = None proxy: Optional[str | Dict[str, str] | Tuple] = None # The default value for proxy in Playwright's source is `None` proxy_rotator: Optional[ProxyRotator] = None extra_headers: Optional[Dict[str, str]] = None @@ -96,6 +97,8 @@ class PlaywrightConfig(Struct, kw_only=True, frozen=False, weakref=True): """Custom validation after msgspec validation""" if self.page_action and not callable(self.page_action): raise TypeError(f"page_action must be callable, got {type(self.page_action).__name__}") + if self.page_setup and not callable(self.page_setup): + raise TypeError(f"page_setup must be callable, got {type(self.page_setup).__name__}") if self.proxy and self.proxy_rotator: raise ValueError( "Cannot use 'proxy_rotator' together with 'proxy'. " @@ -160,6 +163,7 @@ class _fetch_params: timeout: Seconds wait: Seconds page_action: Optional[Callable] + page_setup: Optional[Callable] extra_headers: Optional[Dict[str, str]] disable_resources: bool wait_selector: Optional[str] diff --git a/scrapling/fetchers/chrome.py b/scrapling/fetchers/chrome.py index f7795b7..3c2efcf 100644 --- a/scrapling/fetchers/chrome.py +++ b/scrapling/fetchers/chrome.py @@ -23,7 +23,8 @@ class DynamicFetcher(BaseFetcher): :param load_dom: Enabled by default, wait for all JavaScript on page(s) to fully load and execute. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000 :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the Response object. - :param page_action: Added for automation. A function that takes the `page` object and does the automation you need. + :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need. + :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. :param wait_selector: Wait for a specific CSS selector to be in a specific state. :param init_script: An absolute path to a JavaScript file to be executed on page creation with this request. :param locale: Set the locale for the browser if wanted. Defaults to the system default locale. @@ -65,7 +66,8 @@ class DynamicFetcher(BaseFetcher): :param load_dom: Enabled by default, wait for all JavaScript on page(s) to fully load and execute. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000 :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the Response object. - :param page_action: Added for automation. A function that takes the `page` object and does the automation you need. + :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need. + :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. :param wait_selector: Wait for a specific CSS selector to be in a specific state. :param init_script: An absolute path to a JavaScript file to be executed on page creation with this request. :param locale: Set the locale for the browser if wanted. Defaults to the system default locale. diff --git a/scrapling/fetchers/stealth_chrome.py b/scrapling/fetchers/stealth_chrome.py index 70ce937..ad6e01f 100644 --- a/scrapling/fetchers/stealth_chrome.py +++ b/scrapling/fetchers/stealth_chrome.py @@ -27,7 +27,8 @@ class StealthyFetcher(BaseFetcher): :param network_idle: Wait for the page until there are no network connections for at least 500 ms. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000 :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object. - :param page_action: Added for automation. A function that takes the `page` object and does the automation you need. + :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need. + :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. :param wait_selector: Wait for a specific CSS selector to be in a specific state. :param init_script: An absolute path to a JavaScript file to be executed on page creation for all pages in this session. :param locale: Specify user locale, for example, `en-GB`, `de-DE`, etc. Locale will affect navigator.language value, Accept-Language request header value as well as number and date formatting @@ -78,7 +79,8 @@ class StealthyFetcher(BaseFetcher): :param network_idle: Wait for the page until there are no network connections for at least 500 ms. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000 :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object. - :param page_action: Added for automation. A function that takes the `page` object and does the automation you need. + :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need. + :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. :param wait_selector: Wait for a specific CSS selector to be in a specific state. :param init_script: An absolute path to a JavaScript file to be executed on page creation for all pages in this session. :param locale: Specify user locale, for example, `en-GB`, `de-DE`, etc. Locale will affect navigator.language value, Accept-Language request header value as well as number and date formatting From 158ff04c968a81d6a04f1c06cbdd62572c2408f3 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 13 Apr 2026 04:38:23 +0200 Subject: [PATCH 51/54] docs: add the new page_setup feature --- docs/fetching/dynamic.md | 28 ++++++++++++++++++++++++++-- docs/fetching/stealthy.md | 5 +++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/fetching/dynamic.md b/docs/fetching/dynamic.md index e8ce9db..2e1f537 100644 --- a/docs/fetching/dynamic.md +++ b/docs/fetching/dynamic.md @@ -72,7 +72,8 @@ Scrapling provides many options with this fetcher and its session classes. To ma | load_dom | Enabled by default, wait for all JavaScript on page(s) to fully load and execute (wait for the `domcontentloaded` state). | ✔️ | | timeout | The timeout (milliseconds) used in all operations and waits through the page. The default is 30,000 ms (30 seconds). | ✔️ | | wait | The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the `Response` object. | ✔️ | -| page_action | Added for automation. Pass a function that takes the `page` object and does the necessary automation. | ✔️ | +| page_action | Added for automation. Pass a function that takes the `page` object, runs after navigation, and does the necessary automation. | ✔️ | +| page_setup | A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. | ✔️ | | wait_selector | Wait for a specific css selector to be in a specific state. | ✔️ | | init_script | An absolute path to a JavaScript file to be executed on page creation for all pages in this session. | ✔️ | | wait_selector_state | Scrapling will wait for the given state to be fulfilled for the selector given with `wait_selector`. _Default state is `attached`._ | ✔️ | @@ -96,7 +97,7 @@ Scrapling provides many options with this fetcher and its session classes. To ma | capture_xhr | Pass a regex URL pattern string to capture XHR/fetch requests matching it during page load. Captured responses are available via `response.captured_xhr`. Defaults to `None` (disabled). | ✔️ | | executable_path | Absolute path to a custom browser executable to use instead of the bundled Chromium. Useful for non-standard installations or custom browser builds. | ✔️ | -In session classes, all these arguments can be set globally for the session. Still, you can configure each request individually by passing some of the arguments here that can be configured on the browser tab level like: `google_search`, `timeout`, `wait`, `page_action`, `extra_headers`, `disable_resources`, `wait_selector`, `wait_selector_state`, `network_idle`, `load_dom`, `blocked_domains`, `proxy`, and `selector_config`. +In session classes, all these arguments can be set globally for the session. Still, you can configure each request individually by passing some of the arguments here that can be configured on the browser tab level like: `google_search`, `timeout`, `wait`, `page_action`, `page_setup`, `extra_headers`, `disable_resources`, `wait_selector`, `wait_selector_state`, `network_idle`, `load_dom`, `blocked_domains`, `proxy`, and `selector_config`. !!! note "Notes:" @@ -172,6 +173,29 @@ with open(file='main_cover.png', mode='wb') as f: The `body` attribute of the `Response` object always returns `bytes`. +### Pre-Navigation Setup +If you need to set up event listeners, routes, or scripts that must be registered before the page navigates, use `page_setup`. This function receives the `page` object and runs before `page.goto()` is called. + +```python +from playwright.sync_api import Page + +def capture_websockets(page: Page): + page.on("websocket", lambda ws: print(f"WebSocket opened: {ws.url}")) + +page = DynamicFetcher.fetch('https://example.com', page_setup=capture_websockets) +``` +Async version: +```python +from playwright.async_api import Page + +async def capture_websockets(page: Page): + page.on("websocket", lambda ws: print(f"WebSocket opened: {ws.url}")) + +page = await DynamicFetcher.async_fetch('https://example.com', page_setup=capture_websockets) +``` + +You can combine it with `page_action` -- `page_setup` runs before navigation, `page_action` runs after. + ### Browser Automation This is where your knowledge about [Playwright's Page API](https://playwright.dev/python/docs/api/class-page) comes into play. The function you pass here takes the page object from Playwright's API, performs the desired action, and then the fetcher continues. diff --git a/docs/fetching/stealthy.md b/docs/fetching/stealthy.md index 5b7c042..8daf0b0 100644 --- a/docs/fetching/stealthy.md +++ b/docs/fetching/stealthy.md @@ -49,7 +49,8 @@ Scrapling provides many options with this fetcher and its session classes. Befor | load_dom | Enabled by default, wait for all JavaScript on page(s) to fully load and execute (wait for the `domcontentloaded` state). | ✔️ | | timeout | The timeout (milliseconds) used in all operations and waits through the page. The default is 30,000 ms (30 seconds). | ✔️ | | wait | The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the `Response` object. | ✔️ | -| page_action | Added for automation. Pass a function that takes the `page` object and does the necessary automation. | ✔️ | +| page_action | Added for automation. Pass a function that takes the `page` object, runs after navigation, and does the necessary automation. | ✔️ | +| page_setup | A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. | ✔️ | | wait_selector | Wait for a specific css selector to be in a specific state. | ✔️ | | init_script | An absolute path to a JavaScript file to be executed on page creation for all pages in this session. | ✔️ | | wait_selector_state | Scrapling will wait for the given state to be fulfilled for the selector given with `wait_selector`. _Default state is `attached`._ | ✔️ | @@ -77,7 +78,7 @@ Scrapling provides many options with this fetcher and its session classes. Befor | capture_xhr | Pass a regex URL pattern string to capture XHR/fetch requests matching it during page load. Captured responses are available via `response.captured_xhr`. Defaults to `None` (disabled). | ✔️ | | executable_path | Absolute path to a custom browser executable to use instead of the bundled Chromium. Useful for non-standard installations or custom browser builds. | ✔️ | -In session classes, all these arguments can be set globally for the session. Still, you can configure each request individually by passing some of the arguments here that can be configured on the browser tab level like: `google_search`, `timeout`, `wait`, `page_action`, `extra_headers`, `disable_resources`, `wait_selector`, `wait_selector_state`, `network_idle`, `load_dom`, `solve_cloudflare`, `blocked_domains`, `proxy`, and `selector_config`. +In session classes, all these arguments can be set globally for the session. Still, you can configure each request individually by passing some of the arguments here that can be configured on the browser tab level like: `google_search`, `timeout`, `wait`, `page_action`, `page_setup`, `extra_headers`, `disable_resources`, `wait_selector`, `wait_selector_state`, `network_idle`, `load_dom`, `solve_cloudflare`, `blocked_domains`, `proxy`, and `selector_config`. !!! note "Notes:" From c2e66ab75351d35b52f32453da618d16daea43fc Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 13 Apr 2026 04:39:01 +0200 Subject: [PATCH 52/54] docs(agent): update skill with the latest changes --- agent-skill/Scrapling-Skill.zip | Bin 82913 -> 83257 bytes .../references/fetching/dynamic.md | 28 ++++++++++++++++-- .../references/fetching/stealthy.md | 5 ++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/agent-skill/Scrapling-Skill.zip b/agent-skill/Scrapling-Skill.zip index b8766e29220768219f31dd7a871e37031c2651c9..220183b517e707516db32418604807b6ba9e6382 100644 GIT binary patch delta 20721 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
6&vJ7 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 ZKe-p`E(r9`tRyaOdwLMa;~H@B^lx&^pvwRN diff --git a/agent-skill/Scrapling-Skill/references/fetching/dynamic.md b/agent-skill/Scrapling-Skill/references/fetching/dynamic.md index f623521..a831d7c 100644 --- a/agent-skill/Scrapling-Skill/references/fetching/dynamic.md +++ b/agent-skill/Scrapling-Skill/references/fetching/dynamic.md @@ -60,7 +60,8 @@ All arguments for `DynamicFetcher` and its session classes: | load_dom | Enabled by default, wait for all JavaScript on page(s) to fully load and execute (wait for the `domcontentloaded` state). | ✔️ | | timeout | The timeout (milliseconds) used in all operations and waits through the page. The default is 30,000 ms (30 seconds). | ✔️ | | wait | The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the `Response` object. | ✔️ | -| page_action | Added for automation. Pass a function that takes the `page` object and does the necessary automation. | ✔️ | +| page_action | Added for automation. Pass a function that takes the `page` object, runs after navigation, and does the necessary automation. | ✔️ | +| page_setup | A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. | ✔️ | | wait_selector | Wait for a specific css selector to be in a specific state. | ✔️ | | init_script | An absolute path to a JavaScript file to be executed on page creation for all pages in this session. | ✔️ | | wait_selector_state | Scrapling will wait for the given state to be fulfilled for the selector given with `wait_selector`. _Default state is `attached`._ | ✔️ | @@ -84,7 +85,7 @@ All arguments for `DynamicFetcher` and its session classes: | capture_xhr | Pass a regex URL pattern string to capture XHR/fetch requests matching it during page load. Captured responses are available via `response.captured_xhr`. Defaults to `None` (disabled). | ✔️ | | executable_path | Absolute path to a custom browser executable to use instead of the bundled Chromium. Useful for non-standard installations or custom browser builds. | ✔️ | -In session classes, all these arguments can be set globally for the session. Still, you can configure each request individually by passing some of the arguments here that can be configured on the browser tab level like: `google_search`, `timeout`, `wait`, `page_action`, `extra_headers`, `disable_resources`, `wait_selector`, `wait_selector_state`, `network_idle`, `load_dom`, `blocked_domains`, `proxy`, and `selector_config`. +In session classes, all these arguments can be set globally for the session. Still, you can configure each request individually by passing some of the arguments here that can be configured on the browser tab level like: `google_search`, `timeout`, `wait`, `page_action`, `page_setup`, `extra_headers`, `disable_resources`, `wait_selector`, `wait_selector_state`, `network_idle`, `load_dom`, `blocked_domains`, `proxy`, and `selector_config`. **Notes:** 1. The `disable_resources` option made requests ~25% faster in tests for some websites and can help save proxy usage, but be careful with it, as it can cause some websites to never finish loading. @@ -156,6 +157,29 @@ with open(file='main_cover.png', mode='wb') as f: The `body` attribute of the `Response` object always returns `bytes`. +### Pre-Navigation Setup +If you need to set up event listeners, routes, or scripts that must be registered before the page navigates, use `page_setup`. This function receives the `page` object and runs before `page.goto()` is called. + +```python +from playwright.sync_api import Page + +def capture_websockets(page: Page): + page.on("websocket", lambda ws: print(f"WebSocket opened: {ws.url}")) + +page = DynamicFetcher.fetch('https://example.com', page_setup=capture_websockets) +``` +Async version: +```python +from playwright.async_api import Page + +async def capture_websockets(page: Page): + page.on("websocket", lambda ws: print(f"WebSocket opened: {ws.url}")) + +page = await DynamicFetcher.async_fetch('https://example.com', page_setup=capture_websockets) +``` + +You can combine it with `page_action` -- `page_setup` runs before navigation, `page_action` runs after. + ### Browser Automation This is where your knowledge about [Playwright's Page API](https://playwright.dev/python/docs/api/class-page) comes into play. The function you pass here takes the page object from Playwright's API, performs the desired action, and then the fetcher continues. diff --git a/agent-skill/Scrapling-Skill/references/fetching/stealthy.md b/agent-skill/Scrapling-Skill/references/fetching/stealthy.md index 7f3c896..8939c8d 100644 --- a/agent-skill/Scrapling-Skill/references/fetching/stealthy.md +++ b/agent-skill/Scrapling-Skill/references/fetching/stealthy.md @@ -38,7 +38,8 @@ Scrapling provides many options with this fetcher and its session classes. Befor | load_dom | Enabled by default, wait for all JavaScript on page(s) to fully load and execute (wait for the `domcontentloaded` state). | ✔️ | | timeout | The timeout (milliseconds) used in all operations and waits through the page. The default is 30,000 ms (30 seconds). | ✔️ | | wait | The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the `Response` object. | ✔️ | -| page_action | Added for automation. Pass a function that takes the `page` object and does the necessary automation. | ✔️ | +| page_action | Added for automation. Pass a function that takes the `page` object, runs after navigation, and does the necessary automation. | ✔️ | +| page_setup | A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads. | ✔️ | | wait_selector | Wait for a specific css selector to be in a specific state. | ✔️ | | init_script | An absolute path to a JavaScript file to be executed on page creation for all pages in this session. | ✔️ | | wait_selector_state | Scrapling will wait for the given state to be fulfilled for the selector given with `wait_selector`. _Default state is `attached`._ | ✔️ | @@ -66,7 +67,7 @@ Scrapling provides many options with this fetcher and its session classes. Befor | capture_xhr | Pass a regex URL pattern string to capture XHR/fetch requests matching it during page load. Captured responses are available via `response.captured_xhr`. Defaults to `None` (disabled). | ✔️ | | executable_path | Absolute path to a custom browser executable to use instead of the bundled Chromium. Useful for non-standard installations or custom browser builds. | ✔️ | -In session classes, all these arguments can be set globally for the session. Still, you can configure each request individually by passing some of the arguments here that can be configured on the browser tab level like: `google_search`, `timeout`, `wait`, `page_action`, `extra_headers`, `disable_resources`, `wait_selector`, `wait_selector_state`, `network_idle`, `load_dom`, `solve_cloudflare`, `blocked_domains`, `proxy`, and `selector_config`. +In session classes, all these arguments can be set globally for the session. Still, you can configure each request individually by passing some of the arguments here that can be configured on the browser tab level like: `google_search`, `timeout`, `wait`, `page_action`, `page_setup`, `extra_headers`, `disable_resources`, `wait_selector`, `wait_selector_state`, `network_idle`, `load_dom`, `solve_cloudflare`, `blocked_domains`, `proxy`, and `selector_config`. **Notes:** From 00897dad2f60dc632e4f33972e7ae0c0889b1fff Mon Sep 17 00:00:00 2001 From: Jules Omlor Date: Tue, 14 Apr 2026 01:17:52 -0400 Subject: [PATCH 53/54] feat(mcp): add optional session_id parameter to open_session Allow users to specify a custom session_id when opening a browser session, rather than always generating a random UUID. Useful for naming sessions for easier management across multiple tool calls. - Add session_id: Optional[str] = None parameter - Validate session_id doesn't already exist before starting browser - Fall back to uuid4().hex[:12] if not provided - Add tests for custom session_id and duplicate detection Co-Authored-By: Claude Opus 4.5 --- scrapling/core/ai.py | 7 ++++++- tests/ai/test_ai_mcp.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scrapling/core/ai.py b/scrapling/core/ai.py index 060cb20..7292a83 100644 --- a/scrapling/core/ai.py +++ b/scrapling/core/ai.py @@ -123,6 +123,7 @@ class ScraplingMCPServer: async def open_session( self, session_type: SessionType, + session_id: Optional[str] = None, headless: bool = True, google_search: bool = True, real_chrome: bool = False, @@ -152,6 +153,7 @@ class ScraplingMCPServer: Use close_session to close the session when done, and list_sessions to see all active sessions. :param session_type: The type of session to open. Use "dynamic" for standard Playwright browser, or "stealthy" for anti-bot bypass with fingerprint spoofing. + :param session_id: Optional custom session ID. If not provided, a random 12-character hex ID will be generated. Useful for naming sessions for easier management. :param headless: Run the browser in headless/hidden (default), or headful/visible mode. :param google_search: Enabled by default, Scrapling will set a Google referer header. :param real_chrome: If you have a Chrome browser installed on your device, enable this, and the Fetcher will launch an instance of your browser and use it. @@ -175,6 +177,10 @@ class ScraplingMCPServer: :param solve_cloudflare: (Stealthy only) Solves all types of the Cloudflare's Turnstile/Interstitial challenges. :param additional_args: (Stealthy only) Additional arguments to be passed to Playwright's context as additional settings. """ + session_id = session_id or uuid4().hex[:12] + if session_id in self._sessions: + raise ValueError(f"Session '{session_id}' already exists. Use a different ID or close the existing session first.") + common_kwargs: Dict[str, Any] = dict( wait=wait, proxy=proxy, @@ -211,7 +217,6 @@ class ScraplingMCPServer: await session.start() - session_id = uuid4().hex[:12] entry = _SessionEntry(session=session, session_type=session_type) self._sessions[session_id] = entry diff --git a/tests/ai/test_ai_mcp.py b/tests/ai/test_ai_mcp.py index d897bb5..4806088 100644 --- a/tests/ai/test_ai_mcp.py +++ b/tests/ai/test_ai_mcp.py @@ -177,6 +177,25 @@ class TestSessionManagement: with pytest.raises(ValueError, match="not found"): await server.fetch(url=test_url, session_id=session_id) + @pytest.mark.asyncio + async def test_open_session_with_custom_id(self, server): + """Test opening a session with a custom session_id""" + result = await server.open_session(session_type="dynamic", session_id="my-session", headless=True) + assert isinstance(result, SessionCreatedModel) + assert result.session_id == "my-session" + + await server.close_session("my-session") + + @pytest.mark.asyncio + async def test_open_session_duplicate_id_raises(self, server): + """Test that opening a session with a duplicate session_id raises an error""" + await server.open_session(session_type="dynamic", session_id="dupe", headless=True) + + with pytest.raises(ValueError, match="already exists"): + await server.open_session(session_type="dynamic", session_id="dupe", headless=True) + + await server.close_session("dupe") + class TestNormalizeCredentials: """Test the _normalize_credentials helper""" From 614d136f8cb9f0c31e2720189f25b26472c42d68 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Wed, 15 Apr 2026 20:44:38 +0200 Subject: [PATCH 54/54] build: pump up version and deps --- agent-skill/Scrapling-Skill/SKILL.md | 4 ++-- agent-skill/Scrapling-Skill/examples/README.md | 2 +- pyproject.toml | 6 +++--- scrapling/__init__.py | 2 +- scrapling/core/ai.py | 4 +++- server.json | 4 ++-- setup.cfg | 2 +- 7 files changed, 13 insertions(+), 11 deletions(-) diff --git a/agent-skill/Scrapling-Skill/SKILL.md b/agent-skill/Scrapling-Skill/SKILL.md index e1fc735..2cd1f84 100644 --- a/agent-skill/Scrapling-Skill/SKILL.md +++ b/agent-skill/Scrapling-Skill/SKILL.md @@ -1,7 +1,7 @@ --- name: scrapling-official description: Scrape web pages using Scrapling with anti-bot bypass (like Cloudflare Turnstile), stealth headless browsing, spiders framework, adaptive scraping, and JavaScript rendering. Use when asked to scrape, crawl, or extract data from websites; web_fetch fails; the site has anti-bot protections; write Python code to scrape/crawl; or write spiders. -version: "0.4.6" +version: "0.4.7" license: Complete terms in LICENSE.txt metadata: homepage: "https://scrapling.readthedocs.io/en/latest/index.html" @@ -40,7 +40,7 @@ Blazing fast crawls with real-time stats and streaming. Built by Web Scrapers fo Create a virtual Python environment through any way available, like `venv`, then inside the environment do: -`pip install "scrapling[all]>=0.4.6"` +`pip install "scrapling[all]>=0.4.7"` Then do this to download all the browsers' dependencies: diff --git a/agent-skill/Scrapling-Skill/examples/README.md b/agent-skill/Scrapling-Skill/examples/README.md index 4f645cd..388a594 100644 --- a/agent-skill/Scrapling-Skill/examples/README.md +++ b/agent-skill/Scrapling-Skill/examples/README.md @@ -9,7 +9,7 @@ All examples collect **all 100 quotes across 10 pages**. Make sure Scrapling is installed: ```bash -pip install "scrapling[all]>=0.4.6" +pip install "scrapling[all]>=0.4.7" scrapling install --force ``` diff --git a/pyproject.toml b/pyproject.toml index e8a5500..ada11a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "scrapling" # Static version instead of a dynamic version so we can get better layer caching while building docker, check the docker file to understand -version = "0.4.6" +version = "0.4.7" description = "Scrapling is an undetectable, powerful, flexible, high-performance Python library that makes Web Scraping easy and effortless as it should be!" readme = {file = "README.md", content-type = "text/markdown"} license = {file = "LICENSE"} @@ -77,8 +77,8 @@ fetchers = [ "patchright==1.58.2", "browserforge>=1.2.4", "apify-fingerprint-datapoints>=0.12.0", - "msgspec>=0.21.0", - "anyio>=4.12.1", + "msgspec>=0.21.1", + "anyio>=4.13.0", "protego>=0.6.0", ] ai = [ diff --git a/scrapling/__init__.py b/scrapling/__init__.py index c0c66ff..97af0c5 100644 --- a/scrapling/__init__.py +++ b/scrapling/__init__.py @@ -1,5 +1,5 @@ __author__ = "Karim Shoair (karim.shoair@pm.me)" -__version__ = "0.4.6" +__version__ = "0.4.7" __copyright__ = "Copyright (c) 2024 Karim Shoair" from typing import Any, TYPE_CHECKING diff --git a/scrapling/core/ai.py b/scrapling/core/ai.py index 7292a83..315733f 100644 --- a/scrapling/core/ai.py +++ b/scrapling/core/ai.py @@ -179,7 +179,9 @@ class ScraplingMCPServer: """ session_id = session_id or uuid4().hex[:12] if session_id in self._sessions: - raise ValueError(f"Session '{session_id}' already exists. Use a different ID or close the existing session first.") + raise ValueError( + f"Session '{session_id}' already exists. Use a different ID or close the existing session first." + ) common_kwargs: Dict[str, Any] = dict( wait=wait, diff --git a/server.json b/server.json index 36f60e7..5415056 100644 --- a/server.json +++ b/server.json @@ -14,12 +14,12 @@ "mimeType": "image/png" } ], - "version": "0.4.6", + "version": "0.4.7", "packages": [ { "registryType": "pypi", "identifier": "scrapling", - "version": "0.4.6", + "version": "0.4.7", "runtimeHint": "uvx", "packageArguments": [ { diff --git a/setup.cfg b/setup.cfg index 0794d59..72d64fc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = scrapling -version = 0.4.6 +version = 0.4.7 author = Karim Shoair author_email = karim.shoair@pm.me description = Scrapling is an undetectable, powerful, flexible, high-performance Python library that makes Web Scraping easy and effortless as it should be!