From 00897dad2f60dc632e4f33972e7ae0c0889b1fff Mon Sep 17 00:00:00 2001 From: Jules Omlor Date: Tue, 14 Apr 2026 01:17:52 -0400 Subject: [PATCH 1/8] 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 2/8] 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! From f4186ab9987fcc54e978dafd76492b09a3e15eea Mon Sep 17 00:00:00 2001 From: yetval Date: Wed, 15 Apr 2026 21:48:24 -0400 Subject: [PATCH 3/8] fix: prevent FetcherSession state corruption and lazy session close crash --- scrapling/engines/static.py | 14 ++++++++++++-- scrapling/spiders/session.py | 4 +++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/scrapling/engines/static.py b/scrapling/engines/static.py index 1f4b09b..b74c730 100644 --- a/scrapling/engines/static.py +++ b/scrapling/engines/static.py @@ -716,8 +716,13 @@ class FetcherSession: config["selector_config"] = self.selector_config config["proxy_rotator"] = self._proxy_rotator self._client = _SyncSessionLogic(**config) + try: + result = self._client.__enter__() + except Exception: + self._client = None + raise self._is_alive = True - return self._client.__enter__() + return result raise RuntimeError("This FetcherSession instance already has an active synchronous session.") def __exit__(self, exc_type, exc_val, exc_tb): @@ -737,8 +742,13 @@ class FetcherSession: config["selector_config"] = self.selector_config config["proxy_rotator"] = self._proxy_rotator self._client = _ASyncSessionLogic(**config) + try: + result = await self._client.__aenter__() + except Exception: + self._client = None + raise self._is_alive = True - return await self._client.__aenter__() + return result raise RuntimeError("This FetcherSession instance already has an active asynchronous session.") async def __aexit__(self, exc_type, exc_val, exc_tb): diff --git a/scrapling/spiders/session.py b/scrapling/spiders/session.py index 536be6d..5799e8c 100644 --- a/scrapling/spiders/session.py +++ b/scrapling/spiders/session.py @@ -93,7 +93,9 @@ class SessionManager: async def close(self) -> None: """Close all registered sessions.""" - for session in self._sessions.values(): + for sid, session in self._sessions.items(): + if sid in self._lazy_sessions and not session._is_alive: + continue _ = await session.__aexit__(None, None, None) self._started = False From 76ba28efaafb4fcc647c63eb07bb478d235c6616 Mon Sep 17 00:00:00 2001 From: voidborne-d Date: Thu, 16 Apr 2026 13:10:59 +0000 Subject: [PATCH 4/8] fix(static): exclude block_ads from HTTP request args block_ads is a browser-engine parameter (used by PlayWright/Camoufox fetchers for ad-domain blocking) and is not recognised by curl_cffi's Session.request(). When the CLI's --ai-targeted flag sets block_ads=True, _merge_request_args forwards it unfiltered, causing: TypeError: Session.request() got an unexpected keyword argument 'block_ads' Add block_ads to the skip_keys set so it is stripped before the dict reaches Session.request(), consistent with existing entries for extra_headers and google_search. Fixes #247 --- scrapling/engines/static.py | 1 + tests/fetchers/test_merge_request_args.py | 44 +++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/fetchers/test_merge_request_args.py diff --git a/scrapling/engines/static.py b/scrapling/engines/static.py index fae9f83..033e20e 100644 --- a/scrapling/engines/static.py +++ b/scrapling/engines/static.py @@ -149,6 +149,7 @@ class _ConfigurationLogic(ABC): # Browser session params (ignored by HTTP sessions) "extra_headers", "google_search", + "block_ads", } for k, v in method_kwargs.items(): if k not in skip_keys and v is not None: diff --git a/tests/fetchers/test_merge_request_args.py b/tests/fetchers/test_merge_request_args.py new file mode 100644 index 0000000..6b1e101 --- /dev/null +++ b/tests/fetchers/test_merge_request_args.py @@ -0,0 +1,44 @@ +"""Tests for _merge_request_args to ensure browser-only kwargs are excluded. + +Regression tests for https://github.com/D4Vinci/Scrapling/issues/247 +""" + +import pytest + +from scrapling.engines.static import FetcherClient + + +class TestMergeRequestArgsSkipsBrowserParams: + """Verify that browser-only keyword arguments are stripped before + the request dict is forwarded to curl_cffi's Session.request().""" + + def _build_args(self, **extra_kwargs): + """Helper: instantiate a FetcherClient and call _merge_request_args.""" + client = FetcherClient() + return client._merge_request_args(url="https://example.com", **extra_kwargs) + + def test_block_ads_excluded(self): + """block_ads is a browser-engine param and must not leak into the + HTTP request dict (fixes #247).""" + args = self._build_args(block_ads=True) + assert "block_ads" not in args + + def test_google_search_excluded(self): + """google_search is a browser-engine param and should be stripped.""" + args = self._build_args(google_search=True) + assert "google_search" not in args + + def test_extra_headers_excluded(self): + """extra_headers is a browser-engine param and should be stripped.""" + args = self._build_args(extra_headers={"X-Custom": "val"}) + assert "extra_headers" not in args + + def test_url_present(self): + """The url must always be present in the output dict.""" + args = self._build_args() + assert args["url"] == "https://example.com" + + def test_valid_kwargs_passed_through(self): + """Arbitrary curl_cffi-compatible kwargs should survive.""" + args = self._build_args(cookies={"session": "abc"}) + assert args.get("cookies") == {"session": "abc"} From bb90e0a83efe3ac079b5121e48ebcccef5c4218f Mon Sep 17 00:00:00 2001 From: Rafael Gomides Date: Thu, 16 Apr 2026 14:43:48 -0300 Subject: [PATCH 5/8] Add pt-BR README link to language selector --- README.md | 2 +- docs/README_PT_BR.md | 554 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 docs/README_PT_BR.md diff --git a/README.md b/README.md index 81d7848..b7e091d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

D4Vinci%2FScrapling | Trendshift
- العربيه | Español | Français | Deutsch | 简体中文 | 日本語 | Русский | 한국어 + العربيه | Español | Português (Brasil) | Français | Deutsch | 简体中文 | 日本語 | Русский | 한국어
Tests diff --git a/docs/README_PT_BR.md b/docs/README_PT_BR.md new file mode 100644 index 0000000..8e6273a --- /dev/null +++ b/docs/README_PT_BR.md @@ -0,0 +1,554 @@ + + +

+ + + + Scrapling Poster + + +
+ Web Scraping sem esforço para a web moderna +

+ +

+ D4Vinci%2FScrapling | Trendshift +
+ + Tests + + PyPI version + PyPI package downloads + + Static Badge + + OpenClaw Skill +
+ + Discord + + + X (formerly Twitter) Follow + +
+ + Supported Python versions +

+ +

+ Métodos de seleção + · + Fetchers + · + Spiders + · + Rotação de proxy + · + CLI + · + MCP +

+ +Scrapling é um framework adaptativo de Web Scraping que lida com tudo, desde uma única requisição até um crawl em larga escala. + +Seu parser aprende com as mudanças nos sites e relocaliza automaticamente seus elementos quando as páginas são atualizadas. Seus fetchers contornam sistemas anti-bot como o Cloudflare Turnstile de forma nativa. E seu framework de spiders permite escalar para crawls concorrentes com múltiplas sessões, pausa/retomada e rotação automática de proxies, tudo em poucas linhas de Python. Uma biblioteca, zero concessões. + +Crawls extremamente rápidos com estatísticas em tempo real e streaming. Feito por Web Scrapers para Web Scrapers e usuários comuns, há algo para todo mundo. + +```python +from scrapling.fetchers import Fetcher, AsyncFetcher, StealthyFetcher, DynamicFetcher +StealthyFetcher.adaptive = True +p = StealthyFetcher.fetch('https://example.com', headless=True, network_idle=True) # Busque o site sem chamar atenção! +products = p.css('.product', auto_save=True) # Extraia dados que sobrevivem a mudanças no design do site! +products = p.css('.product', adaptive=True) # Depois, se a estrutura do site mudar, passe `adaptive=True` para encontrá-los! +``` +Ou escale para crawls completos +```python +from scrapling.spiders import Spider, Response + +class MySpider(Spider): + name = "demo" + start_urls = ["https://example.com/"] + + async def parse(self, response: Response): + for item in response.css('.product'): + yield {"title": item.css('h2::text').get()} + +MySpider().start() +``` + +

+ + At DataImpulse, we specialize in developing custom proxy services for your business. Make requests from anywhere, collect data, and enjoy fast connections with our premium proxies. + +

+ +# Patrocinadores Platina + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Scrapling lida com o Cloudflare Turnstile. Para proteção de nível empresarial, + Hyper Solutions + oferece endpoints de API que geram tokens antibot válidos para Akamai, DataDome, Kasada e Incapsula. Chamadas simples de API, sem necessidade de automação de navegador.
+ + + + Nós criamos a + BirdProxies + porque proxies não deveriam ser complicados nem caros. Proxies residenciais e ISP rápidos em mais de 195 localidades, preços justos e suporte de verdade.
+ Experimente nosso jogo FlappyBird na landing page para ganhar dados grátis! +
+ + + + + + Evomi + : proxies residenciais a partir de US$0.49/GB. Navegador de scraping com Chromium totalmente spoofado, IPs residenciais, resolução automática de CAPTCHA e bypass anti-bot.
+ Scraper API para resultados sem complicação. Integrações com MCP e N8N estão disponíveis. +
+ + + + + TikHub.io oferece mais de 900 APIs estáveis em mais de 16 plataformas, incluindo TikTok, X, YouTube e Instagram, com mais de 40M de datasets.
Também oferece modelos de IA com desconto - Claude, GPT, GEMINI e mais com até 71% de desconto. +
+ + + + + Nsocks fornece proxies residenciais e ISP rápidos para desenvolvedores e scrapers. Cobertura global de IPs, alto anonimato, rotação inteligente e desempenho confiável para automação e extração de dados. Use o Xcrawl para simplificar o crawling web em larga escala. +
+ + + + + Feche o notebook. Seus scrapers continuam rodando.
+ PetroSky VPS - servidores em nuvem feitos para automação ininterrupta. Máquinas Windows e Linux com controle total. A partir de €6.99/mês. +
+ + + + + Leia uma análise completa do Scrapling no The Web Scraping Club (nov. 2025), a newsletter número 1 dedicada a Web Scraping. +
+ + + + + Proxy-Seller fornece infraestrutura de proxy confiável para web scraping, oferecendo proxies IPv4, IPv6, ISP, residenciais e móveis com desempenho estável, ampla cobertura geográfica e planos flexíveis para coleta de dados em escala empresarial. +
+ + + + + Proxies estáveis para scraping, automação e multi-accounting. IPs limpos, resposta rápida e desempenho confiável sob carga. Feito para fluxos de trabalho escaláveis. +
+ + + + + Swiftproxy fornece proxies residenciais escaláveis com mais de 80M de IPs em mais de 195 países, entregando conexões rápidas e confiáveis, rotação automática e forte desempenho anti-bloqueio. Teste grátis disponível. +
+ +Quer mostrar seu anúncio aqui? Clique [aqui](https://github.com/sponsors/D4Vinci/sponsorships?tier_id=586646) +# Patrocinadores + + + + + + + + + + + + + + + +Quer mostrar seu anúncio aqui? Clique [aqui](https://github.com/sponsors/D4Vinci) e escolha o plano que fizer mais sentido para você! + +--- + +## Principais Recursos + +### Spiders - Um Framework Completo de Crawling +- 🕷️ **API de Spider estilo Scrapy**: Defina spiders com `start_urls`, callbacks assíncronos `parse` e objetos `Request`/`Response`. +- ⚡ **Crawling Concorrente**: Limites de concorrência configuráveis, throttling por domínio e delays de download. +- 🔄 **Suporte Multi-Sessão**: Interface unificada para requisições HTTP e navegadores headless furtivos em uma única spider - direcione requisições para diferentes sessões por ID. +- 💾 **Pausa e Retomada**: Persistência de crawl baseada em checkpoints. Pressione Ctrl+C para um encerramento gracioso; reinicie para continuar de onde parou. +- 📡 **Modo Streaming**: Faça streaming dos itens extraídos conforme chegam com `async for item in spider.stream()` e estatísticas em tempo real - ideal para UI, pipelines e crawls de longa duração. +- 🛡️ **Detecção de Requisições Bloqueadas**: Detecção automática e retry de requisições bloqueadas com lógica personalizável. +- 🤖 **Conformidade com robots.txt**: Flag opcional `robots_txt_obey` que respeita as diretivas `Disallow`, `Crawl-delay` e `Request-rate` com cache por domínio. +- 🧪 **Modo de Desenvolvimento**: Armazene respostas em disco na primeira execução e reproduza-as nas seguintes - itere sobre sua lógica de `parse()` sem reenviar requisições aos servidores-alvo. +- 📦 **Exportação Nativa**: Exporte resultados via hooks, seu próprio pipeline ou JSON/JSONL nativos com `result.items.to_json()` / `result.items.to_jsonl()` respectivamente. + +### Busca Avançada de Sites com Suporte a Sessões +- **Requisições HTTP**: Requisições HTTP rápidas e furtivas com a classe `Fetcher`. Pode imitar fingerprint TLS de navegadores, cabeçalhos e usar HTTP/3. +- **Carregamento Dinâmico**: Busque sites dinâmicos com automação completa de navegador através da classe `DynamicFetcher`, compatível com o Chromium do Playwright e o Google Chrome. +- **Bypass Anti-Bot**: Capacidades avançadas de stealth com `StealthyFetcher` e spoofing de fingerprint. Pode contornar facilmente todos os tipos de Turnstile/Interstitial do Cloudflare com automação. +- **Gerenciamento de Sessão**: Suporte a sessões persistentes com as classes `FetcherSession`, `StealthySession` e `DynamicSession` para gerenciar cookies e estado entre requisições. +- **Rotação de Proxy**: `ProxyRotator` nativo com estratégias cíclicas ou personalizadas em todos os tipos de sessão, além de sobrescritas de proxy por requisição. +- **Bloqueio de Domínios e Anúncios**: Bloqueie requisições para domínios específicos (e seus subdomínios) ou habilite o bloqueio nativo de anúncios (~3.500 domínios conhecidos de anúncios/rastreadores) nos fetchers baseados em navegador. +- **Prevenção de Vazamento de DNS**: Suporte opcional a DNS-over-HTTPS para rotear consultas DNS através do DoH da Cloudflare, evitando vazamentos de DNS ao usar proxies. +- **Suporte Async**: Suporte assíncrono completo em todos os fetchers e classes dedicadas de sessão async. + +### Scraping Adaptativo e Integração com IA +- 🔄 **Rastreamento Inteligente de Elementos**: Relocalize elementos após mudanças no site usando algoritmos inteligentes de similaridade. +- 🎯 **Seleção Flexível Inteligente**: Seletores CSS, seletores XPath, busca baseada em filtros, busca por texto, busca por regex e muito mais. +- 🔍 **Encontrar Elementos Semelhantes**: Localize automaticamente elementos parecidos com os elementos encontrados. +- 🤖 **Servidor MCP para uso com IA**: Servidor MCP nativo para Web Scraping assistido por IA e extração de dados. O servidor MCP oferece capacidades poderosas e personalizadas que usam o Scrapling para extrair conteúdo direcionado antes de passá-lo à IA (Claude/Cursor/etc), acelerando as operações e reduzindo custos ao minimizar o uso de tokens. ([vídeo demo](https://www.youtube.com/watch?v=qyFk3ZNwOxE)) + +### Arquitetura de Alto Desempenho e Testada em Batalha +- 🚀 **Muito Rápido**: Desempenho otimizado que supera a maioria das bibliotecas Python de scraping. +- 🔋 **Eficiente em Memória**: Estruturas de dados otimizadas e lazy loading para um uso mínimo de memória. +- ⚡ **Serialização JSON Rápida**: 10x mais rápido que a biblioteca padrão. +- 🏗️ **Testado em batalha**: O Scrapling não apenas tem 92% de cobertura de testes e cobertura completa de type hints, como também vem sendo usado diariamente por centenas de Web Scrapers ao longo do último ano. + +### Experiência Amigável para Desenvolvedores/Web Scrapers +- 🎯 **Shell Interativo de Web Scraping**: Shell opcional embutido em IPython com integração ao Scrapling, atalhos e novas ferramentas para acelerar o desenvolvimento de scripts de Web Scraping, como converter requisições curl em requisições Scrapling e visualizar resultados no navegador. +- 🚀 **Use diretamente no Terminal**: Opcionalmente, você pode usar o Scrapling para extrair uma URL sem escrever uma única linha de código! +- 🛠️ **API Rica de Navegação**: Travessia avançada do DOM com métodos de navegação por pais, irmãos e filhos. +- 🧬 **Processamento de Texto Aprimorado**: Métodos nativos de regex, limpeza e operações de string otimizadas. +- 📝 **Geração Automática de Seletores**: Gere seletores CSS/XPath robustos para qualquer elemento. +- 🔌 **API Familiar**: Semelhante a Scrapy/BeautifulSoup, com os mesmos pseudo-elementos usados em Scrapy/Parsel. +- 📘 **Cobertura Completa de Tipos**: Type hints completos para excelente suporte em IDEs e autocompletar de código. Todo o codebase é escaneado automaticamente com **PyRight** e **MyPy** a cada alteração. +- 🔋 **Imagem Docker Pronta**: A cada release, uma imagem Docker contendo todos os navegadores é construída e publicada automaticamente. + +## Primeiros Passos + +Vamos dar uma visão rápida do que o Scrapling pode fazer sem entrar em muitos detalhes. + +### Uso Básico +Requisições HTTP com suporte a sessões +```python +from scrapling.fetchers import Fetcher, FetcherSession + +with FetcherSession(impersonate='chrome') as session: # Use a versão mais recente da fingerprint TLS do Chrome + page = session.get('https://quotes.toscrape.com/', stealthy_headers=True) + quotes = page.css('.quote .text::text').getall() + +# Ou use requisições avulsas +page = Fetcher.get('https://quotes.toscrape.com/') +quotes = page.css('.quote .text::text').getall() +``` +Modo stealth avançado +```python +from scrapling.fetchers import StealthyFetcher, StealthySession + +with StealthySession(headless=True, solve_cloudflare=True) as session: # Mantenha o navegador aberto até terminar + page = session.fetch('https://nopecha.com/demo/cloudflare', google_search=False) + data = page.css('#padded_content a').getall() + +# Ou use o estilo de requisição avulsa, ele abre o navegador para esta requisição e o fecha ao finalizar +page = StealthyFetcher.fetch('https://nopecha.com/demo/cloudflare') +data = page.css('#padded_content a').getall() +``` +Automação completa de navegador +```python +from scrapling.fetchers import DynamicFetcher, DynamicSession + +with DynamicSession(headless=True, disable_resources=False, network_idle=True) as session: # Mantenha o navegador aberto até terminar + page = session.fetch('https://quotes.toscrape.com/', load_dom=False) + data = page.xpath('//span[@class="text"]/text()').getall() # Se preferir, use seletor XPath + +# Ou use o estilo de requisição avulsa, ele abre o navegador para esta requisição e o fecha ao finalizar +page = DynamicFetcher.fetch('https://quotes.toscrape.com/') +data = page.css('.quote .text::text').getall() +``` + +### Spiders +Construa crawlers completos com requisições concorrentes, múltiplos tipos de sessão e pausa/retomada: +```python +from scrapling.spiders import Spider, Request, Response + +class QuotesSpider(Spider): + name = "quotes" + start_urls = ["https://quotes.toscrape.com/"] + concurrent_requests = 10 + + async def parse(self, response: Response): + for quote in response.css('.quote'): + yield { + "text": quote.css('.text::text').get(), + "author": quote.css('.author::text').get(), + } + + next_page = response.css('.next a') + if next_page: + yield response.follow(next_page[0].attrib['href']) + +result = QuotesSpider().start() +print(f"Extraídas {len(result.items)} citações") +result.items.to_json("quotes.json") +``` +Use múltiplos tipos de sessão em uma única spider: +```python +from scrapling.spiders import Spider, Request, Response +from scrapling.fetchers import FetcherSession, AsyncStealthySession + +class MultiSessionSpider(Spider): + name = "multi" + start_urls = ["https://example.com/"] + + def configure_sessions(self, manager): + manager.add("fast", FetcherSession(impersonate="chrome")) + manager.add("stealth", AsyncStealthySession(headless=True), lazy=True) + + async def parse(self, response: Response): + for link in response.css('a::attr(href)').getall(): + # Direcione páginas protegidas através da sessão stealth + if "protected" in link: + yield Request(link, sid="stealth") + else: + yield Request(link, sid="fast", callback=self.parse) # callback explícito +``` +Pause e retome crawls longos com checkpoints executando a spider assim: +```python +QuotesSpider(crawldir="./crawl_data").start() +``` +Pressione Ctrl+C para pausar de forma graciosa - o progresso é salvo automaticamente. Depois, quando você iniciar a spider novamente, passe o mesmo `crawldir` e ela continuará de onde parou. + +### Parsing Avançado e Navegação +```python +from scrapling.fetchers import Fetcher + +# Seleção rica de elementos e navegação +page = Fetcher.get('https://quotes.toscrape.com/') + +# Obtenha citações com múltiplos métodos de seleção +quotes = page.css('.quote') # Seletor CSS +quotes = page.xpath('//div[@class="quote"]') # XPath +quotes = page.find_all('div', {'class': 'quote'}) # Estilo BeautifulSoup +# O mesmo que +quotes = page.find_all('div', class_='quote') +quotes = page.find_all(['div'], class_='quote') +quotes = page.find_all(class_='quote') # e assim por diante... +# Encontre elementos por conteúdo de texto +quotes = page.find_by_text('quote', tag='div') + +# Navegação avançada +quote_text = page.css('.quote')[0].css('.text::text').get() +quote_text = page.css('.quote').css('.text::text').getall() # Seletores encadeados +first_quote = page.css('.quote')[0] +author = first_quote.next_sibling.css('.author::text') +parent_container = first_quote.parent + +# Relações e similaridade entre elementos +similar_elements = first_quote.find_similar() +below_elements = first_quote.below_elements() +``` +Você pode usar o parser imediatamente se não quiser buscar sites, como abaixo: +```python +from scrapling.parser import Selector + +page = Selector("...") +``` +E ele funciona exatamente da mesma maneira! + +### Exemplos de Gerenciamento de Sessão Assíncrona +```python +import asyncio +from scrapling.fetchers import FetcherSession, AsyncStealthySession, AsyncDynamicSession + +async with FetcherSession(http3=True) as session: # `FetcherSession` entende o contexto e funciona tanto em padrões sync quanto async + page1 = session.get('https://quotes.toscrape.com/') + page2 = session.get('https://quotes.toscrape.com/', impersonate='firefox135') + +# Uso de sessão assíncrona +async with AsyncStealthySession(max_pages=2) as session: + tasks = [] + urls = ['https://example.com/page1', 'https://example.com/page2'] + + for url in urls: + task = session.fetch(url) + tasks.append(task) + + print(session.get_pool_stats()) # Opcional - O estado do pool de abas do navegador (ocupada/livre/erro) + results = await asyncio.gather(*tasks) + print(session.get_pool_stats()) +``` + +## CLI e Shell Interativo + +O Scrapling inclui uma poderosa interface de linha de comando: + +[![asciicast](https://asciinema.org/a/736339.svg)](https://asciinema.org/a/736339) + +Inicie o shell interativo de Web Scraping +```bash +scrapling shell +``` +Extraia páginas diretamente para um arquivo sem programar (por padrão, extrai o conteúdo dentro da tag `body`). Se o arquivo de saída terminar com `.txt`, então o conteúdo em texto do alvo será extraído. Se terminar com `.md`, será uma representação em Markdown do conteúdo HTML; se terminar com `.html`, será o próprio conteúdo HTML. +```bash +scrapling extract get 'https://example.com' content.md +scrapling extract get 'https://example.com' content.txt --css-selector '#fromSkipToProducts' --impersonate 'chrome' # Todos os elementos que correspondem ao seletor CSS '#fromSkipToProducts' +scrapling extract fetch 'https://example.com' content.md --css-selector '#fromSkipToProducts' --no-headless +scrapling extract stealthy-fetch 'https://nopecha.com/demo/cloudflare' captchas.html --css-selector '#padded_content a' --solve-cloudflare +``` + +> [!NOTE] +> Existem muitos recursos adicionais, mas queremos manter esta página concisa, incluindo o servidor MCP e o Shell Interativo de Web Scraping. Confira a documentação completa [aqui](https://scrapling.readthedocs.io/en/latest/) + +## Benchmarks de Desempenho + +O Scrapling não é apenas poderoso - ele também é extremamente rápido. Os benchmarks abaixo comparam o parser do Scrapling com as versões mais recentes de outras bibliotecas populares. + +### Teste de Velocidade de Extração de Texto (5000 elementos aninhados) + +| # | Biblioteca | Tempo (ms) | vs Scrapling | +|---|:-----------------:|:----------:|:------------:| +| 1 | Scrapling | 2.02 | 1.0x | +| 2 | Parsel/Scrapy | 2.04 | 1.01 | +| 3 | Raw Lxml | 2.54 | 1.257 | +| 4 | PyQuery | 24.17 | ~12x | +| 5 | Selectolax | 82.63 | ~41x | +| 6 | MechanicalSoup | 1549.71 | ~767.1x | +| 7 | BS4 with Lxml | 1584.31 | ~784.3x | +| 8 | BS4 with html5lib | 3391.91 | ~1679.1x | + + +### Desempenho de Similaridade de Elementos e Busca por Texto + +Os recursos de localização adaptativa de elementos do Scrapling superam significativamente as alternativas: + +| Biblioteca | Tempo (ms) | vs Scrapling | +|-------------|:----------:|:------------:| +| Scrapling | 2.39 | 1.0x | +| AutoScraper | 12.45 | 5.209x | + + +> Todos os benchmarks representam médias de 100+ execuções. Veja [benchmarks.py](https://github.com/D4Vinci/Scrapling/blob/main/benchmarks.py) para a metodologia. + +## Instalação + +O Scrapling requer Python 3.10 ou superior: + +```bash +pip install scrapling +``` + +Esta instalação inclui apenas o motor de parsing e suas dependências, sem fetchers nem dependências de linha de comando. + +### Dependências Opcionais + +1. Se você vai usar qualquer um dos recursos extras abaixo, os fetchers ou suas classes, precisará instalar as dependências dos fetchers e as dependências de navegador deles da seguinte forma: + ```bash + pip install "scrapling[fetchers]" + + scrapling install # instalação normal + scrapling install --force # forçar reinstalação + ``` + + Isso baixa todos os navegadores, juntamente com suas dependências de sistema e dependências de manipulação de fingerprint. + + Ou você pode instalá-los a partir do código em vez de executar um comando como este: + ```python + from scrapling.cli import install + + install([], standalone_mode=False) # instalação normal + install(["--force"], standalone_mode=False) # forçar reinstalação + ``` + +2. Recursos extras: + - Instale o recurso do servidor MCP: + ```bash + pip install "scrapling[ai]" + ``` + - Instale os recursos do shell (Shell de Web Scraping e o comando `extract`): + ```bash + pip install "scrapling[shell]" + ``` + - Instale tudo: + ```bash + pip install "scrapling[all]" + ``` + Lembre-se de que você precisa instalar as dependências de navegador com `scrapling install` depois de qualquer um desses extras (caso ainda não tenha feito isso) + +### Docker +Você também pode baixar uma imagem Docker com todos os extras e navegadores com o seguinte comando a partir do DockerHub: +```bash +docker pull pyd4vinci/scrapling +``` +Ou baixá-la do registro do GitHub: +```bash +docker pull ghcr.io/d4vinci/scrapling:latest +``` +Essa imagem é construída e publicada automaticamente usando GitHub Actions e o branch principal do repositório. + +## Contribuindo + +Contribuições são bem-vindas! Leia nossas [diretrizes de contribuição](https://github.com/D4Vinci/Scrapling/blob/main/CONTRIBUTING.md) antes de começar. + +## Aviso Legal + +> [!CAUTION] +> Esta biblioteca é fornecida apenas para fins educacionais e de pesquisa. Ao usar esta biblioteca, você concorda em cumprir as leis locais e internacionais de scraping de dados e privacidade. Os autores e contribuidores não se responsabilizam por qualquer uso indevido deste software. Sempre respeite os termos de serviço dos sites e os arquivos robots.txt. + +## 🎓 Citações +Se você usou nossa biblioteca para fins de pesquisa, cite-nos com a seguinte referência: +```text + @misc{scrapling, + author = {Karim Shoair}, + title = {Scrapling}, + year = {2024}, + url = {https://github.com/D4Vinci/Scrapling}, + note = {An adaptive Web Scraping framework that handles everything from a single request to a full-scale crawl!} + } +``` + +## Licença + +Este trabalho está licenciado sob a licença BSD-3-Clause. + +## Agradecimentos + +Este projeto inclui código adaptado de: +- Parsel (Licença BSD) - usado para o submódulo [translator](https://github.com/D4Vinci/Scrapling/blob/main/scrapling/core/translator.py) + +--- +
Projetado e desenvolvido com ❤️ por Karim Shoair.

From 78e388f75cfeedc547d1da770fd69f294eda235d Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Fri, 17 Apr 2026 22:07:44 +0200 Subject: [PATCH 6/8] feat: add new mcp tool to screenshot pages Implements #244 --- scrapling/core/ai.py | 81 +++++++++++++++++++++++++-- tests/ai/test_ai_mcp.py | 119 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 6 deletions(-) diff --git a/scrapling/core/ai.py b/scrapling/core/ai.py index 315733f..464d35b 100644 --- a/scrapling/core/ai.py +++ b/scrapling/core/ai.py @@ -3,7 +3,8 @@ from asyncio import gather from datetime import datetime, timezone from dataclasses import dataclass, field -from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp import FastMCP, Image +from mcp.types import ImageContent, TextContent from pydantic import BaseModel, Field from scrapling.core.shell import Convertor @@ -31,6 +32,7 @@ from scrapling.core._types import ( ) SessionType = Literal["dynamic", "stealthy"] +ScreenshotType = Literal["png", "jpeg"] class ResponseModel(BaseModel): @@ -106,14 +108,14 @@ class ScraplingMCPServer: def __init__(self): self._sessions: Dict[str, _SessionEntry] = {} - def _get_session(self, session_id: str, expected_type: SessionType) -> _SessionEntry: - """Look up a session by ID and validate its type.""" + def _get_session(self, session_id: str, expected_type: Optional[SessionType]) -> _SessionEntry: + """Look up a session by ID, optionally validating its type. Pass `None` to skip the type check.""" entry = self._sessions.get(session_id) if entry is None: raise ValueError(f"Session '{session_id}' not found. Use list_sessions to see active sessions.") if not entry.session._is_alive: raise ValueError(f"Session '{session_id}' is no longer alive. Open a new session.") - if entry.session_type != expected_type: + if expected_type is not None and entry.session_type != expected_type: raise ValueError( f"Session '{session_id}' is a '{entry.session_type}' session, but this tool requires a " f"'{expected_type}' session. Use the matching fetch tool for your session type." @@ -260,6 +262,69 @@ class ScraplingMCPServer: for sid, entry in self._sessions.items() ] + async def screenshot( + self, + url: str, + session_id: str, + image_type: ScreenshotType = "png", + full_page: bool = False, + quality: Optional[int] = None, + wait: int | float = 0, + wait_selector: Optional[str] = None, + wait_selector_state: SelectorWaitStates = "attached", + network_idle: bool = False, + timeout: int | float = 30000, + ) -> List[ImageContent | TextContent]: + """Capture a screenshot of a web page using an existing browser session and return it as an image. + A browser session must be opened first with `open_session` (either `dynamic` or `stealthy`); the session ID is then passed here. + + :param url: The URL to navigate to and capture. + :param session_id: ID of an open browser session created with `open_session`. + :param image_type: Image format. Defaults to "png". Use "jpeg" for smaller file sizes. + :param full_page: When True, captures the full scrollable page instead of just the viewport. Defaults to False. + :param quality: Image quality (0-100) for JPEG only. Raises if passed with `image_type="png"`. + :param wait: Time in milliseconds to wait after page load before capturing. Defaults to 0. + :param wait_selector: Optional CSS selector to wait for before capturing. + :param wait_selector_state: State to wait for the selector. Defaults to "attached". + :param network_idle: Wait for the page until there are no network connections for at least 500 ms. + :param timeout: Timeout in milliseconds for page operations. Defaults to 30,000. + """ + if quality is not None and image_type != "jpeg": + raise ValueError("'quality' is only valid when 'image_type' is 'jpeg'.") + + entry = self._get_session(session_id, expected_type=None) + + screenshot_kwargs: Dict[str, Any] = {"type": image_type, "full_page": full_page} + if quality is not None: + screenshot_kwargs["quality"] = quality + + captured: Dict[str, Any] = {} + + async def _capture(page: Any) -> None: + try: + captured["bytes"] = await page.screenshot(**screenshot_kwargs) + captured["url"] = page.url + except Exception as exc: + captured["error"] = exc + + await entry.session.fetch( + url, + wait=wait, + timeout=timeout, + network_idle=network_idle, + wait_selector=wait_selector, + wait_selector_state=wait_selector_state, + page_action=_capture, + ) + + if "error" in captured: + raise captured["error"] + if "bytes" not in captured: + raise RuntimeError(f"Failed to capture screenshot for {url}") + + image = Image(data=captured["bytes"], format=image_type).to_image_content() + return [image, TextContent(type="text", text=captured["url"])] + @staticmethod async def get( url: str, @@ -298,7 +363,8 @@ 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 "safe", which follows redirects but rejects those targeting internal/private IPs (SSRF protection). Pass True to follow all redirects without restriction. + :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. @@ -371,7 +437,8 @@ 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 "safe", which follows redirects but rejects those targeting internal/private IPs (SSRF protection). Pass True to follow all redirects without restriction. + :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. @@ -835,4 +902,6 @@ class ScraplingMCPServer: description=self.bulk_stealthy_fetch.__doc__, structured_output=True, ) + # Screenshot tool (returns image + url content blocks, not structured JSON) + server.add_tool(self.screenshot, title="screenshot", description=self.screenshot.__doc__) server.run(transport="stdio" if not http else "streamable-http") diff --git a/tests/ai/test_ai_mcp.py b/tests/ai/test_ai_mcp.py index 4806088..19e2f02 100644 --- a/tests/ai/test_ai_mcp.py +++ b/tests/ai/test_ai_mcp.py @@ -1,5 +1,12 @@ +import base64 +import struct +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread + import pytest import pytest_httpbin +from mcp.types import ImageContent, TextContent from scrapling.core.ai import ( ScraplingMCPServer, @@ -197,6 +204,118 @@ class TestSessionManagement: await server.close_session("dupe") +def _png_height(data: bytes) -> int: + """Read the height field from a PNG IHDR chunk.""" + return struct.unpack(">I", data[20:24])[0] + + +@contextmanager +def _serve_html(body: bytes): + """Serve a fixed HTML body on localhost, yielding its URL.""" + + class _Handler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args, **kwargs): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}/" + finally: + server.shutdown() + server.server_close() + + +@pytest_httpbin.use_class_based_httpbin +class TestScreenshot: + """Test the screenshot tool""" + + @pytest.fixture(scope="class") + def test_url(self, httpbin): + return f"{httpbin.url}/html" + + @pytest.fixture + def server(self): + return ScraplingMCPServer() + + @pytest.mark.asyncio + async def test_screenshot_png_with_dynamic_session(self, server, test_url): + """PNG screenshot via a dynamic session returns image and url content blocks""" + opened = await server.open_session(session_type="dynamic", headless=True) + try: + result = await server.screenshot(url=test_url, session_id=opened.session_id) + assert isinstance(result, list) and len(result) == 2 + assert isinstance(result[0], ImageContent) + assert result[0].mimeType == "image/png" + assert isinstance(result[1], TextContent) + assert result[1].text == test_url + finally: + await server.close_session(opened.session_id) + + @pytest.mark.asyncio + async def test_screenshot_jpeg_with_quality(self, server, test_url): + """JPEG screenshot with quality parameter via a dynamic session""" + opened = await server.open_session(session_type="dynamic", headless=True) + try: + result = await server.screenshot(url=test_url, session_id=opened.session_id, image_type="jpeg", quality=80) + assert isinstance(result[0], ImageContent) + assert result[0].mimeType == "image/jpeg" + finally: + await server.close_session(opened.session_id) + + @pytest.mark.asyncio + async def test_screenshot_with_stealthy_session(self, server, test_url): + """PNG screenshot via a stealthy session""" + opened = await server.open_session(session_type="stealthy", headless=True) + try: + result = await server.screenshot(url=test_url, session_id=opened.session_id) + assert isinstance(result[0], ImageContent) + assert result[0].mimeType == "image/png" + finally: + await server.close_session(opened.session_id) + + @pytest.mark.asyncio + async def test_screenshot_full_page_taller_than_viewport(self, server): + """full_page=True produces an image taller than the viewport-only capture""" + tall_html = b"
" + with _serve_html(tall_html) as tall_url: + opened = await server.open_session(session_type="dynamic", headless=True) + try: + viewport_result = await server.screenshot(url=tall_url, session_id=opened.session_id, full_page=False) + full_result = await server.screenshot(url=tall_url, session_id=opened.session_id, full_page=True) + + viewport_png = base64.b64decode(viewport_result[0].data) + full_png = base64.b64decode(full_result[0].data) + + assert _png_height(full_png) > _png_height(viewport_png) + finally: + await server.close_session(opened.session_id) + + @pytest.mark.asyncio + async def test_screenshot_invalid_session_id_raises(self, server, test_url): + """Unknown session_id raises ValueError""" + with pytest.raises(ValueError, match="not found"): + await server.screenshot(url=test_url, session_id="does-not-exist") + + @pytest.mark.asyncio + async def test_screenshot_quality_with_png_raises(self, server, test_url): + """quality is rejected when image_type is png""" + opened = await server.open_session(session_type="dynamic", headless=True) + try: + with pytest.raises(ValueError, match="quality"): + await server.screenshot(url=test_url, session_id=opened.session_id, image_type="png", quality=90) + finally: + await server.close_session(opened.session_id) + + class TestNormalizeCredentials: """Test the _normalize_credentials helper""" From 77b29a3ebe9734919f2522f78d064193f728c30d Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Fri, 17 Apr 2026 22:51:20 +0200 Subject: [PATCH 7/8] docs: update with the latest changes --- docs/ai/mcp-server.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/ai/mcp-server.md b/docs/ai/mcp-server.md index 08c7b61..534c525 100644 --- a/docs/ai/mcp-server.md +++ b/docs/ai/mcp-server.md @@ -6,7 +6,7 @@ The **Scrapling MCP Server** is a new feature that brings Scrapling's powerful W ## Features -The Scrapling MCP Server provides nine powerful tools for web scraping: +The Scrapling MCP Server provides ten powerful tools for web scraping: ### 🚀 Basic HTTP Scraping - **`get`**: Fast HTTP requests with browser fingerprint impersonation, generating real browser headers matching the TLS version, HTTP/3, and more! @@ -20,6 +20,9 @@ The Scrapling MCP Server provides nine powerful tools for web scraping: - **`stealthy_fetch`**: Uses our Stealthy browser to bypass Cloudflare Turnstile/Interstitial and other anti-bot systems with complete control over the request/browser! - **`bulk_stealthy_fetch`**: An async version of the above tool that allows stealth scraping of multiple URLs in different browser tabs at the same time! +### 📸 Screenshots +- **`screenshot`**: Capture a PNG or JPEG screenshot of a page using an open browser session, returned as an image content block the model can actually see (not a base64 string blob). Supports full-page captures, JPEG quality, and the usual readiness controls (`wait`, `wait_selector`, `network_idle`). + ### 🔌 Session Management - **`open_session`**: Create a persistent browser session (dynamic or stealthy) that stays open across multiple fetch calls, avoiding the overhead of launching a new browser each time. - **`close_session`**: Close a persistent browser session and free its resources. @@ -331,6 +334,14 @@ This protection runs automatically on all MCP tool responses. Keep `main_content - Always close sessions with `close_session` when done to free resources - Use `list_sessions` to check which sessions are still active - A `session_id` from a dynamic session can only be used with `fetch`/`bulk_fetch`, and a stealthy session can only be used with `stealthy_fetch`/`bulk_stealthy_fetch` +- Pass a custom `session_id` to `open_session` to give sessions meaningful names (e.g. `"search"`, `"checkout"`) instead of the random hex default. `open_session` raises if the chosen ID is already in use, so you can detect collisions up front + +### 7. Capturing Screenshots +- `screenshot` only works through an existing browser session, so call `open_session` first (either `dynamic` or `stealthy` works) +- The image is returned as a real `ImageContent` block, not a base64 string in JSON, so the model sees the page directly +- Use `full_page=True` when you need everything below the fold; the default captures only the visible viewport +- Pick `image_type="jpeg"` with a `quality` value (0-100) for smaller payloads when pixel-perfect color isn't needed +- The same `wait`, `wait_selector`, `network_idle`, and `timeout` controls used by `fetch` are available here too ## Legal and Ethical Considerations From 2a1e9e22f26666fd0e9d9b33d7a2bfa48fc5d0f5 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Fri, 17 Apr 2026 22:51:50 +0200 Subject: [PATCH 8/8] docs(agent): update skill with the latest changes --- agent-skill/Scrapling-Skill.zip | Bin 83257 -> 83861 bytes .../Scrapling-Skill/references/mcp-server.md | 47 +++++++++++++----- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/agent-skill/Scrapling-Skill.zip b/agent-skill/Scrapling-Skill.zip index 220183b517e707516db32418604807b6ba9e6382..daa1f080a27fc242b4729575c05ba52e57cd9a24 100644 GIT binary patch delta 13485 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ⅅS+XN$$^sfkZab2u#%b^e~riEJN;7!OpgY{ zOF+7(_@igJcgFu8`es5j;3@21mKY3+2W0$TH1VXsY5e+7>icgjVjFrfI!YStDf@`2 z7B&KzWDH`=Ngf4R<+#r|GEK1J{Z>YjF6ZBZUmLq0Slza59?EPPClP<^QyC3@xNBfTxdQ^9N`q2FOHj=}?5j}LC9=e=k~wOC$9(X9j=*?bA^``XU+ZQLuhcqEBpPm`~8;m zo7ZpFB6Q(`D>{9eaZLEy8STerG}AcDHdR6BGkghi{$Sx~90aq_?v;_U|vsTvRtvKX5f7Yjs%R5?15NfO0E z?dV<^fR|p%M75Bm9J+OJh>6)TnG{q--PvXemmh;Sl}=Ak`+EDP=NV z#TP~xH~kPXkS^alF7YAqLfWSqL9Z8PXvHc87wwHFX&W<@QmL>N%4pa{&n9KNPwo3x z-M)3?(ooQFl7h0-;<@k`%s~Aw2ybi~bs&n!W^Xz=d6g2ZA2S-vFnNh=tf_pFk<7zY z_b;ML*WUb--N5aQ?wIYVrJk5;(!K2;+Q!qIM!?|@jF?)>BGEqvP~XsNTDc^GAn&`d zs*&rE-GsdS8^+kQjW>Z{eLQ98Gifb72HaNHe9hSo<;AOKB8{p)U7HdY<3I=l$@5?y4uK z+C5~SGUNj^8isRfAxzGIeE|tR?Y%JNfTIy9XIkZ!kC35s`{iQ5nI&P22#RSiqgSUU zhDIjvva>5Q6ves0YlOR%GMn~8-dQ{hgjj@NT4lIPYO8pNB{$;7+W`#7O?%|)o`w~- zH)d)n%jsjWyiviQP$R_=Tj)N}j7J3^CFKS&(` zB>E@FBfBFqPz`{nAkm&PN=Ua^?Syas%Ub8N%%XZ+uha`RoXOTaMo}>E7+LJFsz}jd zH@qb+TFVCT=zUn;>D5b4{#j!R#kBlP^5Q*fKgV%*6d;ra*pcPEVZUA)qthFKb~~Cf zIOTe|uj~Tg8RIl{QHlYAP!RAdA~$(a_b0+m!g}JIGYu&r{Ujm24@U>*gXzn~SA##( zHLIfyKA~SlZ~3}D2}R^Q)b?|?spJre-@4}^2y^oB843y_8lbGoW7v}8GNt9YLrtay!P&q+HPX#cebw`h(Z<0> zN~_+|BC3w^=N;*Wf)V>pVMEy}a**mJ7KA691z}z_3-66W5@SSa5=cr3T5~3RNz(Th zenSxTe}Sn_N9sV}il3e~=(OE?Nn)5%R?D!buCsc=&|uffh0diF2^n2L_X?e-FRB*9 zV$mrdVdM+o`~}3S(YvmZIcjK9orbLp7D>TX%P<_CtZ)1%I-E(KJtUygvyg+Kzj16Q z2A>~Xy2-DE!qcay;Rhi*^;ihX>xT7vOzn&QK7d~I<%!xvUM9cW?-uFMOs%XzZ@~;s zaD~9`>!OMei$~{y@)*IJ6UoT}>vq@0DdAP*mP3~~3-d*|EssU>E6LS1_d3YvyQasB z0e5Z3s@j6#;=Xf%(suA_as`s-V6K=9TJ^uOgwEjUyRZaR0kv4_% z1^Sa}5?Ho2{hC+$a8+hz-KoZ;lXQU4&B*@t8K`Hl_i9(Qvn!Li+NS=H{rf@eD)jX6 zzT0B|G}FsBf)qe8G%dA*WNLsaHu}SO_>-mLyVnq^xZO57NKe8t|EWh@^(StsMj12A z=j`}7G@5t3i}l^%Vk7_tp)*R?J7Ze2Kd;-Xt+rzdT2F7rkN$qdpyOR!h-{(#;^`++ z`~YGjG{vHQ2Yu*?$Yo2#Zm*|glC7r$@DJ1(;Szfq2$=;H2;!o>*}$03{`8tsJ&rrR z=|_*nMM#zmpf2C%IvK2nTq99djJyn7L(%5QoDAB-5rB z${2@fK+r8TWY!D`C#DTvF$3&mS93miBGNU9!6cF>DLWpXkSA6Rw_RfRE>iBIQrq{c za|5|)e%1#c9IT>2Hbz-|7ZRg+Q489{@yat=PX` zuqvibI#gqp?;W4ISw=OG3f$w!0)7LYVjH^MV|+g=_6icPd4G$n_eE4$&-@ir>|n6% zw4}=lbKR>7ezKw@y9ytk3kd#I+O>te%L`M0E*VUeGr#|_cXFIsNLyOjP;V8(j`LZE z8x-ZGKC^v&|Mm;0Fc)7*{NVRMu9N%vncXl|U!x<_pyVh4?3 zJWelqcOuxh`xHdxbOv^$VpcCKUT8;f(Vz&`DQ-~$3;0&bv_qO`brg1^H7il?A5qKu zU^ZR#L<)`7#~<^)(g$S{?=$Mi?PPmeo-plJ@W4U1UJ_@NUrRtov)bD$(0` zbl(qB-F+p~eZ7e5jb6Z8sol?YI>a}qlbM-tF85EJrn7liI>dHPp=d7-U&_cekU+#& zT3iNheqJgmdTQE5JL!Y^Ep1eGJjx2T#*PmA`5tKQP;`(FeOSk)$R+m+Hl5B<>%!kC zIG?ksN^^>`-Cmag62I_RTf0e$j>>0RkQXAUsPLCFcr344dt(|-W!ED*D&tBJi37bS zk%XUv^(aD`-tm_w{Br%3xRcaz_CZthG4X>9GID`If*CdRgco@~6;cxwNDeEevTcnR zy$ms+eY3MPkQS)e<`@Z@{UP>?^zC4gg$ud$;STEtHZ?a8$rOu29*+a>6;hK7-3$sL zv6Rn_^kzQ`>w68>`o&@~_!y+%VUe{_X5YIP2W>I)XD(}5N{3+H>SbeG0YDN{Ps}v9 zTQD{mW^T4jJ?m7Ki(h_Ct?S1?sAXd)8f1rD^msX+_lRQMWqe6qfJVS+l1mbBPl_eV zA&WfGs5>>qgYL+P+MF}>`Dyl8bzy`1s^C)g-o#K5U_+y$a?Pj zbavES7kg=et%p2G=|+r{#$0N&*U@#3aHx|2bE$J!S9iVilpmMRm*bY3VX4_62a4^O zYl*+*8fnH-1`JeGwmYqQKe`pZ5jJ{wyD`+rH}()Lmlv)bdenBg5rLMIT7`z!zip>p zV0ClTd+l?2b*m|LIVey+ktC|iVnS=}YU@F+quXf!Yz;d*)H99)SXd~x`yE)6bOaqX^_9tx%@aiW6?p>(J(?M&vIcOy2TQgVJ01+NR9 z;#GCEsh?TTjS6xGHh~PBo%r7!{H)pb@?uU~^$;R|*VdS&@JrQxtg^XvTF&n^)_;vY5hjAQHpxh}%sRB%k2~yNP$DGk1dJSgJ-DYCufNQF73$$_6UDaidLy zM%yP$TJWiZIMX*JTdqaaT8coF%{zBg--n(;jMAQ16q36d;qyaoDG)z0r>U!TYRqFz z4^p|)@J?|#RY!{Hg?>IloX$wF0uWAiQWUW)-5I+-17-v(`ydaKE2XX4gu%<@P)kds zNjLc;^LGWPIuIJUd10+z>e!1gI+NdN$=XcYoU8FKy8O8?V9O=>X47vurITL>&`Wx;{mVho-?^GihN~=!vobQoS+y#JT zI-z45OCfvL(0L7b2UGGJug)P8YZc97lpB2-9a=`4AkeQtA3xObJWgCOdyn2|8odS3 zcQ0V(Gh!uEZ61#kwd@IpT=Lglp)*|pRIyMIBk}qQRC%HmrLqXZ3Rc`0>8ImgaKHS#gkd_Q%v(WKr-yqe*caYAmr)y)c4XLZn< zQ9|WcBoKDo&)-%$F?p|E4UTwn4mG)o98oWy6g+bbVnXb?i8$`FP*HjC-BSx`tW$o1ngwmyL&d4%` zHm39jJf)YiB=<>JNN62qlmi39w!-1u$%<58yg@7j)_%!>A8bmwUCalZq9vlb3dP|v_eeT zG64y)dho*e+4&DF?yWry<<6`C7*>KmLkC6-^@*(I_Un_8++V`reVt?%AgI$F0(p0N z_6_#=+if_fe_3O0m*0QM(Y_8$(V)1hnwrUdN##97AolOtXJAb)Z#jk}u_7SyD;+bx zzeIIH2zjW9*;@sD>U~EJLH@oU@$N+~vkKw^52rq5yh7>wn%I9)z0#0g?KkJ9r)Co_ z_JQdL4OPbEQ<#v_ic8;cYx!hHxNuxD_0tnFIId~^*oveh(_pgx&9PI{^^WHTI_nWN zU2Fqd1QA{7C3WY>0`hG;R?*DV09*Kq-Qv-Yd$DR?Ms8u{>S_5y^?R1rg5y*GvJeOh zmGs)vsh*`U(|=fi9q~E~g%&|#sn>k3SJGZY$W_^&NSXVI;({xZ6~9ktAFyH@ZtjM( z#R1@}m;2>9BVLpdW&VB-Q@<=;2UI-Q7`j z6;oM!9V-%=x_^nn^9avrWQEv=n+CdqWV<`o84|I2x$J$GPBzcQy+T(7I^A6xD~vfLHfZ66Ma$gm^U2| zTs7wlzpHvwRTe|FV;VMcks(riDzyj4`*k?Uh}Apzhuc*ZCRf0fj`kO<0}l|ssXB5z zps#6x-m%dVMQ|w`x@A##EU$O#@-q~d^UN^C{cDx#`iU z(;QRYvncO=kR>s}x&m1Ddj^#x^!qg#mq$W8dxwe;{bH$%9_~OvcS7Hdt*ChdoD&A> z-yutd29 zU&E?paua^P>L|~sAbih_87F_8WIYC)m}z}54GwXR-k7dUyCC=l>%g(r4j%k>W%OP7iX0+A14uh7gHY(glqGCGp(eSW?XkmYk=BT zHXI3Lg#T3Gs_k`i8nuFI*WJ@f*t2?&d|nLoi(Ce6jZyDC5g+x@1k5 z@mt&w$}lVKZku#r%DWi%abXnc*9p)iFm{&|cH!2D4#i`QwDgB5^jxWko8srBm{IWX z3%zW{Ia3=yB{GPI-(}zR7Bcr2zs=j*(gpvdV^YJswXnf8+^gVE`F#;zDV2`a0w){y z?UKlg?*Sy-l;R)0x=NQ5e3dBKZ-x1!BzM*bHaoG_Ka|^Ac!-GpfW>bR_|zXHL-R(3 z%x3ET7_oS_!X7*wHFTge<){iYH_L`a^ig~hB1g5nKjqLvp*|H5xd8b5A|_ml}0r~_j|sno%;R( zQ;`*DkN_0pLwo!!T=Sq5z36pH$27{G^T5~omNmoRp(&lj7}mkC2rFqX|5ZE2vz?Y$ z>tpGQQuOIfrjcL^8%70OGXK6BX4Z^;f{~cT!ER=JasSL!J^x32-QU|(q=;URC(R0< zX4ZVL#Bhl?(HoW{w?9l-gtY})7uJKcQFbEv2u12DzoT2N-r&9BMJ&EN3|BDOM}L<0-o3IppCsN@$eI0 z5O_*KlSN+B?2+KW;5szRLgLoc2+}C7Xo0+^2?W0%DpGCXcNsM-{4p zWH%OFHS|3y)Ik&n2}V2MDr484nNB-(Zwa)dQBiAwz`hi&RY*pejNrn&Nutx@j%d!u z>a$5fdi^uMHp?9B0c}t)Hv3y_nY0;QeU32FbJMFX+_`yLMmNFq=h@=`Z#7GZjPV6c z$y)OHWUaJ)z#I`TcEW84K8cC7Vr;!`xY(!4YFwoCv@q}GpnGcm&TzM7m+eMQGcwR7 zTmP8pBK~qAZxw!%tNvk$Gl*1-*ghY=dBzBu;W#W-o2Iv(IN{em#t_rZuZa8te1hcF zzV;9tJu#_hispVP2y-q!x+rS<7v&DGZ9?3;7GA*Ap{FnOoCc$`eU{G~ZreSr?Pmqq z1!S#bu3kI!j6^QTKE`j4IOhF^n)@J?;1)XkePsyN9S+Vbt>x79p7`+Gi>ZYaXP|zG!am(II_xckklV+3{@ z6%jN9L{zdugab!_hkwIOU15v)AQ08hR&Ytnc9)sOVY&Ps*`a~Pg-G1f>Hy`cn)xtS zY%2q|c!dMywn(lv2tr9ac+3^2aA@5u6tu&RpXWQVAf7XbG)ydAX4Be>s<4ktMMUFw zJ}cG8vZg#Ic(Qx3lYSM2%U1=D_K*?3ub|BCHjv=>320~y#{`V0L z@Eki(0P@dCi)M5VpaBH@pItS*eVH=qR?9B~i1-^nSxq+rYV=y`o&>qMH_TvF60n@;Q5PB?P!7VG z3v4g;=f-6V{ORW)*MG3-9E9K+0U#!rS`bJA5z77VSo8#e$`D9me+63zI)#4<6W--0 z!0JLkRtQSXe`{q50Y5{W+5D^Rwju{peEOrca{ISH(1i#b`lkT0(hrbe@5=(-{xdg_ zi!uLcxIF6P-x6G~lQ56~A}V5g+0{Q!O!1=>MyJ^t02 zLS~}_|IU*D7)|_-1v?BN*g@>itZ9n_nc@Df#J>w094ijQf!81a0#xN>$+SO|FfQ~IDueVb|4k;pPBe~@k0MOR{;Pp{&^Yw+1@7Q0{)Ty4;9`%TL1t6 delta 12918 zcmaia1yGz@()JAQ?(PJa;O;>Z+%34fYX)}<0}QUg-3jiV0Kwhe9Rh?exp#MK@77=S zpQ*QMT2J?x^Hjal-O2H=U!!1Al@*|2Z~%YSesaubR957~@n!3OJ+(46Kxihd6)=#H zxc)ov8tPw!Gq@rFqh1=83g+Jo=-u_p-vCzO(w=hZ7mzRiekzY=hJB;X>7$?%St8LwgwSOIUy@x8uj+oV; zTd45h#-^o7jmYsK0i#9)8kZ6Q!kcy}>+i@YDaut|jvslSq6i<1x37R827($P@HKjC ziE3OkoOFk4q5?J}Vj1_xOqIdd9R0GBb))}+XMnawfO@6L$bwHKUfA|@N8w=stEtfR zDNS*R1jV_Cn>q6`IR7k^NqDa> zMM^){Q~xpJn0)i~mS?MF`SHRFuK*p}?X3hAneF=C_14*S{isOkcp=C>^C<+LJYYAC z=_>W7Jgs?i!6kof0L^{-XoHqG$5s}z=c>!1R{a~LUHMRWb5YH^xjGgqeaS5L)R=e4 zCE3(zW1ZQTg@t!=omvnB@KR^x@*BNr%AeK6Bbf7lku0ldPNTM=oR_=mpb}- z8>KQjVyHAF`KNa4saTxuw`BZ;K(M_1EV@hMbTC4_jq4 z<+*9==H-RF(ZEsp~yKtrQ7J4S2?=X4j3GH1H@z3!BmMPN30cw&H#{7jXE!zJ9>jI-2gd3 zn?FkJs0@4QckzJ)Dw&LXdFH7;z+JOEKuAPLNaQ)_nxIp$d-FpPiOj{r6MB-`M$$_x zhC8UOJ(OqDJ*z&CYk@{|s?mylCcx^%Hc+*+uKo6-vI0B;%Q;wP5()tL1_uEAjUNO6 z!XNzTgE3IW|3c6o4E&88=lXA`_x}OPU&xWg&WHXNa_r&%1wF7aK0D!mVbA&>j=^R4 z0{=ng-+t}E`ULrZywn2;{Sp5HU?7$7KLFUMXW+R!i0Qvyy&&0@z$iE3?!Qz3-ckQ- zIuw~zQly%57pe`V;LHSwHo;F!EqgibsE>BUqLHP)2@b7Z{JqkA;e+w*&WeQ)&HaW% z4A*PE%92Y75Uf;5A4fH5rFzLH;iV67;+?24rxmpw&=shcfYS0aP_m0G6V(ZK%bf<- zh>PlJR7$5#d_;0=Fp<_v+*)^ZVaBy;F>8%El+#bxx%7ee8`PZ@Ok$moVgB%Cc)td0-{4`cnc?B>q97s8oj(I+D} zY(C!E)zOhw1ES^X`BGdO0S&aLix~n=%Un!WOmI|WvL*)_BF=LG3_eJe6*j4jkcY1} zq%c}w#y@6hxbhI_hZ-+CDOwJu-OC+F4e0u*2OWu#$9hLeY2 zW{f2qn-Z=G`z^zlL>a@Btw&777>>PY{F4-!{4PWGbCBXj-r#G97Q96}QIkMO>W~nh&ON@JAd;G00>^U`!8|>+16AMD%Fg8kT zraoEU!+MyMNkyZI?~=E}!-+dAPDluWO_r9TQK$~@y=hQr46?IO;U-Rs_5(+aVSPzU;qsnE>=KiWJU(zPgRc6Ux?M)u-FnjEKO z9FFrfEYhPcTNGdi7fg@bBe{*DSAV>@H)|PX2CBf2DstH@kFlW6rufvG?I%rkERDb# z{k#=KmCEaZWdAYXf|lnvZ;>5rk?#>P8Mt~*tUCB)QyTP**Rb?ECSF-n!-f22hu<*q z&tr9IlY_-RN6&ub15NO{5@ml_aqPSh=ttse0)@*dB6fXSn!6BKKRd@#`8rYJ@#DOF z21r3(L(;K%Ts04NVtlDQ1m5iD3z0p;&Wok2NarikA3uf0#Dt8#H=rkC(v5m?xEVJz;6mGnA|pu} z5r(vB4Jw8v7^^Y3sZn}+nTqA@ZPFtwv4b39BCrC`XcBNC#wlcXyY`_x$TZ%DtsY9D zhCJv@$Z78=O;RQrx&*#T@V6{7U)`M6VsB3hVn!$Ncp?RM(_R!p@$clb==&7o(8OZ!RoIN|=NsyQgYJQ#d>U$08(Gzx-R z#3cN|%RO!6i^}~m(l}fqHhSWu61QJoynZW8{W&_WoQX%7%8i;QWFH|5-E8unw{@6M z3DsU3OWh`V4NJRa4$XBogR_1sg#O?O>k$Y6>Z^d_?1IWp>m0s{`y}ERSWU zY^=T<`I%3M`~*Lnj>YDS2`_&nVBLJ{AQDdW>yM0uV$xGfYk7qIzythV4()_pUL*3P zHXA%FrB2!RXoE7^pXiibteUHiHpWQ>ii3gL*j!igC zq1AlndOx2#Yr$qLA|1a~Gec4OUZgfUX4?9am~8aKdg7hvA@#LNmL!*(q?c~;hZU@J zyj>)o0K6ZfIqUjay=@Ct3_MX0=jqto%C(c*!e$lGaW7=g<-4K=IA!>9Paht%)J)(B zkAr@6!N1z*SU3peoAdaeZ?hOz*8j^@Hzq+^HGGpLA>v2*dY!0pA-QvL)~QV^xpDGhH^z-W$3X z<`_qy_40_>6Q6jskZ$O07^T$E=VIkGo8!Un;zAic;amlq)q;`_o*Yb-|gF^9rIN!+#Z%CF0p{)1qusupvId3C)aACB;-)_g_{Ew-3F-w?>7&)i?kL zJW&8<*Cue!cQqF_#?>hFqo0$s&|yUKV^@<%oL_2J(ovh1Mh=%kZd>!qcFC$$H0dof z8sf}O!ga)SXDZo6K7acZ-o6h=HBU=46tEHBrYy(v2?U9e_1KK{8kV9EWqNAJn)1kyx}*Kc6=mq#+CQlHw-Aino7W>Jug!yVImt zj3O6Ki!=K{eyN=0yb`vP^k%*yxd=t2;s+Vp3RO4m^$V)3BmCGYO??-z(mg{d6y0PK zB6GKd*bEwNkflVJ!0b3^w4M`LGWCDvrrl0u#EH8b22fu;1mYX}tS5*A4roXCaa_?q z`xf72HB^3~EA`5a)pBK8QjW!_r&(z5!Wo-+Sj(eaXAI!H(_t1}HEfeWVNY^gdBU`N zBYCwc_Sl`?(Pf;salbv7u#%F@v11TO-W0~1I|F+Aq!JM#<98nznc8jKErUk)L%oIp zV3QYA`9cBX*XdK9AvcApBk(h**t05eReTAFN0o_R$Kn}=3-V*9WR-eA;!w9)c4I}D zyp`Ic8Oqat*q}!sp3S$w!#<^{C%-9r_0-#mioBnZ5#WZDLzOxk?ER|TMBlnE5q0oM zN){Bp?jSuq-RpP0vD!ej=lg-bx)G|-Q5xyc9ZR7Gl)i)#5z!?Ry&+~k#+gh5o4ij6<2RWox2_M zOFE^M4u11n3SDJuua%+Mn=*oU7MOGg${eEp!yh{oNBo*#Tllaet0iEeBo9AASlnzs zZlOzeQgEKXZc;i)zpP$*gNL&on3V5m13h<(ZoJ{vdx&~zsx*5|Z=g=D-iEu>Mh2W{ zt&}buAlG5TX+7^or-5i%SFX-yjmrh1KgDu-k}?b`l$k2u$};AE`%rM)Vt`cE2jgwy zezDW87-V^3LtNo5v=+p_FL@NL*_R>YM*E?3{k{Nl0s{OgCq zP1*hD+-TA&FC-k-*luzoa|=B=(Fx7Q_BHBaN7|G9N}3gFa!2vw_D_am#p>Q5_qU+e zB51C@T2BgwbP#>QlEgcYNCXSKphkmybQmFf!uC3I{|2noy@Jkotx;1OYhq(Mv}4lP ztqKR)#9$ZE(xtfyqP%NQBD0J(6cvc3#~C%*7gf?YIK}>vuCwOZwz;pbm06!Ss|Bj* zZxZt#x4Jmq&J+EDeQQdNT(N!tx?gj5?O5OL?id3xeK~{1txYiZ_AGf>!?dQ6;CmIj;O%1R<{bC>si`V`&J_8D zE^%4B&5kJ*;SH83ghk67B2K2qtxw_DSsOYssAfNFcNdHjT`I+1u%$8((H6sQoTXul zVp8gw)=Tn{m7xLIb^hyUBX0u%CZ(6naY^M`bXlPAt4+~KivjY_g+Wfo1AG5gf z5m3OD?%F`p{@9)8G`!$j5K7&u!sn;;H`SQ2_8nAH1P_~jg*dx2w6VKOZ&v0x%;`gM z7j29lTi3v*XPJf^xQT2|(3t*E0Tu^^-68t?Y>c%n z46>tnW3TlcJQiOk6QTr8aAY;5cr!YmBDxIw)s1&rF4-yPyZ$7wqcz*c>JV){$R_^m z{2gH`_b(DKFNMxE$gjIP+zg&spM%}tHOi;KTJIC|>z=3E+0$}7Is0x&%YF`(h8k0n zdF{>SO8@lmP|Zqk;4V^@Ofy-3j#-lQHxYq~U5$-9!S)4QnrODs6<)`)`f3$2W4c&B zjzuI&@T!XE%CD1(kct9}4U*>eZX3jd4kxq527D23)1D3|!hXku!A}+9R31hyue;9C{^MFm+fR7XJXGKcTR@QDIkU?) zofsx^c#=i)sEx|Hcj7N!&z0p`NZ&;&|dX&dAUdaE{ z3!sECjm*&j07rrUsTY8Yxn%#RTwo8j%KDEh*2M6XzrX&gF!5Ve0Z3UN z^WaMO-|S{jEZ`yhpMnC!lnBW3kBY)Kp~J-G!L-*$98w!5Nk*DCI*N?amimOTGzyV% zkrpKMg(bsulQhfF`NmxA)B<;%y}y>$p9tC<8=nxKNLOQnGnrB}EWQpxP0o9gzqrQ_ zZ5z_@+A!Mb4-3sM+SsKiklLo*VZpMw8`^+B3^07s+EN9vNU>^-MvY3Ceu`b~FJzsU zuqY&Bd(VKktgp)iS1O{l#mB8t_uf3OUq{2NX{MAYE`%ldecrV02#v<1k4mZn39iVl zfJ!Lt7!;nTb=39egvgLW{WpdTjfpx`zi8NY7+BPB7=slrXt6%(X&>pDODwr2NBnSN zZD_1b@>IQvFDT|pmVQ@!c8|8kFvfi(|L^RsY zUKoWbzEIqKPQUD8&xx5KH9M_nHOJ

9D&I?Sp>-xmX5a_>;X_VJxB>dwekLZw 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 diff --git a/agent-skill/Scrapling-Skill/references/mcp-server.md b/agent-skill/Scrapling-Skill/references/mcp-server.md index a860f0a..48a2d10 100644 --- a/agent-skill/Scrapling-Skill/references/mcp-server.md +++ b/agent-skill/Scrapling-Skill/references/mcp-server.md @@ -1,8 +1,8 @@ # Scrapling MCP Server -The Scrapling MCP server exposes nine web scraping tools over the MCP protocol. It supports CSS-selector-based content narrowing (reducing tokens by extracting only relevant elements before returning results), three levels of scraping capability (plain HTTP, browser-rendered, and stealth/anti-bot bypass), and persistent browser session management. +The Scrapling MCP server exposes ten tools over the MCP protocol. It supports CSS-selector-based content narrowing (reducing tokens by extracting only relevant elements before returning results), three levels of scraping capability (plain HTTP, browser-rendered, and stealth/anti-bot bypass), persistent browser session management, and page screenshots returned as real image content blocks. -All scraping tools return a `ResponseModel` with fields: `status` (int), `content` (list of strings), `url` (str). +All scraping tools return a `ResponseModel` with fields: `status` (int), `content` (list of strings), `url` (str). The `screenshot` tool returns a list of MCP content blocks: an `ImageContent` (the screenshot bytes) followed by a `TextContent` (the post-redirect URL). ## Tools @@ -99,17 +99,18 @@ Opens a browser session that stays alive across multiple fetch calls, avoiding t **Key parameters:** -| Parameter | Type | Default | Description | -|--------------------|-----------------------------|--------------|---------------------------------------------------------------------| -| `session_type` | `"dynamic"` / `"stealthy"` | required | Type of browser session to create | -| `headless` | bool | true | Run browser hidden or visible | -| `max_pages` | int | 5 | Max concurrent browser tabs (1-50) | -| `proxy` | str or dict or null | null | Proxy for all requests in this session | -| `timeout` | number | 30000 | Default timeout in ms | -| `solve_cloudflare` | bool | false | (Stealthy only) Auto-solve Cloudflare challenges | -| `hide_canvas` | bool | false | (Stealthy only) Canvas fingerprint noise | -| `block_webrtc` | bool | false | (Stealthy only) Block WebRTC IP leak | -| `allow_webgl` | bool | true | (Stealthy only) Keep WebGL enabled | +| Parameter | Type | Default | Description | +|--------------------|-----------------------------|--------------|-------------------------------------------------------------------------------------------------------| +| `session_type` | `"dynamic"` / `"stealthy"` | required | Type of browser session to create | +| `session_id` | str or null | null | Custom ID for the session. If omitted, a random 12-char hex ID is generated. Raises if already in use | +| `headless` | bool | true | Run browser hidden or visible | +| `max_pages` | int | 5 | Max concurrent browser tabs (1-50) | +| `proxy` | str or dict or null | null | Proxy for all requests in this session | +| `timeout` | number | 30000 | Default timeout in ms | +| `solve_cloudflare` | bool | false | (Stealthy only) Auto-solve Cloudflare challenges | +| `hide_canvas` | bool | false | (Stealthy only) Canvas fingerprint noise | +| `block_webrtc` | bool | false | (Stealthy only) Block WebRTC IP leak | +| `allow_webgl` | bool | true | (Stealthy only) Keep WebGL enabled | Plus all other browser session parameters (`google_search`, `real_chrome`, `cdp_url`, `locale`, `timezone_id`, `useragent`, `extra_headers`, `cookies`, `disable_resources`, `network_idle`, `wait_selector`, `wait_selector_state`). @@ -131,6 +132,25 @@ Returns a list of `SessionInfo` objects, each with `session_id`, `session_type`, No parameters. +### `screenshot` -- Capture a page screenshot + +Navigates to a URL inside an existing browser session and returns the screenshot as an MCP `ImageContent` block (the bytes the model can see directly, not a base64 string in JSON) followed by a `TextContent` block carrying the post-redirect URL. + +Requires an open browser session. Call `open_session` first, then pass the `session_id` here. Both `dynamic` and `stealthy` sessions are accepted. + +| Parameter | Type | Default | Description | +|-----------------------|-----------------------|--------------|--------------------------------------------------------------------------------------| +| `url` | str | required | URL to navigate to and capture | +| `session_id` | str | required | ID of an open browser session created with `open_session` | +| `image_type` | `"png"` / `"jpeg"` | `"png"` | Image format. Use `"jpeg"` for smaller payloads | +| `full_page` | bool | false | Capture the full scrollable page instead of just the viewport | +| `quality` | int or null | null | JPEG quality 0-100. Raises if passed with `image_type="png"` | +| `wait` | number | 0 | Extra wait (ms) after page load before capture | +| `wait_selector` | str or null | null | CSS selector to wait for before capture | +| `wait_selector_state` | str | `"attached"` | State for `wait_selector`: `"attached"` / `"visible"` / `"hidden"` / `"detached"` | +| `network_idle` | bool | false | Wait until no network activity for 500ms | +| `timeout` | number | 30000 | Timeout in milliseconds | + ## Tool selection guide | Scenario | Tool | @@ -142,6 +162,7 @@ No parameters. | Cloudflare or strong anti-bot protection | `stealthy_fetch` (with `solve_cloudflare=true` for Turnstile) | | Multiple protected pages | `bulk_stealthy_fetch` | | Multiple pages from the same site | `open_session` + `fetch`/`stealthy_fetch` with `session_id` | +| Need a screenshot of a page | `open_session` + `screenshot` with `session_id` | Start with `get` (fastest, lowest resource cost). Escalate to `fetch` if content requires JS rendering. Escalate to `stealthy_fetch` only if blocked. For multiple pages from the same site, use a persistent session to avoid browser launch overhead.