247153575d
Tests / tests (map[TOXENV:py310], macos-latest, 3.10) (push) Has been cancelled
Tests / tests (map[TOXENV:py311], macos-latest, 3.11) (push) Has been cancelled
Tests / tests (map[TOXENV:py312], macos-latest, 3.12) (push) Has been cancelled
Tests / tests (map[TOXENV:py313], macos-latest, 3.13) (push) Has been cancelled
685 lines
20 KiB
Python
685 lines
20 KiB
Python
from os import environ
|
|
from pathlib import Path
|
|
from subprocess import check_output
|
|
from sys import executable as python_executable
|
|
|
|
from scrapling import __version__
|
|
from scrapling.core.utils import log
|
|
from scrapling.engines.toolbelt.custom import Response
|
|
from scrapling.core.utils._shell import _CookieParser, _ParseHeaders
|
|
from scrapling.core._types import List, Optional, Dict, Tuple, Any, Callable
|
|
|
|
from orjson import loads as json_loads, JSONDecodeError
|
|
|
|
try:
|
|
from click import command, option, Choice, group, argument, version_option
|
|
except (ImportError, ModuleNotFoundError) as e:
|
|
raise ModuleNotFoundError(
|
|
"You need to install scrapling with any of the extras to enable Shell commands. See: https://scrapling.readthedocs.io/en/latest/#installation"
|
|
) from e
|
|
|
|
__OUTPUT_FILE_HELP__ = "The output file path can be an HTML file, a Markdown file of the HTML content, or the text content itself. Use file extensions (`.html`/`.md`/`.txt`) respectively."
|
|
__PACKAGE_DIR__ = Path(__file__).parent
|
|
|
|
|
|
def __Execute(cmd: List[str], help_line: str) -> None: # pragma: no cover
|
|
print(f"Installing {help_line}...")
|
|
_ = check_output(cmd, shell=False) # nosec B603
|
|
# I meant to not use try except here
|
|
|
|
|
|
def __ParseJSONData(json_string: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
"""Parse JSON string into a Python object"""
|
|
if not json_string:
|
|
return None
|
|
|
|
try:
|
|
return json_loads(json_string)
|
|
except JSONDecodeError as err: # pragma: no cover
|
|
raise ValueError(f"Invalid JSON data '{json_string}': {err}")
|
|
|
|
|
|
def __Request_and_Save(
|
|
fetcher_func: Callable[..., Response],
|
|
url: str,
|
|
output_file: str,
|
|
css_selector: Optional[str] = None,
|
|
ai_targeted: bool = False,
|
|
**kwargs,
|
|
) -> None:
|
|
"""Make a request using the specified fetcher function and save the result"""
|
|
from scrapling.core.shell import Convertor
|
|
|
|
# Handle relative paths - convert to an absolute path based on the current working directory
|
|
output_path = Path(output_file)
|
|
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}'")
|
|
|
|
|
|
def __ParseExtractArguments(
|
|
headers: List[str], cookies: str, params: str, json: Optional[str] = None
|
|
) -> Tuple[Dict[str, str], Dict[str, str], Dict[str, str], Optional[Dict[str, str]]]:
|
|
"""Parse arguments for extract command"""
|
|
parsed_headers, parsed_cookies = _ParseHeaders(headers)
|
|
if cookies:
|
|
for key, value in _CookieParser(cookies):
|
|
try:
|
|
parsed_cookies[key] = value
|
|
except Exception as err:
|
|
raise ValueError(f"Could not parse cookies '{cookies}': {err}")
|
|
|
|
parsed_json = __ParseJSONData(json)
|
|
parsed_params = {}
|
|
for param in params:
|
|
if "=" in param:
|
|
key, value = param.split("=", 1)
|
|
parsed_params[key] = value
|
|
|
|
return parsed_headers, parsed_cookies, parsed_params, parsed_json
|
|
|
|
|
|
def __BuildRequest(headers: List[str], cookies: str, params: str, json: Optional[str] = None, **kwargs) -> Dict:
|
|
"""Build a request object using the specified arguments"""
|
|
# Parse parameters
|
|
parsed_headers, parsed_cookies, parsed_params, parsed_json = __ParseExtractArguments(headers, cookies, params, json)
|
|
# Build request arguments
|
|
request_kwargs: Dict[str, Any] = {
|
|
"headers": parsed_headers if parsed_headers else None,
|
|
"cookies": parsed_cookies if parsed_cookies else None,
|
|
}
|
|
if parsed_json:
|
|
request_kwargs["json"] = parsed_json
|
|
if parsed_params:
|
|
request_kwargs["params"] = parsed_params
|
|
if "proxy" in kwargs:
|
|
request_kwargs["proxy"] = kwargs.pop("proxy")
|
|
|
|
# Parse impersonate parameter if it contains commas (for random selection)
|
|
if "impersonate" in kwargs and "," in (kwargs.get("impersonate") or ""):
|
|
kwargs["impersonate"] = [browser.strip() for browser in kwargs["impersonate"].split(",")]
|
|
|
|
return {**request_kwargs, **kwargs}
|
|
|
|
|
|
@command(help="Install all Scrapling's Fetchers dependencies")
|
|
@option(
|
|
"-f",
|
|
"--force",
|
|
"force",
|
|
is_flag=True,
|
|
default=False,
|
|
type=bool,
|
|
help="Force Scrapling to reinstall all Fetchers dependencies",
|
|
)
|
|
def install(force): # pragma: no cover
|
|
if force or not __PACKAGE_DIR__.joinpath(".scrapling_dependencies_installed").exists():
|
|
__Execute(
|
|
[python_executable, "-m", "playwright", "install", "chromium"],
|
|
"Playwright browsers",
|
|
)
|
|
__Execute(
|
|
[
|
|
python_executable,
|
|
"-m",
|
|
"playwright",
|
|
"install-deps",
|
|
"chromium",
|
|
],
|
|
"Playwright dependencies",
|
|
)
|
|
from tld.utils import update_tld_names
|
|
|
|
update_tld_names(fail_silently=True)
|
|
# if no errors raised by the above commands, then we add the below file
|
|
__PACKAGE_DIR__.joinpath(".scrapling_dependencies_installed").touch()
|
|
else:
|
|
print("The dependencies are already installed")
|
|
|
|
|
|
@command(help="Run Scrapling's MCP server (Check the docs for more info).")
|
|
@option(
|
|
"--http",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Whether to run the MCP server in streamable-http transport or leave it as stdio (Default: False)",
|
|
)
|
|
@option(
|
|
"--host",
|
|
type=str,
|
|
default="0.0.0.0",
|
|
help="The host to use if streamable-http transport is enabled (Default: '0.0.0.0')",
|
|
)
|
|
@option(
|
|
"--port", type=int, default=8000, help="The port to use if streamable-http transport is enabled (Default: 8000)"
|
|
)
|
|
@option(
|
|
"--executable-path",
|
|
type=str,
|
|
default=None,
|
|
help="Path to a custom Chromium-compatible browser executable for browser-based MCP tools",
|
|
)
|
|
def mcp(http, host, port, executable_path):
|
|
from scrapling.core.ai import ScraplingMCPServer
|
|
|
|
server = ScraplingMCPServer(executable_path=executable_path)
|
|
server.serve(http, host, port)
|
|
|
|
|
|
@command(help="Interactive scraping console")
|
|
@option(
|
|
"-c",
|
|
"--code",
|
|
"code",
|
|
is_flag=False,
|
|
default="",
|
|
type=str,
|
|
help="Evaluate the code in the shell, print the result and exit",
|
|
)
|
|
@option(
|
|
"-L",
|
|
"--loglevel",
|
|
"level",
|
|
is_flag=False,
|
|
default="debug",
|
|
type=Choice(["debug", "info", "warning", "error", "critical", "fatal"], case_sensitive=False),
|
|
help="Log level (default: DEBUG)",
|
|
)
|
|
def shell(code, level):
|
|
from scrapling.core.shell import CustomShell
|
|
|
|
console = CustomShell(code=code, log_level=level)
|
|
console.start()
|
|
|
|
|
|
@group(
|
|
help="Fetch web pages using various fetchers and extract full/selected HTML content as HTML, Markdown, or extract text content."
|
|
)
|
|
def extract():
|
|
"""Extract content from web pages and save to files"""
|
|
pass
|
|
|
|
|
|
####
|
|
# Shared Click option decorator factories
|
|
####
|
|
|
|
|
|
def _common_http_options(f):
|
|
"""Apply shared Click options for all HTTP extract commands (get/post/put/delete)."""
|
|
decorators = [
|
|
option(
|
|
"--ai-targeted",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Extract only main content and sanitize hidden elements for AI consumption (default: False)",
|
|
),
|
|
option(
|
|
"--stealthy-headers/--no-stealthy-headers",
|
|
default=True,
|
|
help="Use stealthy browser headers (default: True)",
|
|
),
|
|
option(
|
|
"--impersonate",
|
|
help="Browser to impersonate. Can be a single browser (e.g., chrome) or comma-separated list for random selection (e.g., chrome,firefox,safari).",
|
|
),
|
|
option(
|
|
"--verify/--no-verify",
|
|
default=True,
|
|
help="Whether to verify SSL certificates (default: True)",
|
|
),
|
|
option(
|
|
"--follow-redirects/--no-follow-redirects",
|
|
default=True,
|
|
help="Whether to follow redirects (default: True)",
|
|
),
|
|
option(
|
|
"--params",
|
|
"-p",
|
|
multiple=True,
|
|
help='Query parameters in format "key=value" (can be used multiple times)',
|
|
),
|
|
option(
|
|
"--css-selector",
|
|
"-s",
|
|
help="CSS selector to extract specific content from the page. It returns all matches.",
|
|
),
|
|
option("--proxy", help='Proxy URL in format "http://username:password@host:port"'),
|
|
option("--timeout", type=int, default=30, help="Request timeout in seconds (default: 30)"),
|
|
option("--cookies", help='Cookies string in format "name1=value1; name2=value2"'),
|
|
option(
|
|
"--headers",
|
|
"-H",
|
|
multiple=True,
|
|
help='HTTP headers in format "Key: Value" (can be used multiple times)',
|
|
),
|
|
]
|
|
for decorator in decorators:
|
|
f = decorator(f)
|
|
return f
|
|
|
|
|
|
def _common_browser_options(f):
|
|
"""Apply shared Click options for browser-based commands (fetch/stealthy_fetch)."""
|
|
decorators = [
|
|
option(
|
|
"--ai-targeted",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Extract only main content and sanitize hidden elements for AI consumption (default: False)",
|
|
),
|
|
option(
|
|
"--executable-path",
|
|
type=str,
|
|
default=None,
|
|
help="Path to a custom Chromium-compatible browser executable. Falls back to the SCRAPLING_EXECUTABLE_PATH environment variable when not set.",
|
|
),
|
|
option(
|
|
"--extra-headers",
|
|
"-H",
|
|
multiple=True,
|
|
help='Extra headers in format "Key: Value" (can be used multiple times)',
|
|
),
|
|
option("--proxy", help='Proxy URL in format "http://username:password@host:port"'),
|
|
option(
|
|
"--real-chrome/--no-real-chrome",
|
|
default=False,
|
|
help="If you have a Chrome browser installed on your device, enable this, and the Fetcher will launch an instance of your browser and use it. (default: False)",
|
|
),
|
|
option("--locale", default=None, help="Specify user locale. Defaults to the system default locale."),
|
|
option("--wait-selector", help="CSS selector to wait for before proceeding"),
|
|
option(
|
|
"--css-selector",
|
|
"-s",
|
|
help="CSS selector to extract specific content from the page. It returns all matches.",
|
|
),
|
|
option(
|
|
"--wait",
|
|
type=int,
|
|
default=0,
|
|
help="Additional wait time in milliseconds after page load (default: 0)",
|
|
),
|
|
option(
|
|
"--timeout",
|
|
type=int,
|
|
default=30000,
|
|
help="Timeout in milliseconds (default: 30000)",
|
|
),
|
|
option(
|
|
"--network-idle/--no-network-idle",
|
|
default=False,
|
|
help="Wait for network idle (default: False)",
|
|
),
|
|
option(
|
|
"--disable-resources/--enable-resources",
|
|
default=False,
|
|
help="Drop unnecessary resources for speed boost (default: False)",
|
|
),
|
|
option(
|
|
"--headless/--no-headless",
|
|
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)
|
|
return f
|
|
|
|
|
|
def _data_options(f):
|
|
"""Apply data/json options for POST and PUT commands."""
|
|
decorators = [
|
|
option("--json", "-j", help="JSON data to include in the request body (as string)"),
|
|
option(
|
|
"--data",
|
|
"-d",
|
|
help='Form data to include in the request body (as string, ex: "param1=value1¶m2=value2")',
|
|
),
|
|
]
|
|
for decorator in decorators:
|
|
f = decorator(f)
|
|
return f
|
|
|
|
|
|
def __http_command(
|
|
method_name: str, url: str, output_file: str, css_selector: Optional[str], ai_targeted: bool = False, **kwargs
|
|
) -> None:
|
|
"""Shared implementation for HTTP extract commands."""
|
|
from scrapling.fetchers import Fetcher
|
|
|
|
__Request_and_Save(getattr(Fetcher, method_name), url, output_file, css_selector, ai_targeted=ai_targeted, **kwargs)
|
|
|
|
|
|
@extract.command(help=f"Perform a GET request and save the content to a file.\n\n{__OUTPUT_FILE_HELP__}")
|
|
@argument("url", required=True)
|
|
@argument("output_file", required=True)
|
|
@_common_http_options
|
|
def get(
|
|
url,
|
|
output_file,
|
|
headers,
|
|
cookies,
|
|
timeout,
|
|
proxy,
|
|
css_selector,
|
|
params,
|
|
follow_redirects,
|
|
verify,
|
|
impersonate,
|
|
stealthy_headers,
|
|
ai_targeted,
|
|
):
|
|
"""Perform a GET request and save the content to a file."""
|
|
kwargs = __BuildRequest(
|
|
headers,
|
|
cookies,
|
|
params,
|
|
None,
|
|
timeout=timeout,
|
|
follow_redirects=follow_redirects,
|
|
verify=verify,
|
|
stealthy_headers=stealthy_headers,
|
|
impersonate=impersonate,
|
|
proxy=proxy,
|
|
)
|
|
__http_command("get", url, output_file, css_selector, ai_targeted=ai_targeted, **kwargs)
|
|
|
|
|
|
@extract.command(help=f"Perform a POST request and save the content to a file.\n\n{__OUTPUT_FILE_HELP__}")
|
|
@argument("url", required=True)
|
|
@argument("output_file", required=True)
|
|
@_data_options
|
|
@_common_http_options
|
|
def post(
|
|
url,
|
|
output_file,
|
|
data,
|
|
json,
|
|
headers,
|
|
cookies,
|
|
timeout,
|
|
proxy,
|
|
css_selector,
|
|
params,
|
|
follow_redirects,
|
|
verify,
|
|
impersonate,
|
|
stealthy_headers,
|
|
ai_targeted,
|
|
):
|
|
"""Perform a POST request and save the content to a file."""
|
|
kwargs = __BuildRequest(
|
|
headers,
|
|
cookies,
|
|
params,
|
|
json,
|
|
timeout=timeout,
|
|
follow_redirects=follow_redirects,
|
|
verify=verify,
|
|
stealthy_headers=stealthy_headers,
|
|
impersonate=impersonate,
|
|
proxy=proxy,
|
|
data=data,
|
|
)
|
|
__http_command("post", url, output_file, css_selector, ai_targeted=ai_targeted, **kwargs)
|
|
|
|
|
|
@extract.command(help=f"Perform a PUT request and save the content to a file.\n\n{__OUTPUT_FILE_HELP__}")
|
|
@argument("url", required=True)
|
|
@argument("output_file", required=True)
|
|
@_data_options
|
|
@_common_http_options
|
|
def put(
|
|
url,
|
|
output_file,
|
|
data,
|
|
json,
|
|
headers,
|
|
cookies,
|
|
timeout,
|
|
proxy,
|
|
css_selector,
|
|
params,
|
|
follow_redirects,
|
|
verify,
|
|
impersonate,
|
|
stealthy_headers,
|
|
ai_targeted,
|
|
):
|
|
"""Perform a PUT request and save the content to a file."""
|
|
kwargs = __BuildRequest(
|
|
headers,
|
|
cookies,
|
|
params,
|
|
json,
|
|
timeout=timeout,
|
|
follow_redirects=follow_redirects,
|
|
verify=verify,
|
|
stealthy_headers=stealthy_headers,
|
|
impersonate=impersonate,
|
|
proxy=proxy,
|
|
data=data,
|
|
)
|
|
__http_command("put", url, output_file, css_selector, ai_targeted=ai_targeted, **kwargs)
|
|
|
|
|
|
@extract.command(help=f"Perform a DELETE request and save the content to a file.\n\n{__OUTPUT_FILE_HELP__}")
|
|
@argument("url", required=True)
|
|
@argument("output_file", required=True)
|
|
@_common_http_options
|
|
def delete(
|
|
url,
|
|
output_file,
|
|
headers,
|
|
cookies,
|
|
timeout,
|
|
proxy,
|
|
css_selector,
|
|
params,
|
|
follow_redirects,
|
|
verify,
|
|
impersonate,
|
|
stealthy_headers,
|
|
ai_targeted,
|
|
):
|
|
"""Perform a DELETE request and save the content to a file."""
|
|
kwargs = __BuildRequest(
|
|
headers,
|
|
cookies,
|
|
params,
|
|
None,
|
|
timeout=timeout,
|
|
follow_redirects=follow_redirects,
|
|
verify=verify,
|
|
stealthy_headers=stealthy_headers,
|
|
impersonate=impersonate,
|
|
proxy=proxy,
|
|
)
|
|
__http_command("delete", url, output_file, css_selector, ai_targeted=ai_targeted, **kwargs)
|
|
|
|
|
|
def __build_browser_kwargs(
|
|
headless,
|
|
disable_resources,
|
|
network_idle,
|
|
timeout,
|
|
wait,
|
|
wait_selector,
|
|
locale,
|
|
real_chrome,
|
|
proxy,
|
|
parsed_headers,
|
|
dns_over_https,
|
|
block_ads,
|
|
executable_path,
|
|
) -> Dict[str, Any]:
|
|
"""Build shared kwargs dict for browser-based commands."""
|
|
kwargs: Dict[str, Any] = {
|
|
"headless": headless,
|
|
"disable_resources": disable_resources,
|
|
"network_idle": network_idle,
|
|
"timeout": timeout,
|
|
"locale": locale,
|
|
"real_chrome": real_chrome,
|
|
"dns_over_https": dns_over_https,
|
|
"block_ads": block_ads,
|
|
}
|
|
if wait > 0:
|
|
kwargs["wait"] = wait
|
|
if wait_selector:
|
|
kwargs["wait_selector"] = wait_selector
|
|
if proxy:
|
|
kwargs["proxy"] = proxy
|
|
if parsed_headers:
|
|
kwargs["extra_headers"] = parsed_headers
|
|
executable_path = executable_path or environ.get("SCRAPLING_EXECUTABLE_PATH") or None
|
|
if executable_path:
|
|
kwargs["executable_path"] = executable_path
|
|
return kwargs
|
|
|
|
|
|
@extract.command(help=f"Use DynamicFetcher to fetch content with browser automation.\n\n{__OUTPUT_FILE_HELP__}")
|
|
@argument("url", required=True)
|
|
@argument("output_file", required=True)
|
|
@_common_browser_options
|
|
def fetch(
|
|
url,
|
|
output_file,
|
|
headless,
|
|
disable_resources,
|
|
network_idle,
|
|
timeout,
|
|
wait,
|
|
css_selector,
|
|
wait_selector,
|
|
locale,
|
|
real_chrome,
|
|
proxy,
|
|
extra_headers,
|
|
ai_targeted,
|
|
executable_path,
|
|
dns_over_https,
|
|
block_ads,
|
|
):
|
|
"""Opens up a browser and fetch content using DynamicFetcher."""
|
|
parsed_headers, _ = _ParseHeaders(extra_headers, False)
|
|
kwargs = __build_browser_kwargs(
|
|
headless,
|
|
disable_resources,
|
|
network_idle,
|
|
timeout,
|
|
wait,
|
|
wait_selector,
|
|
locale,
|
|
real_chrome,
|
|
proxy,
|
|
parsed_headers,
|
|
dns_over_https,
|
|
block_ads,
|
|
executable_path,
|
|
)
|
|
from scrapling.fetchers import DynamicFetcher
|
|
|
|
__Request_and_Save(DynamicFetcher.fetch, url, output_file, css_selector, ai_targeted=ai_targeted, **kwargs)
|
|
|
|
|
|
@extract.command(help=f"Use StealthyFetcher to fetch content with advanced stealth features.\n\n{__OUTPUT_FILE_HELP__}")
|
|
@argument("url", required=True)
|
|
@argument("output_file", required=True)
|
|
@option(
|
|
"--block-webrtc/--allow-webrtc",
|
|
default=False,
|
|
help="Block WebRTC entirely (default: False)",
|
|
)
|
|
@option(
|
|
"--solve-cloudflare/--no-solve-cloudflare",
|
|
default=False,
|
|
help="Solve Cloudflare challenges (default: False)",
|
|
)
|
|
@option("--allow-webgl/--block-webgl", default=True, help="Allow WebGL (default: True)")
|
|
@option(
|
|
"--hide-canvas/--show-canvas",
|
|
default=False,
|
|
help="Add noise to canvas operations (default: False)",
|
|
)
|
|
@_common_browser_options
|
|
def stealthy_fetch(
|
|
url,
|
|
output_file,
|
|
headless,
|
|
disable_resources,
|
|
network_idle,
|
|
timeout,
|
|
wait,
|
|
css_selector,
|
|
wait_selector,
|
|
locale,
|
|
real_chrome,
|
|
proxy,
|
|
extra_headers,
|
|
block_webrtc,
|
|
solve_cloudflare,
|
|
allow_webgl,
|
|
hide_canvas,
|
|
ai_targeted,
|
|
executable_path,
|
|
dns_over_https,
|
|
block_ads,
|
|
):
|
|
"""Opens up a browser with advanced stealth features and fetch content using StealthyFetcher."""
|
|
parsed_headers, _ = _ParseHeaders(extra_headers, False)
|
|
kwargs = __build_browser_kwargs(
|
|
headless,
|
|
disable_resources,
|
|
network_idle,
|
|
timeout,
|
|
wait,
|
|
wait_selector,
|
|
locale,
|
|
real_chrome,
|
|
proxy,
|
|
parsed_headers,
|
|
dns_over_https,
|
|
block_ads,
|
|
executable_path,
|
|
)
|
|
kwargs.update(
|
|
{
|
|
"block_webrtc": block_webrtc,
|
|
"solve_cloudflare": solve_cloudflare,
|
|
"allow_webgl": allow_webgl,
|
|
"hide_canvas": hide_canvas,
|
|
}
|
|
)
|
|
from scrapling.fetchers import StealthyFetcher
|
|
|
|
__Request_and_Save(StealthyFetcher.fetch, url, output_file, css_selector, ai_targeted=ai_targeted, **kwargs)
|
|
|
|
|
|
@group()
|
|
@version_option(version=__version__, prog_name="Scrapling")
|
|
def main():
|
|
pass
|
|
|
|
|
|
# Adding commands
|
|
main.add_command(install)
|
|
main.add_command(shell)
|
|
main.add_command(extract)
|
|
main.add_command(mcp)
|