Merge branch 'dev' into fix/fetcher-session-state-corruption

This commit is contained in:
Yuval Dinodia
2026-04-15 21:51:26 -04:00
committed by GitHub
67 changed files with 5857 additions and 187 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
__author__ = "Karim Shoair (karim.shoair@pm.me)"
__version__ = "0.4.4"
__version__ = "0.4.7"
__copyright__ = "Copyright (c) 2024 Karim Shoair"
from typing import Any, TYPE_CHECKING
+24
View File
@@ -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}'")
@@ -309,6 +311,16 @@ 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,
help="Block requests to known ad and tracker domains (default: False)",
),
]
for decorator in decorators:
f = decorator(f)
@@ -498,6 +510,8 @@ 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."""
kwargs: Dict[str, Any] = {
@@ -507,6 +521,8 @@ 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:
kwargs["wait"] = wait
@@ -538,6 +554,8 @@ def fetch(
proxy,
extra_headers,
ai_targeted,
dns_over_https,
block_ads,
):
"""Opens up a browser and fetch content using DynamicFetcher."""
parsed_headers, _ = _ParseHeaders(extra_headers, False)
@@ -552,6 +570,8 @@ def fetch(
real_chrome,
proxy,
parsed_headers,
dns_over_https,
block_ads,
)
from scrapling.fetchers import DynamicFetcher
@@ -597,6 +617,8 @@ 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."""
parsed_headers, _ = _ParseHeaders(extra_headers, False)
@@ -611,6 +633,8 @@ def stealthy_fetch(
real_chrome,
proxy,
parsed_headers,
dns_over_https,
block_ads,
)
kwargs.update(
{
+19 -1
View File
@@ -2,12 +2,14 @@ from scrapling.core._types import (
Any,
Dict,
List,
Set,
Tuple,
Sequence,
Callable,
Optional,
SetCookieParam,
SelectorWaitStates,
FollowRedirects,
)
# Parameter definitions for shell function signatures (defined once at module level)
@@ -26,7 +28,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]],
@@ -45,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,
@@ -57,6 +60,13 @@ _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],
"dns_over_https": bool,
}
_STEALTHY_FETCH_PARAMS = {
@@ -71,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,
@@ -83,6 +94,13 @@ _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],
"dns_over_https": bool,
"allow_webgl": bool,
"hide_canvas": bool,
"block_webrtc": bool,
+1
View File
@@ -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`
+16 -5
View File
@@ -27,6 +27,7 @@ from scrapling.core._types import (
SetCookieParam,
extraction_types,
SelectorWaitStates,
FollowRedirects,
)
SessionType = Literal["dynamic", "stealthy"]
@@ -122,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,
@@ -151,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.
@@ -174,6 +177,12 @@ 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,
@@ -182,6 +191,7 @@ class ScraplingMCPServer:
cookies=cookies,
cdp_url=cdp_url,
headless=headless,
block_ads=True,
max_pages=max_pages,
useragent=useragent,
timezone_id=timezone_id,
@@ -209,7 +219,6 @@ class ScraplingMCPServer:
await session.start()
session_id = uuid4().hex[:12]
entry = _SessionEntry(session=session, session_type=session_type)
self._sessions[session_id] = entry
@@ -262,7 +271,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 +298,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 +344,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 +371,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.
@@ -568,6 +577,7 @@ class ScraplingMCPServer:
cookies=cookies,
cdp_url=cdp_url,
headless=headless,
block_ads=True,
max_pages=len(urls),
useragent=useragent,
timezone_id=timezone_id,
@@ -776,6 +786,7 @@ class ScraplingMCPServer:
timeout=timeout,
cookies=cookies,
headless=headless,
block_ads=True,
useragent=useragent,
timezone_id=timezone_id,
real_chrome=real_chrome,
+7 -2
View File
@@ -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))
@@ -47,7 +52,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))
+1 -1
View File
@@ -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]:
+13
View File
@@ -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
@@ -449,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,
+20 -4
View File
@@ -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)
+20 -4
View File
@@ -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)
+6 -1
View File
@@ -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]]
@@ -73,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]]
@@ -87,10 +89,12 @@ 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
executable_path: Optional[str]
dns_over_https: bool
class PlaywrightFetchParams(TypedDict, total=False):
@@ -102,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
+15 -1
View File
@@ -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):
@@ -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
@@ -85,15 +86,19 @@ 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
executable_path: Optional[str] = None
dns_over_https: bool = False
def __post_init__(self): # pragma: no cover
"""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'. "
@@ -127,6 +132,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
@@ -150,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]
+13 -11
View File
@@ -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
@@ -250,6 +251,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
@@ -284,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.
@@ -316,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.
@@ -348,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.
@@ -380,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.
@@ -501,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.
@@ -533,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.
@@ -565,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.
@@ -597,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.
@@ -662,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,
@@ -681,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.
File diff suppressed because it is too large Load Diff
+25 -4
View File
@@ -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:
+8 -2
View File
@@ -15,13 +15,16 @@ 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 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.
: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.
@@ -55,13 +58,16 @@ 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 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.
: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.
+8 -2
View File
@@ -20,12 +20,15 @@ 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 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.
: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
@@ -69,12 +72,15 @@ 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 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.
: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
+79
View File
@@ -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}")
+156 -49
View File
@@ -1,16 +1,19 @@
import json
import pprint
from pathlib import Path
from urllib.parse import urlparse
import anyio
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.cache import ResponseCacheManager
from scrapling.spiders.checkpoint import CheckpointManager, CheckpointData
from scrapling.core._types import Dict, Union, Optional, TYPE_CHECKING, Any, AsyncGenerator
@@ -41,10 +44,29 @@ class CrawlerEngine:
)
self.stats = CrawlStats()
if self.spider.robots_txt_obey:
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)
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()
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()
@@ -68,11 +90,42 @@ 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.
"""
robots_manager = self._robots_manager
if robots_manager is None:
return self.spider.download_delay
domain = request.domain
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) will fetch here.
c_delay, r_rate = await robots_manager.get_delay_directives(request.url, request.sid)
delay = self.spider.download_delay
if r_rate:
req_count, period = r_rate
if req_count > 0:
delay = max(delay, period / req_count)
if c_delay is not None:
delay = max(delay, c_delay)
self._domain_delays[domain] = delay
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)
self._domain_limiters.setdefault(domain, CapacityLimiter(self.spider.concurrent_requests_per_domain))
return self._domain_limiters[domain]
return self._global_limiter
@@ -85,47 +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."""
async with self._rate_limiter(request.domain):
if self.spider.download_delay:
await anyio.sleep(self.spider.download_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):
@@ -155,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:
@@ -219,6 +302,25 @@ class CrawlerEngine:
return True
async def _prefetch_robots_txt(self) -> None:
"""Pre-warm the robots.txt cache before the crawl loop starts.
Extracts unique domains from start_urls, preserving the original scheme.
"""
if not self._robots_manager or not self.spider.start_urls:
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)
async def crawl(self) -> CrawlStats:
"""Run the spider and return CrawlStats."""
self._running = True
@@ -227,6 +329,9 @@ class CrawlerEngine:
self._pause_requested = False
self._force_stop = False
self.stats = CrawlStats(start_time=anyio.current_time())
self._domain_limiters.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
@@ -238,6 +343,8 @@ class CrawlerEngine:
self.stats.download_delay = self.spider.download_delay
await self.spider.on_start(resuming=resuming)
await self._prefetch_robots_txt()
try:
if not resuming:
async for request in self.spider.start_requests():
@@ -251,11 +358,7 @@ 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 canceling to avoid data loss
if self._checkpoint_system_enabled:
await self._save_checkpoint()
self.paused = True
@@ -263,6 +366,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
+6
View File
@@ -47,6 +47,9 @@ class CrawlStats:
concurrent_requests_per_domain: int = 0
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
@@ -95,6 +98,9 @@ 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,
"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,
+77
View File
@@ -0,0 +1,77 @@
from urllib.parse import urlparse
from anyio import create_task_group
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."""
def __init__(self, fetch_fn: Callable[[str, str], Awaitable]):
self._fetch_fn = fetch_fn
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"
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[domain] = parser
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.
: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, "*")
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.
: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("*")
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,
)
async def prefetch(self, urls: list[str], sid: str) -> None:
"""Pre-warm the robots.txt cache for a list of seed URLs concurrently.
: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
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)
+7
View File
@@ -72,6 +72,13 @@ class Spider(ABC):
start_urls: list[str] = []
allowed_domains: Set[str] = set()
# 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