From 31e838ce9b08bee1795b0ddfa81134a84c123b09 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Wed, 4 Dec 2024 14:27:36 +0200 Subject: [PATCH 01/46] Make standard string methods return TextHandler again instead of str --- scrapling/core/custom_types.py | 71 +++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/scrapling/core/custom_types.py b/scrapling/core/custom_types.py index b8cb44f..f952da7 100644 --- a/scrapling/core/custom_types.py +++ b/scrapling/core/custom_types.py @@ -14,11 +14,70 @@ class TextHandler(str): __slots__ = () def __new__(cls, string): - # Because str is immutable and we can't override __init__ - if type(string) is str: + if isinstance(string, str): return super().__new__(cls, string) - else: - return super().__new__(cls, '') + return super().__new__(cls, '') + + # Make methods from original `str` class return `TextHandler` instead of returning `str` again + # Of course, this stupid workaround is only so we can keep the auto-completion working without issues in your IDE + # and I made sonnet write it for me :) + def strip(self, chars=None): + return TextHandler(super().strip(chars)) + + def lstrip(self, chars=None): + return TextHandler(super().lstrip(chars)) + + def rstrip(self, chars=None): + return TextHandler(super().rstrip(chars)) + + def capitalize(self): + return TextHandler(super().capitalize()) + + def casefold(self): + return TextHandler(super().casefold()) + + def center(self, width, fillchar=' '): + return TextHandler(super().center(width, fillchar)) + + def expandtabs(self, tabsize=8): + return TextHandler(super().expandtabs(tabsize)) + + def format(self, *args, **kwargs): + return TextHandler(super().format(*args, **kwargs)) + + def format_map(self, mapping): + return TextHandler(super().format_map(mapping)) + + def join(self, iterable): + return TextHandler(super().join(iterable)) + + def ljust(self, width, fillchar=' '): + return TextHandler(super().ljust(width, fillchar)) + + def rjust(self, width, fillchar=' '): + return TextHandler(super().rjust(width, fillchar)) + + def swapcase(self): + return TextHandler(super().swapcase()) + + def title(self): + return TextHandler(super().title()) + + def translate(self, table): + return TextHandler(super().translate(table)) + + def zfill(self, width): + return TextHandler(super().zfill(width)) + + def replace(self, old, new, count=-1): + return TextHandler(super().replace(old, new, count)) + + def upper(self): + return TextHandler(super().upper()) + + def lower(self): + return TextHandler(super().lower()) + ############## def sort(self, reverse: bool = False) -> str: """Return a sorted version of the string""" @@ -32,9 +91,9 @@ def clean(self) -> str: def json(self) -> Dict: """Return json response if the response is jsonable otherwise throw error""" - # Using __str__ function as a workaround for orjson issue with subclasses of str + # Using str function as a workaround for orjson issue with subclasses of str # Check this out: https://github.com/ijl/orjson/issues/445 - return loads(self.__str__()) + return loads(str(self)) def re( self, regex: Union[str, Pattern[str]], replace_entities: bool = True, clean_match: bool = False, From 45e86f50a37a211c7224fb294d359211a71929d3 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Wed, 4 Dec 2024 14:28:37 +0200 Subject: [PATCH 02/46] Adding empty methods (get/get_all/extract/extract_all) For easy copy-paste from Scrapy/parsel code when needed :) --- scrapling/core/custom_types.py | 23 +++++++++++++++++++++++ scrapling/parser.py | 17 +++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/scrapling/core/custom_types.py b/scrapling/core/custom_types.py index f952da7..0419406 100644 --- a/scrapling/core/custom_types.py +++ b/scrapling/core/custom_types.py @@ -89,6 +89,16 @@ def clean(self) -> str: data = re.sub(' +', ' ', data) return self.__class__(data.strip()) + # For easy copy-paste from Scrapy/parsel code when needed :) + def get(self, default=None): + return self + + def get_all(self): + return self + + extract = get_all + extract_first = get + def json(self) -> Dict: """Return json response if the response is jsonable otherwise throw error""" # Using str function as a workaround for orjson issue with subclasses of str @@ -186,6 +196,19 @@ def re_first(self, regex: Union[str, Pattern[str]], default=None, replace_entiti return result return default + # For easy copy-paste from Scrapy/parsel code when needed :) + def get(self, default=None): + """Returns the first item of the current list + :param default: the default value to return if the current list is empty + """ + return self[0] if len(self) > 0 else default + + def extract(self): + return self + + extract_first = get + get_all = extract + class AttributesHandler(Mapping): """A read-only mapping to use instead of the standard dictionary for the speed boost but at the same time I use it to add more functionalities. diff --git a/scrapling/parser.py b/scrapling/parser.py index daaa8c4..169c9e7 100644 --- a/scrapling/parser.py +++ b/scrapling/parser.py @@ -330,6 +330,16 @@ def previous(self) -> Union['Adaptor', None]: return self.__convert_results(prev_element) + # For easy copy-paste from Scrapy/parsel code when needed :) + def get(self, default=None): + return self + + def get_all(self): + return self + + extract = get_all + extract_first = get + def __str__(self) -> str: return self.html_content @@ -1073,12 +1083,19 @@ def filter(self, func: Callable[['Adaptor'], bool]) -> Union['Adaptors', List]: ] return self.__class__(results) if results else results + # For easy copy-paste from Scrapy/parsel code when needed :) def get(self, default=None): """Returns the first item of the current list :param default: the default value to return if the current list is empty """ return self[0] if len(self) > 0 else default + def extract(self): + return self + + extract_first = get + get_all = extract + @property def first(self): """Returns the first item of the current list or `None` if the list is empty""" From d0f18951acd44244ad94d44f4c87e56650ab7ee2 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Wed, 4 Dec 2024 23:38:30 +0200 Subject: [PATCH 03/46] fix: Enable WebGL by default --- README.md | 2 +- scrapling/engines/camo.py | 4 ++-- scrapling/fetchers.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9f7a86e..0259bb1 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ True | page_action | Added for automation. A function that takes the `page` object, does the automation you need, then returns `page` again. | ✔️ | | addons | List of Firefox addons to use. **Must be paths to extracted addons.** | ✔️ | | humanize | Humanize the cursor movement. Takes either True or the MAX duration in seconds of the cursor movement. The cursor typically takes up to 1.5 seconds to move across the window. | ✔️ | -| allow_webgl | Whether to allow WebGL. To prevent leaks, only use this for special cases. | ✔️ | +| allow_webgl | Enabled by default. Disabling it WebGL not recommended as many WAFs now checks if WebGL is enabled. | ✔️ | | disable_ads | Enabled by default, this installs `uBlock Origin` addon on the browser if enabled. | ✔️ | | network_idle | Wait for the page until there are no network connections for at least 500 ms. | ✔️ | | timeout | The timeout in milliseconds that is used in all operations and waits through the page. The default is 30000. | ✔️ | diff --git a/scrapling/engines/camo.py b/scrapling/engines/camo.py index 2741206..e7a6575 100644 --- a/scrapling/engines/camo.py +++ b/scrapling/engines/camo.py @@ -15,7 +15,7 @@ class CamoufoxEngine: def __init__( self, headless: Optional[Union[bool, Literal['virtual']]] = True, block_images: Optional[bool] = False, disable_resources: Optional[bool] = False, - block_webrtc: Optional[bool] = False, allow_webgl: Optional[bool] = False, network_idle: Optional[bool] = False, humanize: Optional[Union[bool, float]] = True, + block_webrtc: Optional[bool] = False, allow_webgl: Optional[bool] = True, network_idle: Optional[bool] = False, humanize: Optional[Union[bool, float]] = True, timeout: Optional[float] = 30000, page_action: Callable = do_nothing, wait_selector: Optional[str] = None, addons: Optional[List[str]] = None, wait_selector_state: str = 'attached', google_search: Optional[bool] = True, extra_headers: Optional[Dict[str, str]] = None, proxy: Optional[Union[str, Dict[str, str]]] = None, os_randomize: Optional[bool] = None, disable_ads: Optional[bool] = True, @@ -32,7 +32,7 @@ def __init__( :param block_webrtc: Blocks WebRTC entirely. :param addons: List of Firefox addons to use. Must be paths to extracted addons. :param humanize: Humanize the cursor movement. Takes either True or the MAX duration in seconds of the cursor movement. The cursor typically takes up to 1.5 seconds to move across the window. - :param allow_webgl: Whether to allow WebGL. To prevent leaks, only use this for special cases. + :param allow_webgl: Enabled by default. Disabling it WebGL not recommended as many WAFs now checks if WebGL is enabled. :param network_idle: Wait for the page until there are no network connections for at least 500 ms. :param disable_ads: Enabled by default, this installs `uBlock Origin` addon on the browser if enabled. :param os_randomize: If enabled, Scrapling will randomize the OS fingerprints used. The default is Scrapling matching the fingerprints with the current OS. diff --git a/scrapling/fetchers.py b/scrapling/fetchers.py index 619f2f8..94059ab 100644 --- a/scrapling/fetchers.py +++ b/scrapling/fetchers.py @@ -80,7 +80,7 @@ class StealthyFetcher(BaseFetcher): """ def fetch( self, url: str, headless: Optional[Union[bool, Literal['virtual']]] = True, block_images: Optional[bool] = False, disable_resources: Optional[bool] = False, - block_webrtc: Optional[bool] = False, allow_webgl: Optional[bool] = False, network_idle: Optional[bool] = False, addons: Optional[List[str]] = None, + block_webrtc: Optional[bool] = False, allow_webgl: Optional[bool] = True, network_idle: Optional[bool] = False, addons: Optional[List[str]] = None, timeout: Optional[float] = 30000, page_action: Callable = do_nothing, wait_selector: Optional[str] = None, humanize: Optional[Union[bool, float]] = True, wait_selector_state: str = 'attached', google_search: Optional[bool] = True, extra_headers: Optional[Dict[str, str]] = None, proxy: Optional[Union[str, Dict[str, str]]] = None, os_randomize: Optional[bool] = None, disable_ads: Optional[bool] = True, @@ -99,7 +99,7 @@ def fetch( :param addons: List of Firefox addons to use. Must be paths to extracted addons. :param disable_ads: Enabled by default, this installs `uBlock Origin` addon on the browser if enabled. :param humanize: Humanize the cursor movement. Takes either True or the MAX duration in seconds of the cursor movement. The cursor typically takes up to 1.5 seconds to move across the window. - :param allow_webgl: Whether to allow WebGL. To prevent leaks, only use this for special cases. + :param allow_webgl: Enabled by default. Disabling it WebGL not recommended as many WAFs now checks if WebGL is enabled. :param network_idle: Wait for the page until there are no network connections for at least 500 ms. :param os_randomize: If enabled, Scrapling will randomize the OS fingerprints used. The default is Scrapling matching the fingerprints with the current OS. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30000 From 5f9c3980dc5c28062f3240e547841d99e0eb7674 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Tue, 10 Dec 2024 22:02:43 +0200 Subject: [PATCH 04/46] Adding `urljoin` method to Adaptors and Responses --- scrapling/parser.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scrapling/parser.py b/scrapling/parser.py index 169c9e7..85fd79e 100644 --- a/scrapling/parser.py +++ b/scrapling/parser.py @@ -2,6 +2,7 @@ import os import re from difflib import SequenceMatcher +from urllib.parse import urljoin from cssselect import SelectorError, SelectorSyntaxError from cssselect import parse as split_selectors @@ -243,6 +244,10 @@ def _traverse(node: html.HtmlElement) -> None: return TextHandler(separator.join([s for s in _all_strings])) + def urljoin(self, relative_url: str) -> str: + """Join this Adaptor's url with a relative url to form an absolute full URL.""" + return urljoin(self.url, relative_url) + @property def attrib(self) -> AttributesHandler: """Get attributes of the element""" From b4f90615c32ac649af175fb8ea1b47fafaeac02a Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Tue, 10 Dec 2024 22:06:08 +0200 Subject: [PATCH 05/46] Adding `keep_cdata` argument for `Adaptor` and `Response` classes This will force lxml to keep cdata while parsing html if you want --- README.md | 2 +- scrapling/engines/toolbelt/custom.py | 4 +++- scrapling/parser.py | 10 +++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0259bb1..2f8455c 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ You might be slightly confused by now so let me clear things up. All fetcher-typ ```python from scrapling import Fetcher, StealthyFetcher, PlayWrightFetcher ``` -All of them can take these initialization arguments: `auto_match`, `huge_tree`, `keep_comments`, `storage`, `storage_args`, and `debug`, which are the same ones you give to the `Adaptor` class. +All of them can take these initialization arguments: `auto_match`, `huge_tree`, `keep_comments`, `keep_cdata`, `storage`, `storage_args`, and `debug`, which are the same ones you give to the `Adaptor` class. If you don't want to pass arguments to the generated `Adaptor` object and want to use the default values, you can use this import instead for cleaner code: ```python diff --git a/scrapling/engines/toolbelt/custom.py b/scrapling/engines/toolbelt/custom.py index 6e321cc..eabd219 100644 --- a/scrapling/engines/toolbelt/custom.py +++ b/scrapling/engines/toolbelt/custom.py @@ -105,7 +105,7 @@ class BaseFetcher: def __init__( self, huge_tree: bool = True, keep_comments: Optional[bool] = False, auto_match: Optional[bool] = True, storage: Any = SQLiteStorageSystem, storage_args: Optional[Dict] = None, debug: Optional[bool] = False, - automatch_domain: Optional[str] = None, + automatch_domain: Optional[str] = None, keep_cdata: Optional[bool] = False, ): """Arguments below are the same from the Adaptor class so you can pass them directly, the rest of Adaptor's arguments are detected and passed automatically from the Fetcher based on the response for accessibility. @@ -113,6 +113,7 @@ def __init__( :param huge_tree: Enabled by default, should always be enabled when parsing large HTML documents. This controls libxml2 feature that forbids parsing certain large documents to protect from possible memory exhaustion. :param keep_comments: While parsing the HTML body, drop comments or not. Disabled by default for obvious reasons + :param keep_cdata: While parsing the HTML body, drop cdata or not. Disabled by default for cleaner HTML. :param auto_match: Globally turn-off the auto-match feature in all functions, this argument takes higher priority over all auto-match related arguments/functions in the class. :param storage: The storage class to be passed for auto-matching functionalities, see ``Docs`` for more info. @@ -127,6 +128,7 @@ def __init__( self.adaptor_arguments = dict( huge_tree=huge_tree, keep_comments=keep_comments, + keep_cdata=keep_cdata, auto_match=auto_match, storage=storage, storage_args=storage_args, diff --git a/scrapling/parser.py b/scrapling/parser.py index 85fd79e..7171080 100644 --- a/scrapling/parser.py +++ b/scrapling/parser.py @@ -25,6 +25,7 @@ class Adaptor(SelectorsGeneration): __slots__ = ( 'url', 'encoding', '__auto_match_enabled', '_root', '_storage', '__debug', '__keep_comments', '__huge_tree_enabled', '__attributes', '__text', '__tag', + '__keep_cdata', ) def __init__( @@ -36,6 +37,7 @@ def __init__( huge_tree: bool = True, root: Optional[html.HtmlElement] = None, keep_comments: Optional[bool] = False, + keep_cdata: Optional[bool] = False, auto_match: Optional[bool] = True, storage: Any = SQLiteStorageSystem, storage_args: Optional[Dict] = None, @@ -59,6 +61,7 @@ def __init__( :param root: Used internally to pass etree objects instead of text/body arguments, it takes highest priority. Don't use it unless you know what you are doing! :param keep_comments: While parsing the HTML body, drop comments or not. Disabled by default for obvious reasons + :param keep_cdata: While parsing the HTML body, drop cdata or not. Disabled by default for cleaner HTML. :param auto_match: Globally turn-off the auto-match feature in all functions, this argument takes higher priority over all auto-match related arguments/functions in the class. :param storage: The storage class to be passed for auto-matching functionalities, see ``Docs`` for more info. @@ -84,8 +87,8 @@ def __init__( # https://lxml.de/api/lxml.etree.HTMLParser-class.html parser = html.HTMLParser( - recover=True, remove_blank_text=True, remove_comments=(keep_comments is False), encoding=encoding, - compact=True, huge_tree=huge_tree, default_doctype=True + recover=True, remove_blank_text=True, remove_comments=(not keep_comments), encoding=encoding, + compact=True, huge_tree=huge_tree, default_doctype=True, strip_cdata=(not keep_cdata), ) self._root = etree.fromstring(body, parser=parser, base_url=url) if is_jsonable(text or body.decode()): @@ -119,6 +122,7 @@ def __init__( self._storage = storage(**storage_args) self.__keep_comments = keep_comments + self.__keep_cdata = keep_cdata self.__huge_tree_enabled = huge_tree self.encoding = encoding self.url = url @@ -156,7 +160,7 @@ def __get_correct_result( root=element, text='', body=b'', # Since root argument is provided, both `text` and `body` will be ignored so this is just a filler url=self.url, encoding=self.encoding, auto_match=self.__auto_match_enabled, - keep_comments=True, # if the comments are already removed in initialization, no need to try to delete them in sub-elements + keep_comments=self.__keep_comments, keep_cdata=self.__keep_cdata, huge_tree=self.__huge_tree_enabled, debug=self.__debug, **self.__response_data ) From 93ed76831207da617895ec405f5bd8575b2a2983 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Tue, 10 Dec 2024 22:22:34 +0200 Subject: [PATCH 06/46] Bumping up the libraries versions for better stealth and speed! --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 0a29929..bfbb741 100644 --- a/setup.py +++ b/setup.py @@ -55,9 +55,9 @@ "orjson>=3", "tldextract", 'httpx[brotli,zstd]', - 'playwright==1.48', # Temporary because currently All libraries that provide CDP patches doesn't support playwright 1.49 yet - 'rebrowser-playwright', - 'camoufox>=0.4.4', + 'playwright>=1.49.1', + 'rebrowser-playwright>=1.49.1', + 'camoufox>=0.4.7', 'browserforge', ], python_requires=">=3.8", From e60a57c63fe80ce25c94243c735d201631cf92b4 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Tue, 10 Dec 2024 22:37:37 +0200 Subject: [PATCH 07/46] Python 3.8 is not supported anymore It's not supported in the new version of Playwright and is problematic in some situations while being slower than newer versions. --- .pre-commit-config.yaml | 2 +- setup.py | 2 +- tox.ini | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9e3cf04..4b90529 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,4 +16,4 @@ repos: rev: v1.6.0 hooks: - id: vermin - args: ['-t=3.8-', '--violations', '--eval-annotations', '--no-tips'] + args: ['-t=3.9-', '--violations', '--eval-annotations', '--no-tips'] diff --git a/setup.py b/setup.py index bfbb741..7d706c1 100644 --- a/setup.py +++ b/setup.py @@ -60,7 +60,7 @@ 'camoufox>=0.4.7', 'browserforge', ], - python_requires=">=3.8", + python_requires=">=3.9", url="https://github.com/D4Vinci/Scrapling", project_urls={ "Documentation": "https://github.com/D4Vinci/Scrapling/tree/main/docs", # For now diff --git a/tox.ini b/tox.ini index 28b09e1..b539b49 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = pre-commit,py{38,39,310,311,312,313} +envlist = pre-commit,py{39,310,311,312,313} [testenv] usedevelop = True @@ -15,8 +15,7 @@ commands = playwright install chromium playwright install-deps chromium firefox camoufox fetch --browserforge - py38: pytest --config-file=pytest.ini --cov=scrapling --cov-report=xml - py{39,310,311,312,313}: pytest --config-file=pytest.ini --cov=scrapling --cov-report=xml -n auto + pytest --config-file=pytest.ini --cov=scrapling --cov-report=xml -n auto [testenv:pre-commit] basepython = python3 From ba11c433ffbfb6a93aab252a7ac8bae7f2ef919c Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Tue, 10 Dec 2024 22:56:51 +0200 Subject: [PATCH 08/46] Preparing to release 0.2.9 soon --- scrapling/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scrapling/__init__.py b/scrapling/__init__.py index 9240821..081d7ea 100644 --- a/scrapling/__init__.py +++ b/scrapling/__init__.py @@ -5,7 +5,7 @@ from scrapling.parser import Adaptor, Adaptors __author__ = "Karim Shoair (karim.shoair@pm.me)" -__version__ = "0.2.8" +__version__ = "0.2.9" __copyright__ = "Copyright (c) 2024 Karim Shoair" diff --git a/setup.cfg b/setup.cfg index 84169d5..b8b9227 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = scrapling -version = 0.2.8 +version = 0.2.9 author = Karim Shoair author_email = karim.shoair@pm.me description = Scrapling is an undetectable, powerful, flexible, adaptive, and high-performance web scraping library for Python. diff --git a/setup.py b/setup.py index 7d706c1..2076c14 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="scrapling", - version="0.2.8", + version="0.2.9", description="""Scrapling is a powerful, flexible, and high-performance web scraping library for Python. It simplifies the process of extracting data from websites, even when they undergo structural changes, and offers impressive speed improvements over many popular scraping tools.""", From 70b54241e45c1f07e68767471783f1d851bf8ec1 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Tue, 10 Dec 2024 23:32:54 +0200 Subject: [PATCH 09/46] Update tox.ini --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b539b49..b78af38 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ commands = playwright install chromium playwright install-deps chromium firefox camoufox fetch --browserforge - pytest --config-file=pytest.ini --cov=scrapling --cov-report=xml -n auto + pytest --cov=scrapling --cov-report=xml -n auto [testenv:pre-commit] basepython = python3 From e3324444118aa656a9d834c59936b8a272f045fa Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Tue, 10 Dec 2024 23:35:21 +0200 Subject: [PATCH 10/46] Update tests.yml --- .github/workflows/tests.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 473435b..0376d52 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,10 +17,6 @@ jobs: fail-fast: false matrix: include: - - python-version: "3.8" - os: ubuntu-latest - env: - TOXENV: py - python-version: "3.9" os: ubuntu-latest env: From 9f0001af26a8a2d214be34c724132744eea8b60a Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Wed, 11 Dec 2024 20:14:32 +0200 Subject: [PATCH 11/46] feat: logging for response status --- scrapling/core/utils.py | 5 +++-- scrapling/engines/toolbelt/custom.py | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/scrapling/core/utils.py b/scrapling/core/utils.py index 35f8d0a..a911757 100644 --- a/scrapling/core/utils.py +++ b/scrapling/core/utils.py @@ -14,8 +14,9 @@ html_forbidden = {html.HtmlComment, } logging.basicConfig( - level=logging.ERROR, - format='%(asctime)s - %(levelname)s - %(message)s', + level=logging.INFO, + format="[%(asctime)s] %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", handlers=[ logging.StreamHandler() ] diff --git a/scrapling/engines/toolbelt/custom.py b/scrapling/engines/toolbelt/custom.py index eabd219..7c8acd6 100644 --- a/scrapling/engines/toolbelt/custom.py +++ b/scrapling/engines/toolbelt/custom.py @@ -85,7 +85,8 @@ def get_value(cls, content_type: Optional[str], text: Optional[str] = 'test') -> class Response(Adaptor): """This class is returned by all engines as a way to unify response type between different libraries.""" - def __init__(self, url: str, text: str, body: bytes, status: int, reason: str, cookies: Dict, headers: Dict, request_headers: Dict, encoding: str = 'utf-8', **adaptor_arguments: Dict): + def __init__(self, url: str, text: str, body: bytes, status: int, reason: str, cookies: Dict, headers: Dict, request_headers: Dict, + encoding: str = 'utf-8', method: str = 'GET', **adaptor_arguments: Dict): automatch_domain = adaptor_arguments.pop('automatch_domain', None) self.status = status self.reason = reason @@ -96,6 +97,8 @@ def __init__(self, url: str, text: str, body: bytes, status: int, reason: str, c super().__init__(text=text, body=body, url=automatch_domain or url, encoding=encoding, **adaptor_arguments) # For back-ward compatibility self.adaptor = self + # For easier debugging while working from a Python shell + logging.info(f'Fetched ({status}) <{method} {url}> (referer: {request_headers.get("referer")})') # def __repr__(self): # return f'<{self.__class__.__name__} [{self.status} {self.reason}]>' From dcf51876a571fd6542867d351b6c5a8afebebb1a Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Wed, 11 Dec 2024 20:18:05 +0200 Subject: [PATCH 12/46] fix: Adaptor.body returns raw HTML without processing If possible, otherwise returns `Adaptor.html_content` --- scrapling/parser.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scrapling/parser.py b/scrapling/parser.py index 7171080..123fc6b 100644 --- a/scrapling/parser.py +++ b/scrapling/parser.py @@ -25,7 +25,7 @@ class Adaptor(SelectorsGeneration): __slots__ = ( 'url', 'encoding', '__auto_match_enabled', '_root', '_storage', '__debug', '__keep_comments', '__huge_tree_enabled', '__attributes', '__text', '__tag', - '__keep_cdata', + '__keep_cdata', '__raw_body' ) def __init__( @@ -73,17 +73,20 @@ def __init__( raise ValueError("Adaptor class needs text, body, or root arguments to work") self.__text = None + self.__raw_body = '' if root is None: if text is None: if not body or not isinstance(body, bytes): raise TypeError(f"body argument must be valid and of type bytes, got {body.__class__}") body = body.replace(b"\x00", b"").strip() + self.__raw_body = body.replace(b"\x00", b"").strip().decode() else: if not isinstance(text, str): raise TypeError(f"text argument must be of type str, got {text.__class__}") body = text.strip().replace("\x00", "").encode(encoding) or b"" + self.__raw_body = text.strip() # https://lxml.de/api/lxml.etree.HTMLParser-class.html parser = html.HTMLParser( @@ -264,7 +267,10 @@ def html_content(self) -> str: """Return the inner html code of the element""" return etree.tostring(self._root, encoding='unicode', method='html', with_tail=False) - body = html_content + @property + def body(self) -> str: + """Return raw HTML code of the element/page without any processing when possible or return `Adaptor.html_content`""" + return self.__raw_body or self.html_content def prettify(self) -> str: """Return a prettified version of the element's inner html-code""" From f30eb6ab6c83a2bfbee0cd6d5b4f27ffc23e512a Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Wed, 11 Dec 2024 20:27:07 +0200 Subject: [PATCH 13/46] build: disable the 404 error test for playwright --- tests/fetchers/test_playwright.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/fetchers/test_playwright.py b/tests/fetchers/test_playwright.py index dda30e0..039f124 100644 --- a/tests/fetchers/test_playwright.py +++ b/tests/fetchers/test_playwright.py @@ -22,7 +22,8 @@ def setUp(self): def test_basic_fetch(self): """Test doing basic fetch request with multiple statuses""" self.assertEqual(self.fetcher.fetch(self.status_200).status, 200) - self.assertEqual(self.fetcher.fetch(self.status_404).status, 404) + # There's a bug with playwright makes it crashes if a URL returns status code 404 without body, let's disable this till they reply to my issue report + # self.assertEqual(self.fetcher.fetch(self.status_404).status, 404) self.assertEqual(self.fetcher.fetch(self.status_501).status, 501) def test_networkidle(self): From 193827e27b2b7c1b06752474ca282966782b07d4 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Wed, 11 Dec 2024 21:41:37 +0200 Subject: [PATCH 14/46] refactor(api)!: Unifying log under 1 logger and removing debug parameter So now you control the logging and the debugging from the shell through the logger with the name 'scrapling' --- .github/ISSUE_TEMPLATE/01-bug_report.yml | 2 +- CONTRIBUTING.md | 6 ++- README.md | 2 +- benchmarks.py | 6 +-- scrapling/core/storage_adaptors.py | 11 ++--- scrapling/core/translator.py | 4 +- scrapling/core/utils.py | 57 +++++++++++----------- scrapling/engines/camo.py | 5 +- scrapling/engines/pw.py | 4 +- scrapling/engines/static.py | 3 -- scrapling/engines/toolbelt/custom.py | 25 ++++------ scrapling/engines/toolbelt/fingerprints.py | 6 +-- scrapling/engines/toolbelt/navigation.py | 8 ++- scrapling/parser.py | 26 +++++----- tests/parser/test_automatch.py | 4 +- tests/parser/test_general.py | 4 +- 16 files changed, 81 insertions(+), 92 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/01-bug_report.yml b/.github/ISSUE_TEMPLATE/01-bug_report.yml index 6a34895..0340da1 100644 --- a/.github/ISSUE_TEMPLATE/01-bug_report.yml +++ b/.github/ISSUE_TEMPLATE/01-bug_report.yml @@ -65,7 +65,7 @@ body: - type: textarea attributes: - label: "Actual behavior (Remember to use `debug` parameter)" + label: "Actual behavior" validations: required: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e3b3b2..8adf4d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,11 @@ tests/test_parser_functions.py ................ [100%] =============================== 16 passed in 0.22s ================================ ``` -Also, consider setting `debug` to `True` while initializing the Adaptor object so it's easier to know what's happening in the background. +Also, consider setting the scrapling logging level to `debug` so it's easier to know what's happening in the background. +```python +>>> import logging +>>> logging.getLogger("scrapling").setLevel(logging.DEBUG) +``` ### The process is straight-forward. diff --git a/README.md b/README.md index 2f8455c..a717b7d 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ You might be slightly confused by now so let me clear things up. All fetcher-typ ```python from scrapling import Fetcher, StealthyFetcher, PlayWrightFetcher ``` -All of them can take these initialization arguments: `auto_match`, `huge_tree`, `keep_comments`, `keep_cdata`, `storage`, `storage_args`, and `debug`, which are the same ones you give to the `Adaptor` class. +All of them can take these initialization arguments: `auto_match`, `huge_tree`, `keep_comments`, `keep_cdata`, `storage`, and `storage_args`, which are the same ones you give to the `Adaptor` class. If you don't want to pass arguments to the generated `Adaptor` object and want to use the default values, you can use this import instead for cleaner code: ```python diff --git a/benchmarks.py b/benchmarks.py index de647d6..28af680 100644 --- a/benchmarks.py +++ b/benchmarks.py @@ -64,9 +64,9 @@ def test_pyquery(): @benchmark def test_scrapling(): # No need to do `.extract()` like parsel to extract text - # Also, this is faster than `[t.text for t in Adaptor(large_html, auto_match=False, debug=False).css('.item')]` + # Also, this is faster than `[t.text for t in Adaptor(large_html, auto_match=False).css('.item')]` # for obvious reasons, of course. - return Adaptor(large_html, auto_match=False, debug=False).css('.item::text') + return Adaptor(large_html, auto_match=False).css('.item::text') @benchmark @@ -103,7 +103,7 @@ def test_scrapling_text(request_html): # Will loop over resulted elements to get text too to make comparison even more fair otherwise Scrapling will be even faster return [ element.text for element in Adaptor( - request_html, auto_match=False, debug=False + request_html, auto_match=False ).find_by_text('Tipping the Velvet', first_match=True).find_similar(ignore_attributes=['title']) ] diff --git a/scrapling/core/storage_adaptors.py b/scrapling/core/storage_adaptors.py index 983e863..c614643 100644 --- a/scrapling/core/storage_adaptors.py +++ b/scrapling/core/storage_adaptors.py @@ -1,4 +1,3 @@ -import logging import sqlite3 import threading from abc import ABC, abstractmethod @@ -9,7 +8,7 @@ from tldextract import extract as tld from scrapling.core._types import Dict, Optional, Union -from scrapling.core.utils import _StorageTools, cache +from scrapling.core.utils import _StorageTools, log, lru_cache class StorageSystemMixin(ABC): @@ -20,7 +19,7 @@ def __init__(self, url: Union[str, None] = None): """ self.url = url - @cache(None, typed=True) + @lru_cache(None, typed=True) def _get_base_url(self, default_value: str = 'default') -> str: if not self.url or type(self.url) is not str: return default_value @@ -52,7 +51,7 @@ def retrieve(self, identifier: str) -> Optional[Dict]: raise NotImplementedError('Storage system must implement `save` method') @staticmethod - @cache(None, typed=True) + @lru_cache(None, typed=True) def _get_hash(identifier: str) -> str: """If you want to hash identifier in your storage system, use this safer""" identifier = identifier.lower().strip() @@ -64,7 +63,7 @@ def _get_hash(identifier: str) -> str: return f"{hash_value}_{len(identifier)}" # Length to reduce collision chance -@cache(None, typed=True) +@lru_cache(None, typed=True) class SQLiteStorageSystem(StorageSystemMixin): """The recommended system to use, it's race condition safe and thread safe. Mainly built so the library can run in threaded frameworks like scrapy or threaded tools @@ -86,7 +85,7 @@ def __init__(self, storage_file: str, url: Union[str, None] = None): self.connection.execute("PRAGMA journal_mode=WAL") self.cursor = self.connection.cursor() self._setup_database() - logging.debug( + log.debug( f'Storage system loaded with arguments (storage_file="{storage_file}", url="{url}")' ) diff --git a/scrapling/core/translator.py b/scrapling/core/translator.py index aa6211e..263a24a 100644 --- a/scrapling/core/translator.py +++ b/scrapling/core/translator.py @@ -17,7 +17,7 @@ from w3lib.html import HTML5_WHITESPACE from scrapling.core._types import Any, Optional, Protocol, Self -from scrapling.core.utils import cache +from scrapling.core.utils import lru_cache regex = f"[{HTML5_WHITESPACE}]+" replace_html5_whitespaces = re.compile(regex).sub @@ -139,6 +139,6 @@ def xpath_text_simple_pseudo_element(xpath: OriginalXPathExpr) -> XPathExpr: class HTMLTranslator(TranslatorMixin, OriginalHTMLTranslator): - @cache(maxsize=256) + @lru_cache(maxsize=256) def css_to_xpath(self, css: str, prefix: str = "descendant-or-self::") -> str: return super().css_to_xpath(css, prefix) diff --git a/scrapling/core/utils.py b/scrapling/core/utils.py index a911757..27cb884 100644 --- a/scrapling/core/utils.py +++ b/scrapling/core/utils.py @@ -9,18 +9,36 @@ # Using cache on top of a class is brilliant way to achieve Singleton design pattern without much code # functools.cache is available on Python 3.9+ only so let's keep lru_cache -from functools import lru_cache as cache # isort:skip - +from functools import lru_cache # isort:skip html_forbidden = {html.HtmlComment, } -logging.basicConfig( - level=logging.INFO, - format="[%(asctime)s] %(levelname)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - handlers=[ - logging.StreamHandler() - ] -) + + +@lru_cache(1, typed=True) +def setup_logger(): + """Create and configure a logger with a standard format. + + :returns: logging.Logger: Configured logger instance + """ + logger = logging.getLogger('scrapling') + logger.setLevel(logging.INFO) + + formatter = logging.Formatter( + fmt="[%(asctime)s] %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + + # Add handler to logger (if not already added) + if not logger.handlers: + logger.addHandler(console_handler) + + return logger + + +log = setup_logger() def is_jsonable(content: Union[bytes, str]) -> bool: @@ -34,23 +52,6 @@ def is_jsonable(content: Union[bytes, str]) -> bool: return False -@cache(None, typed=True) -def setup_basic_logging(level: str = 'debug'): - levels = { - 'debug': logging.DEBUG, - 'info': logging.INFO, - 'warning': logging.WARNING, - 'error': logging.ERROR, - 'critical': logging.CRITICAL - } - formatter = logging.Formatter("[%(asctime)s] %(levelname)s: %(message)s", "%Y-%m-%d %H:%M:%S") - lvl = levels[level.lower()] - handler = logging.StreamHandler() - handler.setFormatter(formatter) - # Configure the root logger - logging.basicConfig(level=lvl, handlers=[handler]) - - def flatten(lst: Iterable): return list(chain.from_iterable(lst)) @@ -114,7 +115,7 @@ def _get_element_path(cls, element: html.HtmlElement): # return _impl -@cache(None, typed=True) +@lru_cache(None, typed=True) def clean_spaces(string): string = string.replace('\t', ' ') string = re.sub('[\n|\r]', '', string) diff --git a/scrapling/engines/camo.py b/scrapling/engines/camo.py index e7a6575..d755275 100644 --- a/scrapling/engines/camo.py +++ b/scrapling/engines/camo.py @@ -1,10 +1,9 @@ -import logging - from camoufox import DefaultAddons from camoufox.sync_api import Camoufox from scrapling.core._types import (Callable, Dict, List, Literal, Optional, Union) +from scrapling.core.utils import log from scrapling.engines.toolbelt import (Response, StatusText, check_type_validity, construct_proxy_dict, do_nothing, @@ -63,7 +62,7 @@ def __init__( self.page_action = page_action else: self.page_action = do_nothing - logging.error('[Ignored] Argument "page_action" must be callable') + log.error('[Ignored] Argument "page_action" must be callable') self.wait_selector = wait_selector self.wait_selector_state = wait_selector_state diff --git a/scrapling/engines/pw.py b/scrapling/engines/pw.py index 7d15174..8b2895c 100644 --- a/scrapling/engines/pw.py +++ b/scrapling/engines/pw.py @@ -1,7 +1,7 @@ import json -import logging from scrapling.core._types import Callable, Dict, List, Optional, Union +from scrapling.core.utils import log from scrapling.engines.constants import (DEFAULT_STEALTH_FLAGS, NSTBROWSER_DEFAULT_QUERY) from scrapling.engines.toolbelt import (Response, StatusText, @@ -78,7 +78,7 @@ def __init__( self.page_action = page_action else: self.page_action = do_nothing - logging.error('[Ignored] Argument "page_action" must be callable') + log.error('[Ignored] Argument "page_action" must be callable') self.wait_selector = wait_selector self.wait_selector_state = wait_selector_state diff --git a/scrapling/engines/static.py b/scrapling/engines/static.py index a091c4f..9bad034 100644 --- a/scrapling/engines/static.py +++ b/scrapling/engines/static.py @@ -1,5 +1,3 @@ -import logging - import httpx from httpx._models import Response as httpxResponse @@ -36,7 +34,6 @@ def _headers_job(headers: Optional[Dict], url: str, stealth: bool) -> Dict: # Validate headers if not headers.get('user-agent') and not headers.get('User-Agent'): headers['User-Agent'] = generate_headers(browser_mode=False).get('User-Agent') - logging.info(f"Can't find useragent in headers so '{headers['User-Agent']}' was used.") if stealth: extra_headers = generate_headers(browser_mode=False) diff --git a/scrapling/engines/toolbelt/custom.py b/scrapling/engines/toolbelt/custom.py index 7c8acd6..6632b6b 100644 --- a/scrapling/engines/toolbelt/custom.py +++ b/scrapling/engines/toolbelt/custom.py @@ -2,13 +2,12 @@ Functions related to custom types or type checking """ import inspect -import logging from email.message import Message from scrapling.core._types import (Any, Callable, Dict, List, Optional, Tuple, Type, Union) from scrapling.core.custom_types import MappingProxyType -from scrapling.core.utils import cache, setup_basic_logging +from scrapling.core.utils import log, lru_cache from scrapling.parser import Adaptor, SQLiteStorageSystem @@ -17,7 +16,7 @@ class ResponseEncoding: __ISO_8859_1_CONTENT_TYPES = {"text/plain", "text/html", "text/css", "text/javascript"} @classmethod - @cache(maxsize=None) + @lru_cache(maxsize=None) def __parse_content_type(cls, header_value: str) -> Tuple[str, Dict[str, str]]: """Parse content type and parameters from a content-type header value. @@ -39,7 +38,7 @@ def __parse_content_type(cls, header_value: str) -> Tuple[str, Dict[str, str]]: return content_type, params @classmethod - @cache(maxsize=None) + @lru_cache(maxsize=None) def get_value(cls, content_type: Optional[str], text: Optional[str] = 'test') -> str: """Determine the appropriate character encoding from a content-type header. @@ -98,7 +97,7 @@ def __init__(self, url: str, text: str, body: bytes, status: int, reason: str, c # For back-ward compatibility self.adaptor = self # For easier debugging while working from a Python shell - logging.info(f'Fetched ({status}) <{method} {url}> (referer: {request_headers.get("referer")})') + log.info(f'Fetched ({status}) <{method} {url}> (referer: {request_headers.get("referer")})') # def __repr__(self): # return f'<{self.__class__.__name__} [{self.status} {self.reason}]>' @@ -107,7 +106,7 @@ def __init__(self, url: str, text: str, body: bytes, status: int, reason: str, c class BaseFetcher: def __init__( self, huge_tree: bool = True, keep_comments: Optional[bool] = False, auto_match: Optional[bool] = True, - storage: Any = SQLiteStorageSystem, storage_args: Optional[Dict] = None, debug: Optional[bool] = False, + storage: Any = SQLiteStorageSystem, storage_args: Optional[Dict] = None, automatch_domain: Optional[str] = None, keep_cdata: Optional[bool] = False, ): """Arguments below are the same from the Adaptor class so you can pass them directly, the rest of Adaptor's arguments @@ -124,7 +123,6 @@ def __init__( If empty, default values will be used. :param automatch_domain: For cases where you want to automatch selectors across different websites as if they were on the same website, use this argument to unify them. Otherwise, the domain of the request is used by default. - :param debug: Enable debug mode """ # Adaptor class parameters # I won't validate Adaptor's class parameters here again, I will leave it to be validated later @@ -134,14 +132,11 @@ def __init__( keep_cdata=keep_cdata, auto_match=auto_match, storage=storage, - storage_args=storage_args, - debug=debug, + storage_args=storage_args ) - # If the user used fetchers first, then configure the logger from here instead of the `Adaptor` class - setup_basic_logging(level='debug' if debug else 'info') if automatch_domain: if type(automatch_domain) is not str: - logging.warning('[Ignored] The argument "automatch_domain" must be of string type') + log.warning('[Ignored] The argument "automatch_domain" must be of string type') else: self.adaptor_arguments.update({'automatch_domain': automatch_domain}) @@ -217,7 +212,7 @@ class StatusText: }) @classmethod - @cache(maxsize=128) + @lru_cache(maxsize=128) def get(cls, status_code: int) -> str: """Get the phrase for a given HTTP status code.""" return cls._phrases.get(status_code, "Unknown Status Code") @@ -284,7 +279,7 @@ def check_type_validity(variable: Any, valid_types: Union[List[Type], None], def error_msg = f'Argument "{var_name}" cannot be None' if critical: raise TypeError(error_msg) - logging.error(f'[Ignored] {error_msg}') + log.error(f'[Ignored] {error_msg}') return default_value # If no valid_types specified and variable has a value, return it @@ -297,7 +292,7 @@ def check_type_validity(variable: Any, valid_types: Union[List[Type], None], def error_msg = f'Argument "{var_name}" must be of type {" or ".join(type_names)}' if critical: raise TypeError(error_msg) - logging.error(f'[Ignored] {error_msg}') + log.error(f'[Ignored] {error_msg}') return default_value return variable diff --git a/scrapling/engines/toolbelt/fingerprints.py b/scrapling/engines/toolbelt/fingerprints.py index 5600003..a7bf633 100644 --- a/scrapling/engines/toolbelt/fingerprints.py +++ b/scrapling/engines/toolbelt/fingerprints.py @@ -9,10 +9,10 @@ from tldextract import extract from scrapling.core._types import Dict, Union -from scrapling.core.utils import cache +from scrapling.core.utils import lru_cache -@cache(None, typed=True) +@lru_cache(None, typed=True) def generate_convincing_referer(url: str) -> str: """Takes the domain from the URL without the subdomain/suffix and make it look like you were searching google for this website @@ -26,7 +26,7 @@ def generate_convincing_referer(url: str) -> str: return f'https://www.google.com/search?q={website_name}' -@cache(None, typed=True) +@lru_cache(None, typed=True) def get_os_name() -> Union[str, None]: """Get the current OS name in the same format needed for browserforge diff --git a/scrapling/engines/toolbelt/navigation.py b/scrapling/engines/toolbelt/navigation.py index 2d24cac..5811a76 100644 --- a/scrapling/engines/toolbelt/navigation.py +++ b/scrapling/engines/toolbelt/navigation.py @@ -1,15 +1,13 @@ """ Functions related to files and URLs """ - -import logging import os from urllib.parse import urlencode, urlparse from playwright.sync_api import Route from scrapling.core._types import Dict, Optional, Union -from scrapling.core.utils import cache +from scrapling.core.utils import log, lru_cache from scrapling.engines.constants import DEFAULT_DISABLED_RESOURCES @@ -20,7 +18,7 @@ def intercept_route(route: Route) -> Union[Route, None]: :return: PlayWright `Route` object """ if route.request.resource_type in DEFAULT_DISABLED_RESOURCES: - logging.debug(f'Blocking background resource "{route.request.url}" of type "{route.request.resource_type}"') + log.debug(f'Blocking background resource "{route.request.url}" of type "{route.request.resource_type}"') return route.abort() return route.continue_() @@ -97,7 +95,7 @@ def construct_cdp_url(cdp_url: str, query_params: Optional[Dict] = None) -> str: raise ValueError(f"Invalid CDP URL: {str(e)}") -@cache(None, typed=True) +@lru_cache(None, typed=True) def js_bypass_path(filename: str) -> str: """Takes the base filename of JS file inside the `bypasses` folder then return the full path of it diff --git a/scrapling/parser.py b/scrapling/parser.py index 123fc6b..47d2b32 100644 --- a/scrapling/parser.py +++ b/scrapling/parser.py @@ -18,12 +18,12 @@ StorageSystemMixin, _StorageTools) from scrapling.core.translator import HTMLTranslator from scrapling.core.utils import (clean_spaces, flatten, html_forbidden, - is_jsonable, logging, setup_basic_logging) + is_jsonable, log) class Adaptor(SelectorsGeneration): __slots__ = ( - 'url', 'encoding', '__auto_match_enabled', '_root', '_storage', '__debug', + 'url', 'encoding', '__auto_match_enabled', '_root', '_storage', '__keep_comments', '__huge_tree_enabled', '__attributes', '__text', '__tag', '__keep_cdata', '__raw_body' ) @@ -41,7 +41,6 @@ def __init__( auto_match: Optional[bool] = True, storage: Any = SQLiteStorageSystem, storage_args: Optional[Dict] = None, - debug: Optional[bool] = True, **kwargs ): """The main class that works as a wrapper for the HTML input data. Using this class, you can search for elements @@ -67,7 +66,6 @@ def __init__( :param storage: The storage class to be passed for auto-matching functionalities, see ``Docs`` for more info. :param storage_args: A dictionary of ``argument->value`` pairs to be passed for the storage class. If empty, default values will be used. - :param debug: Enable debug mode """ if root is None and not body and text is None: raise ValueError("Adaptor class needs text, body, or root arguments to work") @@ -106,7 +104,6 @@ def __init__( self._root = root - setup_basic_logging(level='debug' if debug else 'info') self.__auto_match_enabled = auto_match if self.__auto_match_enabled: @@ -117,7 +114,7 @@ def __init__( } if not hasattr(storage, '__wrapped__'): - raise ValueError("Storage class must be wrapped with cache decorator, see docs for info") + raise ValueError("Storage class must be wrapped with lru_cache decorator, see docs for info") if not issubclass(storage.__wrapped__, StorageSystemMixin): raise ValueError("Storage system must be inherited from class `StorageSystemMixin`") @@ -132,7 +129,6 @@ def __init__( # For selector stuff self.__attributes = None self.__tag = None - self.__debug = debug # No need to check if all response attributes exist or not because if `status` exist, then the rest exist (Save some CPU cycles for speed) self.__response_data = { key: getattr(self, key) for key in ('status', 'reason', 'cookies', 'headers', 'request_headers',) @@ -164,7 +160,7 @@ def __get_correct_result( text='', body=b'', # Since root argument is provided, both `text` and `body` will be ignored so this is just a filler url=self.url, encoding=self.encoding, auto_match=self.__auto_match_enabled, keep_comments=self.__keep_comments, keep_cdata=self.__keep_cdata, - huge_tree=self.__huge_tree_enabled, debug=self.__debug, + huge_tree=self.__huge_tree_enabled, **self.__response_data ) return element @@ -417,10 +413,10 @@ def _traverse(node: html.HtmlElement, ele: Dict) -> None: if score_table: highest_probability = max(score_table.keys()) if score_table[highest_probability] and highest_probability >= percentage: - logging.debug(f'Highest probability was {highest_probability}%') - logging.debug('Top 5 best matching elements are: ') + log.debug(f'Highest probability was {highest_probability}%') + log.debug('Top 5 best matching elements are: ') for percent in tuple(sorted(score_table.keys(), reverse=True))[:5]: - logging.debug(f'{percent} -> {self.__convert_results(score_table[percent])}') + log.debug(f'{percent} -> {self.__convert_results(score_table[percent])}') if not adaptor_type: return score_table[highest_probability] return self.__convert_results(score_table[highest_probability]) @@ -546,7 +542,7 @@ def xpath(self, selector: str, identifier: str = '', if selected_elements: if not self.__auto_match_enabled and auto_save: - logging.warning("Argument `auto_save` will be ignored because `auto_match` wasn't enabled on initialization. Check docs for more info.") + log.warning("Argument `auto_save` will be ignored because `auto_match` wasn't enabled on initialization. Check docs for more info.") elif self.__auto_match_enabled and auto_save: self.save(selected_elements[0], identifier or selector) @@ -565,7 +561,7 @@ def xpath(self, selector: str, identifier: str = '', return self.__convert_results(selected_elements) elif not self.__auto_match_enabled and auto_match: - logging.warning("Argument `auto_match` will be ignored because `auto_match` wasn't enabled on initialization. Check docs for more info.") + log.warning("Argument `auto_match` will be ignored because `auto_match` wasn't enabled on initialization. Check docs for more info.") return self.__convert_results(selected_elements) @@ -769,7 +765,7 @@ def save(self, element: Union['Adaptor', html.HtmlElement], identifier: str) -> self._storage.save(element, identifier) else: - logging.critical( + log.critical( "Can't use Auto-match features with disabled globally, you have to start a new class instance." ) @@ -783,7 +779,7 @@ def retrieve(self, identifier: str) -> Optional[Dict]: if self.__auto_match_enabled: return self._storage.retrieve(identifier) - logging.critical( + log.critical( "Can't use Auto-match features with disabled globally, you have to start a new class instance." ) diff --git a/tests/parser/test_automatch.py b/tests/parser/test_automatch.py index 1e78e87..79fe6b7 100644 --- a/tests/parser/test_automatch.py +++ b/tests/parser/test_automatch.py @@ -42,8 +42,8 @@ def test_element_relocation(self): ''' - old_page = Adaptor(original_html, url='example.com', auto_match=True, debug=True) - new_page = Adaptor(changed_html, url='example.com', auto_match=True, debug=True) + old_page = Adaptor(original_html, url='example.com', auto_match=True) + new_page = Adaptor(changed_html, url='example.com', auto_match=True) # 'p1' was used as ID and now it's not and all the path elements have changes # Also at the same time testing auto-match vs combined selectors diff --git a/tests/parser/test_general.py b/tests/parser/test_general.py index ea1fb78..8ce369a 100644 --- a/tests/parser/test_general.py +++ b/tests/parser/test_general.py @@ -74,7 +74,7 @@ def setUp(self): ''' - self.page = Adaptor(self.html, auto_match=False, debug=False) + self.page = Adaptor(self.html, auto_match=False) def test_css_selector(self): """Test Selecting elements with complex CSS selectors""" @@ -273,7 +273,7 @@ def test_performance(self): large_html = '' + '
' * 5000 + '
' * 5000 + '' start_time = time.time() - parsed = Adaptor(large_html, auto_match=False, debug=False) + parsed = Adaptor(large_html, auto_match=False) elements = parsed.css('.item') end_time = time.time() From e25434197469ca983dd9cc36d807b4d2f652a611 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Wed, 11 Dec 2024 21:43:36 +0200 Subject: [PATCH 15/46] fix: forgot to stage it with last commit --- scrapling/engines/static.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scrapling/engines/static.py b/scrapling/engines/static.py index 9bad034..557c6b9 100644 --- a/scrapling/engines/static.py +++ b/scrapling/engines/static.py @@ -2,6 +2,7 @@ from httpx._models import Response as httpxResponse from scrapling.core._types import Dict, Optional, Union +from scrapling.core.utils import log from .toolbelt import Response, generate_convincing_referer, generate_headers @@ -34,6 +35,7 @@ def _headers_job(headers: Optional[Dict], url: str, stealth: bool) -> Dict: # Validate headers if not headers.get('user-agent') and not headers.get('User-Agent'): headers['User-Agent'] = generate_headers(browser_mode=False).get('User-Agent') + log.debug(f"Can't find useragent in headers so '{headers['User-Agent']}' was used.") if stealth: extra_headers = generate_headers(browser_mode=False) @@ -58,6 +60,7 @@ def _prepare_response(self, response: httpxResponse) -> Response: cookies=dict(response.cookies), headers=dict(response.headers), request_headers=dict(response.request.headers), + method=response.request.method, **self.adaptor_arguments ) From 6f87420e356c88ee52628be821cd958fe1752007 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Wed, 11 Dec 2024 21:44:03 +0200 Subject: [PATCH 16/46] build: disable the 501 error test for playwright --- tests/fetchers/test_playwright.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fetchers/test_playwright.py b/tests/fetchers/test_playwright.py index 039f124..a22ecfd 100644 --- a/tests/fetchers/test_playwright.py +++ b/tests/fetchers/test_playwright.py @@ -22,9 +22,9 @@ def setUp(self): def test_basic_fetch(self): """Test doing basic fetch request with multiple statuses""" self.assertEqual(self.fetcher.fetch(self.status_200).status, 200) - # There's a bug with playwright makes it crashes if a URL returns status code 404 without body, let's disable this till they reply to my issue report + # There's a bug with playwright makes it crashes if a URL returns status code 4xx/5xx without body, let's disable this till they reply to my issue report # self.assertEqual(self.fetcher.fetch(self.status_404).status, 404) - self.assertEqual(self.fetcher.fetch(self.status_501).status, 501) + # self.assertEqual(self.fetcher.fetch(self.status_501).status, 501) def test_networkidle(self): """Test if waiting for `networkidle` make page does not finish loading or not""" From bfe906315e68cefb6137519814ade4cdaf5b5960 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Thu, 12 Dec 2024 12:43:49 +0200 Subject: [PATCH 17/46] feat: adding `geoip` parameter to the StealthyFetcher --- README.md | 1 + scrapling/engines/camo.py | 5 +++++ scrapling/fetchers.py | 5 ++++- setup.py | 2 +- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a717b7d..8191284 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,7 @@ True | addons | List of Firefox addons to use. **Must be paths to extracted addons.** | ✔️ | | humanize | Humanize the cursor movement. Takes either True or the MAX duration in seconds of the cursor movement. The cursor typically takes up to 1.5 seconds to move across the window. | ✔️ | | allow_webgl | Enabled by default. Disabling it WebGL not recommended as many WAFs now checks if WebGL is enabled. | ✔️ | +| geoip | Recommended to use with proxies; Automatically use IP's longitude, latitude, timezone, country, locale, & spoof the WebRTC IP address. It will also calculate and spoof the browser's language based on the distribution of language speakers in the target region. | ✔️ | | disable_ads | Enabled by default, this installs `uBlock Origin` addon on the browser if enabled. | ✔️ | | network_idle | Wait for the page until there are no network connections for at least 500 ms. | ✔️ | | timeout | The timeout in milliseconds that is used in all operations and waits through the page. The default is 30000. | ✔️ | diff --git a/scrapling/engines/camo.py b/scrapling/engines/camo.py index d755275..955a700 100644 --- a/scrapling/engines/camo.py +++ b/scrapling/engines/camo.py @@ -18,6 +18,7 @@ def __init__( timeout: Optional[float] = 30000, page_action: Callable = do_nothing, wait_selector: Optional[str] = None, addons: Optional[List[str]] = None, wait_selector_state: str = 'attached', google_search: Optional[bool] = True, extra_headers: Optional[Dict[str, str]] = None, proxy: Optional[Union[str, Dict[str, str]]] = None, os_randomize: Optional[bool] = None, disable_ads: Optional[bool] = True, + geoip: Optional[bool] = False, adaptor_arguments: Dict = None, ): """An engine that utilizes Camoufox library, check the `StealthyFetcher` class for more documentation. @@ -38,6 +39,8 @@ def __init__( :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30000 :param page_action: Added for automation. A function that takes the `page` object, does the automation you need, then returns `page` again. :param wait_selector: Wait for a specific css selector to be in a specific state. + :param geoip: Recommended to use with proxies; Automatically use IP's longitude, latitude, timezone, country, locale, & spoof the WebRTC IP address. + It will also calculate and spoof the browser's language based on the distribution of language speakers in the target region. :param wait_selector_state: The state to wait for the selector given with `wait_selector`. Default state is `attached`. :param google_search: Enabled by default, Scrapling will set the referer header to be as if this request came from a Google search for this website's domain name. :param extra_headers: A dictionary of extra headers to add to the request. _The referer set by the `google_search` argument takes priority over the referer set here if used together._ @@ -53,6 +56,7 @@ def __init__( self.google_search = bool(google_search) self.os_randomize = bool(os_randomize) self.disable_ads = bool(disable_ads) + self.geoip = bool(geoip) self.extra_headers = extra_headers or {} self.proxy = construct_proxy_dict(proxy) self.addons = addons or [] @@ -76,6 +80,7 @@ def fetch(self, url: str) -> Response: """ addons = [] if self.disable_ads else [DefaultAddons.UBO] with Camoufox( + geoip=self.geoip, proxy=self.proxy, addons=self.addons, exclude_addons=addons, diff --git a/scrapling/fetchers.py b/scrapling/fetchers.py index 94059ab..f0f3c02 100644 --- a/scrapling/fetchers.py +++ b/scrapling/fetchers.py @@ -83,7 +83,7 @@ def fetch( block_webrtc: Optional[bool] = False, allow_webgl: Optional[bool] = True, network_idle: Optional[bool] = False, addons: Optional[List[str]] = None, timeout: Optional[float] = 30000, page_action: Callable = do_nothing, wait_selector: Optional[str] = None, humanize: Optional[Union[bool, float]] = True, wait_selector_state: str = 'attached', google_search: Optional[bool] = True, extra_headers: Optional[Dict[str, str]] = None, proxy: Optional[Union[str, Dict[str, str]]] = None, - os_randomize: Optional[bool] = None, disable_ads: Optional[bool] = True, + os_randomize: Optional[bool] = None, disable_ads: Optional[bool] = True, geoip: Optional[bool] = False, ) -> Response: """ Opens up a browser and do your request based on your chosen options below. @@ -100,6 +100,8 @@ def fetch( :param disable_ads: Enabled by default, this installs `uBlock Origin` addon on the browser if enabled. :param humanize: Humanize the cursor movement. Takes either True or the MAX duration in seconds of the cursor movement. The cursor typically takes up to 1.5 seconds to move across the window. :param allow_webgl: Enabled by default. Disabling it WebGL not recommended as many WAFs now checks if WebGL is enabled. + :param geoip: Recommended to use with proxies; Automatically use IP's longitude, latitude, timezone, country, locale, & spoof the WebRTC IP address. + It will also calculate and spoof the browser's language based on the distribution of language speakers in the target region. :param network_idle: Wait for the page until there are no network connections for at least 500 ms. :param os_randomize: If enabled, Scrapling will randomize the OS fingerprints used. The default is Scrapling matching the fingerprints with the current OS. :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30000 @@ -113,6 +115,7 @@ def fetch( """ engine = CamoufoxEngine( proxy=proxy, + geoip=geoip, addons=addons, timeout=timeout, headless=headless, diff --git a/setup.py b/setup.py index 2076c14..a548211 100644 --- a/setup.py +++ b/setup.py @@ -57,7 +57,7 @@ 'httpx[brotli,zstd]', 'playwright>=1.49.1', 'rebrowser-playwright>=1.49.1', - 'camoufox>=0.4.7', + 'camoufox[geoip]>=0.4.7', 'browserforge', ], python_requires=">=3.9", From f9bee4cbbb711d530732ef80035a73aaf6ae9c3d Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Thu, 12 Dec 2024 14:37:31 +0200 Subject: [PATCH 18/46] build: Pumping up camoufox version to solve browserforge issue Dropped browserforge from the requirements here so its version gets controlled by camoufox --- setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.py b/setup.py index a548211..bbaccf0 100644 --- a/setup.py +++ b/setup.py @@ -57,8 +57,7 @@ 'httpx[brotli,zstd]', 'playwright>=1.49.1', 'rebrowser-playwright>=1.49.1', - 'camoufox[geoip]>=0.4.7', - 'browserforge', + 'camoufox[geoip]>=0.4.8' ], python_requires=">=3.9", url="https://github.com/D4Vinci/Scrapling", From 838dd62ee4f11daa3fc1bbc5dc7c7a10eda6ffd1 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Fri, 13 Dec 2024 20:43:53 +0200 Subject: [PATCH 19/46] build: pumping up camoufox For the issue with geoip --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bbaccf0..baa9429 100644 --- a/setup.py +++ b/setup.py @@ -57,7 +57,7 @@ 'httpx[brotli,zstd]', 'playwright>=1.49.1', 'rebrowser-playwright>=1.49.1', - 'camoufox[geoip]>=0.4.8' + 'camoufox[geoip]>=0.4.9' ], python_requires=">=3.9", url="https://github.com/D4Vinci/Scrapling", From 299793af3f363a77ebacf1a4ea65072a015df2d1 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 15 Dec 2024 15:59:15 +0200 Subject: [PATCH 20/46] feat: adding the `retries` argument for all methods of `Fetcher` class --- README.md | 2 +- scrapling/engines/static.py | 11 ++++++----- scrapling/fetchers.py | 30 ++++++++++++++++++++++-------- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 8191284..7477a14 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ Also, the `Response` object returned from all fetchers is the same as the `Adapt ### Fetcher This class is built on top of [httpx](https://www.python-httpx.org/) with additional configuration options, here you can do `GET`, `POST`, `PUT`, and `DELETE` requests. -For all methods, you have `stealth_headers` which makes `Fetcher` create and use real browser's headers then create a referer header as if this request came from Google's search of this URL's domain. It's enabled by default. +For all methods, you have `stealth_headers` which makes `Fetcher` create and use real browser's headers then create a referer header as if this request came from Google's search of this URL's domain. It's enabled by default. You can also set the number of retries with the argument `retries` for all methods and this will make httpx retry requests if it failed for any reason. The default number of retries for all `Fetcher` methods is 3. You can route all traffic (HTTP and HTTPS) to a proxy for any of these methods in this format `http://username:password@localhost:8030` ```python diff --git a/scrapling/engines/static.py b/scrapling/engines/static.py index 557c6b9..4329ec4 100644 --- a/scrapling/engines/static.py +++ b/scrapling/engines/static.py @@ -8,7 +8,7 @@ class StaticEngine: - def __init__(self, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = None, adaptor_arguments: Dict = None): + def __init__(self, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = None, retries: Optional[int] = 3, adaptor_arguments: Dict = None): """An engine that utilizes httpx library, check the `Fetcher` class for more documentation. :param follow_redirects: As the name says -- if enabled (default), redirects will be followed. @@ -17,6 +17,7 @@ def __init__(self, follow_redirects: bool = True, timeout: Optional[Union[int, f """ self.timeout = timeout self.follow_redirects = bool(follow_redirects) + self.retries = retries self._extra_headers = generate_headers(browser_mode=False) self.adaptor_arguments = adaptor_arguments if adaptor_arguments else {} @@ -75,7 +76,7 @@ def get(self, url: str, proxy: Optional[str] = None, stealthy_headers: Optional[ :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ headers = self._headers_job(kwargs.pop('headers', {}), url, stealthy_headers) - with httpx.Client(proxy=proxy) as client: + with httpx.Client(proxy=proxy, transport=httpx.HTTPTransport(retries=self.retries)) as client: request = client.get(url=url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) return self._prepare_response(request) @@ -91,7 +92,7 @@ def post(self, url: str, proxy: Optional[str] = None, stealthy_headers: Optional :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ headers = self._headers_job(kwargs.pop('headers', {}), url, stealthy_headers) - with httpx.Client(proxy=proxy) as client: + with httpx.Client(proxy=proxy, transport=httpx.HTTPTransport(retries=self.retries)) as client: request = client.post(url=url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) return self._prepare_response(request) @@ -107,7 +108,7 @@ def delete(self, url: str, proxy: Optional[str] = None, stealthy_headers: Option :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ headers = self._headers_job(kwargs.pop('headers', {}), url, stealthy_headers) - with httpx.Client(proxy=proxy) as client: + with httpx.Client(proxy=proxy, transport=httpx.HTTPTransport(retries=self.retries)) as client: request = client.delete(url=url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) return self._prepare_response(request) @@ -123,7 +124,7 @@ def put(self, url: str, proxy: Optional[str] = None, stealthy_headers: Optional[ :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ headers = self._headers_job(kwargs.pop('headers', {}), url, stealthy_headers) - with httpx.Client(proxy=proxy) as client: + with httpx.Client(proxy=proxy, transport=httpx.HTTPTransport(retries=self.retries)) as client: request = client.put(url=url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) return self._prepare_response(request) diff --git a/scrapling/fetchers.py b/scrapling/fetchers.py index f0f3c02..1983fb8 100644 --- a/scrapling/fetchers.py +++ b/scrapling/fetchers.py @@ -10,7 +10,9 @@ class Fetcher(BaseFetcher): Any additional keyword arguments passed to the methods below are passed to the respective httpx's method directly. """ - def get(self, url: str, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = 10, stealthy_headers: Optional[bool] = True, proxy: Optional[str] = None, **kwargs: Dict) -> Response: + def get( + self, url: str, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = 10, stealthy_headers: Optional[bool] = True, + proxy: Optional[str] = None, retries: Optional[int] = 3, **kwargs: Dict) -> Response: """Make basic HTTP GET request for you but with some added flavors. :param url: Target url. @@ -19,13 +21,16 @@ def get(self, url: str, follow_redirects: bool = True, timeout: Optional[Union[i :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and create a referer header as if this request had came from Google's search of this URL's domain. :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` + :param retries: The number of retries to do through httpx if the request failed for any reason. The default is 3 retries. :param kwargs: Any additional keyword arguments are passed directly to `httpx.get()` function so check httpx documentation for details. :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ - response_object = StaticEngine(follow_redirects, timeout, adaptor_arguments=self.adaptor_arguments).get(url, proxy, stealthy_headers, **kwargs) + response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=self.adaptor_arguments).get(url, proxy, stealthy_headers, **kwargs) return response_object - def post(self, url: str, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = 10, stealthy_headers: Optional[bool] = True, proxy: Optional[str] = None, **kwargs: Dict) -> Response: + def post( + self, url: str, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = 10, stealthy_headers: Optional[bool] = True, + proxy: Optional[str] = None, retries: Optional[int] = 3, **kwargs: Dict) -> Response: """Make basic HTTP POST request for you but with some added flavors. :param url: Target url. @@ -34,13 +39,16 @@ def post(self, url: str, follow_redirects: bool = True, timeout: Optional[Union[ :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and create a referer header as if this request came from Google's search of this URL's domain. :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` + :param retries: The number of retries to do through httpx if the request failed for any reason. The default is 3 retries. :param kwargs: Any additional keyword arguments are passed directly to `httpx.post()` function so check httpx documentation for details. :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ - response_object = StaticEngine(follow_redirects, timeout, adaptor_arguments=self.adaptor_arguments).post(url, proxy, stealthy_headers, **kwargs) + response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=self.adaptor_arguments).post(url, proxy, stealthy_headers, **kwargs) return response_object - def put(self, url: str, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = 10, stealthy_headers: Optional[bool] = True, proxy: Optional[str] = None, **kwargs: Dict) -> Response: + def put( + self, url: str, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = 10, stealthy_headers: Optional[bool] = True, + proxy: Optional[str] = None, retries: Optional[int] = 3, **kwargs: Dict) -> Response: """Make basic HTTP PUT request for you but with some added flavors. :param url: Target url @@ -49,14 +57,17 @@ def put(self, url: str, follow_redirects: bool = True, timeout: Optional[Union[i :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and create a referer header as if this request came from Google's search of this URL's domain. :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` + :param retries: The number of retries to do through httpx if the request failed for any reason. The default is 3 retries. :param kwargs: Any additional keyword arguments are passed directly to `httpx.put()` function so check httpx documentation for details. :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ - response_object = StaticEngine(follow_redirects, timeout, adaptor_arguments=self.adaptor_arguments).put(url, proxy, stealthy_headers, **kwargs) + response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=self.adaptor_arguments).put(url, proxy, stealthy_headers, **kwargs) return response_object - def delete(self, url: str, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = 10, stealthy_headers: Optional[bool] = True, proxy: Optional[str] = None, **kwargs: Dict) -> Response: + def delete( + self, url: str, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = 10, stealthy_headers: Optional[bool] = True, + proxy: Optional[str] = None, retries: Optional[int] = 3, **kwargs: Dict) -> Response: """Make basic HTTP DELETE request for you but with some added flavors. :param url: Target url @@ -65,10 +76,13 @@ def delete(self, url: str, follow_redirects: bool = True, timeout: Optional[Unio :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and create a referer header as if this request came from Google's search of this URL's domain. :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` + :param retries: The number of retries to do through httpx if the request failed for any reason. The default is 3 retries. :param kwargs: Any additional keyword arguments are passed directly to `httpx.delete()` function so check httpx documentation for details. :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ - response_object = StaticEngine(follow_redirects, timeout, adaptor_arguments=self.adaptor_arguments).delete(url, proxy, stealthy_headers, **kwargs) + response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=self.adaptor_arguments).delete(url, proxy, stealthy_headers, **kwargs) + return response_object + return response_object From 445af3c702d1826dd66d7347be65865d42e8434e Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 15 Dec 2024 16:13:08 +0200 Subject: [PATCH 21/46] perf: Give repeated usage of `Fetcher` a slight performance increase By caching the `StaticEngine` class instance --- scrapling/engines/static.py | 11 +++++++---- scrapling/fetchers.py | 12 ++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/scrapling/engines/static.py b/scrapling/engines/static.py index 4329ec4..69f54e6 100644 --- a/scrapling/engines/static.py +++ b/scrapling/engines/static.py @@ -1,14 +1,15 @@ import httpx from httpx._models import Response as httpxResponse -from scrapling.core._types import Dict, Optional, Union -from scrapling.core.utils import log +from scrapling.core._types import Dict, Optional, Tuple, Union +from scrapling.core.utils import log, lru_cache from .toolbelt import Response, generate_convincing_referer, generate_headers +@lru_cache(typed=True) class StaticEngine: - def __init__(self, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = None, retries: Optional[int] = 3, adaptor_arguments: Dict = None): + def __init__(self, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = None, retries: Optional[int] = 3, adaptor_arguments: Tuple = None): """An engine that utilizes httpx library, check the `Fetcher` class for more documentation. :param follow_redirects: As the name says -- if enabled (default), redirects will be followed. @@ -19,7 +20,9 @@ def __init__(self, follow_redirects: bool = True, timeout: Optional[Union[int, f self.follow_redirects = bool(follow_redirects) self.retries = retries self._extra_headers = generate_headers(browser_mode=False) - self.adaptor_arguments = adaptor_arguments if adaptor_arguments else {} + # Because we are using `lru_cache` for a slight optimization but both dict/dict_items are not hashable so they can't be cached + # So my solution here was to convert it to tuple then convert it back to dictionary again here as tuples are hashable, ofc `tuple().__hash__()` + self.adaptor_arguments = dict(adaptor_arguments) if adaptor_arguments else {} @staticmethod def _headers_job(headers: Optional[Dict], url: str, stealth: bool) -> Dict: diff --git a/scrapling/fetchers.py b/scrapling/fetchers.py index 1983fb8..61332c6 100644 --- a/scrapling/fetchers.py +++ b/scrapling/fetchers.py @@ -25,7 +25,8 @@ def get( :param kwargs: Any additional keyword arguments are passed directly to `httpx.get()` function so check httpx documentation for details. :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ - response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=self.adaptor_arguments).get(url, proxy, stealthy_headers, **kwargs) + adaptor_arguments = tuple(self.adaptor_arguments.items()) + response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).get(url, proxy, stealthy_headers, **kwargs) return response_object def post( @@ -43,7 +44,8 @@ def post( :param kwargs: Any additional keyword arguments are passed directly to `httpx.post()` function so check httpx documentation for details. :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ - response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=self.adaptor_arguments).post(url, proxy, stealthy_headers, **kwargs) + adaptor_arguments = tuple(self.adaptor_arguments.items()) + response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).post(url, proxy, stealthy_headers, **kwargs) return response_object def put( @@ -62,7 +64,8 @@ def put( :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ - response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=self.adaptor_arguments).put(url, proxy, stealthy_headers, **kwargs) + adaptor_arguments = tuple(self.adaptor_arguments.items()) + response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).put(url, proxy, stealthy_headers, **kwargs) return response_object def delete( @@ -80,7 +83,8 @@ def delete( :param kwargs: Any additional keyword arguments are passed directly to `httpx.delete()` function so check httpx documentation for details. :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ - response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=self.adaptor_arguments).delete(url, proxy, stealthy_headers, **kwargs) + adaptor_arguments = tuple(self.adaptor_arguments.items()) + response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).delete(url, proxy, stealthy_headers, **kwargs) return response_object return response_object From faf728a794dd18f63c7757ce35bbd7aadda717b0 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 15 Dec 2024 16:49:48 +0200 Subject: [PATCH 22/46] style: moving repeated arguments from inside the functions to __init__ Might give a slight performance increase too --- .flake8 | 1 + scrapling/engines/static.py | 85 +++++++++++++++++++++++-------------- scrapling/fetchers.py | 8 ++-- 3 files changed, 59 insertions(+), 35 deletions(-) diff --git a/.flake8 b/.flake8 index fae58af..cd2987a 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,4 @@ [flake8] ignore = E501, F401 +extend-ignore = E999 exclude = .git,.venv,__pycache__,docs,.github,build,dist,tests,benchmarks.py \ No newline at end of file diff --git a/scrapling/engines/static.py b/scrapling/engines/static.py index 69f54e6..8dd67fa 100644 --- a/scrapling/engines/static.py +++ b/scrapling/engines/static.py @@ -9,13 +9,23 @@ @lru_cache(typed=True) class StaticEngine: - def __init__(self, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = None, retries: Optional[int] = 3, adaptor_arguments: Tuple = None): + def __init__( + self, url: str, proxy: Optional[str] = None, stealthy_headers: Optional[bool] = True, follow_redirects: bool = True, + timeout: Optional[Union[int, float]] = None, retries: Optional[int] = 3, adaptor_arguments: Tuple = None + ): """An engine that utilizes httpx library, check the `Fetcher` class for more documentation. + :param url: Target url. + :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and + create a referer header as if this request had came from Google's search of this URL's domain. + :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` :param follow_redirects: As the name says -- if enabled (default), redirects will be followed. :param timeout: The time to wait for the request to finish in seconds. The default is 10 seconds. :param adaptor_arguments: The arguments that will be passed in the end while creating the final Adaptor's class. """ + self.url = url + self.proxy = proxy + self.stealth = stealthy_headers self.timeout = timeout self.follow_redirects = bool(follow_redirects) self.retries = retries @@ -24,14 +34,11 @@ def __init__(self, follow_redirects: bool = True, timeout: Optional[Union[int, f # So my solution here was to convert it to tuple then convert it back to dictionary again here as tuples are hashable, ofc `tuple().__hash__()` self.adaptor_arguments = dict(adaptor_arguments) if adaptor_arguments else {} - @staticmethod - def _headers_job(headers: Optional[Dict], url: str, stealth: bool) -> Dict: + def _headers_job(self, headers: Optional[Dict]) -> Dict: """Adds useragent to headers if it doesn't exist, generates real headers and append it to current headers, and finally generates a referer header that looks like if this request came from Google's search of the current URL's domain. :param headers: Current headers in the request if the user passed any - :param url: The Target URL. - :param stealth: Whether stealth mode is enabled or not. :return: A dictionary of the new headers. """ headers = headers or {} @@ -41,10 +48,10 @@ def _headers_job(headers: Optional[Dict], url: str, stealth: bool) -> Dict: headers['User-Agent'] = generate_headers(browser_mode=False).get('User-Agent') log.debug(f"Can't find useragent in headers so '{headers['User-Agent']}' was used.") - if stealth: + if self.stealth: extra_headers = generate_headers(browser_mode=False) headers.update(extra_headers) - headers.update({'referer': generate_convincing_referer(url)}) + headers.update({'referer': generate_convincing_referer(self.url)}) return headers @@ -68,14 +75,18 @@ def _prepare_response(self, response: httpxResponse) -> Response: **self.adaptor_arguments ) - def get(self, url: str, proxy: Optional[str] = None, stealthy_headers: Optional[bool] = True, **kwargs: Dict) -> Response: + def get(self, **kwargs: Dict) -> Response: """Make basic HTTP GET request for you but with some added flavors. - :param url: Target url. - :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and - create a referer header as if this request had came from Google's search of this URL's domain. - :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` - :param kwargs: Any additional keyword arguments are passed directly to `httpx.get()` function so check httpx documentation for details. + :param kwargs: Any keyword arguments are passed directly to `httpx.get()` function so check httpx documentation for details. + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` + """ + headers = self._headers_job(kwargs.pop('headers', {})) + with httpx.Client(proxy=self.proxy, transport=httpx.HTTPTransport(retries=self.retries)) as client: + request = client.get(url=self.url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) + + return self._prepare_response(request) + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ headers = self._headers_job(kwargs.pop('headers', {}), url, stealthy_headers) @@ -84,14 +95,18 @@ def get(self, url: str, proxy: Optional[str] = None, stealthy_headers: Optional[ return self._prepare_response(request) - def post(self, url: str, proxy: Optional[str] = None, stealthy_headers: Optional[bool] = True, **kwargs: Dict) -> Response: + def post(self, **kwargs: Dict) -> Response: """Make basic HTTP POST request for you but with some added flavors. - :param url: Target url. - :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and - create a referer header as if this request had came from Google's search of this URL's domain. - :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` - :param kwargs: Any additional keyword arguments are passed directly to `httpx.post()` function so check httpx documentation for details. + :param kwargs: Any keyword arguments are passed directly to `httpx.post()` function so check httpx documentation for details. + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` + """ + headers = self._headers_job(kwargs.pop('headers', {})) + with httpx.Client(proxy=self.proxy, transport=httpx.HTTPTransport(retries=self.retries)) as client: + request = client.post(url=self.url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) + + return self._prepare_response(request) + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ headers = self._headers_job(kwargs.pop('headers', {}), url, stealthy_headers) @@ -100,14 +115,18 @@ def post(self, url: str, proxy: Optional[str] = None, stealthy_headers: Optional return self._prepare_response(request) - def delete(self, url: str, proxy: Optional[str] = None, stealthy_headers: Optional[bool] = True, **kwargs: Dict) -> Response: + def delete(self, **kwargs: Dict) -> Response: """Make basic HTTP DELETE request for you but with some added flavors. - :param url: Target url. - :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and - create a referer header as if this request had came from Google's search of this URL's domain. - :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` - :param kwargs: Any additional keyword arguments are passed directly to `httpx.delete()` function so check httpx documentation for details. + :param kwargs: Any keyword arguments are passed directly to `httpx.delete()` function so check httpx documentation for details. + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` + """ + headers = self._headers_job(kwargs.pop('headers', {})) + with httpx.Client(proxy=self.proxy, transport=httpx.HTTPTransport(retries=self.retries)) as client: + request = client.delete(url=self.url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) + + return self._prepare_response(request) + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ headers = self._headers_job(kwargs.pop('headers', {}), url, stealthy_headers) @@ -116,14 +135,18 @@ def delete(self, url: str, proxy: Optional[str] = None, stealthy_headers: Option return self._prepare_response(request) - def put(self, url: str, proxy: Optional[str] = None, stealthy_headers: Optional[bool] = True, **kwargs: Dict) -> Response: + def put(self, **kwargs: Dict) -> Response: """Make basic HTTP PUT request for you but with some added flavors. - :param url: Target url. - :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and - create a referer header as if this request had came from Google's search of this URL's domain. - :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` - :param kwargs: Any additional keyword arguments are passed directly to `httpx.put()` function so check httpx documentation for details. + :param kwargs: Any keyword arguments are passed directly to `httpx.put()` function so check httpx documentation for details. + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` + """ + headers = self._headers_job(kwargs.pop('headers', {})) + with httpx.Client(proxy=self.proxy, transport=httpx.HTTPTransport(retries=self.retries)) as client: + request = client.put(url=self.url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) + + return self._prepare_response(request) + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ headers = self._headers_job(kwargs.pop('headers', {}), url, stealthy_headers) diff --git a/scrapling/fetchers.py b/scrapling/fetchers.py index 61332c6..8ad6a78 100644 --- a/scrapling/fetchers.py +++ b/scrapling/fetchers.py @@ -26,7 +26,7 @@ def get( :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ adaptor_arguments = tuple(self.adaptor_arguments.items()) - response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).get(url, proxy, stealthy_headers, **kwargs) + response_object = StaticEngine(url, proxy, stealthy_headers, follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).get(**kwargs) return response_object def post( @@ -45,7 +45,7 @@ def post( :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ adaptor_arguments = tuple(self.adaptor_arguments.items()) - response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).post(url, proxy, stealthy_headers, **kwargs) + response_object = StaticEngine(url, proxy, stealthy_headers, follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).post(**kwargs) return response_object def put( @@ -65,7 +65,7 @@ def put( :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ adaptor_arguments = tuple(self.adaptor_arguments.items()) - response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).put(url, proxy, stealthy_headers, **kwargs) + response_object = StaticEngine(url, proxy, stealthy_headers, follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).put(**kwargs) return response_object def delete( @@ -84,7 +84,7 @@ def delete( :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ adaptor_arguments = tuple(self.adaptor_arguments.items()) - response_object = StaticEngine(follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).delete(url, proxy, stealthy_headers, **kwargs) + response_object = StaticEngine(url, proxy, stealthy_headers, follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).delete(**kwargs) return response_object return response_object From b10cfd3643593b0eef9b08758be46adf7cda769f Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 15 Dec 2024 16:51:58 +0200 Subject: [PATCH 23/46] feat: Adding `AsyncFetcher` class version of `Fetcher` The first step in fully supporting async --- .flake8 | 1 - scrapling/engines/static.py | 40 +++++++++++++------ scrapling/fetchers.py | 76 +++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/.flake8 b/.flake8 index cd2987a..fae58af 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,3 @@ [flake8] ignore = E501, F401 -extend-ignore = E999 exclude = .git,.venv,__pycache__,docs,.github,build,dist,tests,benchmarks.py \ No newline at end of file diff --git a/scrapling/engines/static.py b/scrapling/engines/static.py index 8dd67fa..9d5bed7 100644 --- a/scrapling/engines/static.py +++ b/scrapling/engines/static.py @@ -87,11 +87,15 @@ def get(self, **kwargs: Dict) -> Response: return self._prepare_response(request) + async def async_get(self, **kwargs: Dict) -> Response: + """Make basic async HTTP GET request for you but with some added flavors. + + :param kwargs: Any keyword arguments are passed directly to `httpx.get()` function so check httpx documentation for details. :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ - headers = self._headers_job(kwargs.pop('headers', {}), url, stealthy_headers) - with httpx.Client(proxy=proxy, transport=httpx.HTTPTransport(retries=self.retries)) as client: - request = client.get(url=url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) + headers = self._headers_job(kwargs.pop('headers', {})) + async with httpx.AsyncClient(proxy=self.proxy) as client: + request = await client.get(url=self.url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) return self._prepare_response(request) @@ -107,11 +111,15 @@ def post(self, **kwargs: Dict) -> Response: return self._prepare_response(request) + async def async_post(self, **kwargs: Dict) -> Response: + """Make basic async HTTP POST request for you but with some added flavors. + + :param kwargs: Any keyword arguments are passed directly to `httpx.post()` function so check httpx documentation for details. :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ - headers = self._headers_job(kwargs.pop('headers', {}), url, stealthy_headers) - with httpx.Client(proxy=proxy, transport=httpx.HTTPTransport(retries=self.retries)) as client: - request = client.post(url=url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) + headers = self._headers_job(kwargs.pop('headers', {})) + async with httpx.AsyncClient(proxy=self.proxy) as client: + request = await client.post(url=self.url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) return self._prepare_response(request) @@ -127,11 +135,15 @@ def delete(self, **kwargs: Dict) -> Response: return self._prepare_response(request) + async def async_delete(self, **kwargs: Dict) -> Response: + """Make basic async HTTP DELETE request for you but with some added flavors. + + :param kwargs: Any keyword arguments are passed directly to `httpx.delete()` function so check httpx documentation for details. :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ - headers = self._headers_job(kwargs.pop('headers', {}), url, stealthy_headers) - with httpx.Client(proxy=proxy, transport=httpx.HTTPTransport(retries=self.retries)) as client: - request = client.delete(url=url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) + headers = self._headers_job(kwargs.pop('headers', {})) + async with httpx.AsyncClient(proxy=self.proxy) as client: + request = await client.delete(url=self.url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) return self._prepare_response(request) @@ -147,10 +159,14 @@ def put(self, **kwargs: Dict) -> Response: return self._prepare_response(request) + async def async_put(self, **kwargs: Dict) -> Response: + """Make basic async HTTP PUT request for you but with some added flavors. + + :param kwargs: Any keyword arguments are passed directly to `httpx.put()` function so check httpx documentation for details. :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` """ - headers = self._headers_job(kwargs.pop('headers', {}), url, stealthy_headers) - with httpx.Client(proxy=proxy, transport=httpx.HTTPTransport(retries=self.retries)) as client: - request = client.put(url=url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) + headers = self._headers_job(kwargs.pop('headers', {})) + async with httpx.AsyncClient(proxy=self.proxy) as client: + request = await client.put(url=self.url, headers=headers, follow_redirects=self.follow_redirects, timeout=self.timeout, **kwargs) return self._prepare_response(request) diff --git a/scrapling/fetchers.py b/scrapling/fetchers.py index 8ad6a78..6f6e4a5 100644 --- a/scrapling/fetchers.py +++ b/scrapling/fetchers.py @@ -87,6 +87,82 @@ def delete( response_object = StaticEngine(url, proxy, stealthy_headers, follow_redirects, timeout, retries, adaptor_arguments=adaptor_arguments).delete(**kwargs) return response_object + +class AsyncFetcher(Fetcher): + async def get( + self, url: str, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = 10, stealthy_headers: Optional[bool] = True, + proxy: Optional[str] = None, retries: Optional[int] = 3, **kwargs: Dict) -> Response: + """Make basic HTTP GET request for you but with some added flavors. + + :param url: Target url. + :param follow_redirects: As the name says -- if enabled (default), redirects will be followed. + :param timeout: The time to wait for the request to finish in seconds. The default is 10 seconds. + :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and + create a referer header as if this request had came from Google's search of this URL's domain. + :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` + :param retries: The number of retries to do through httpx if the request failed for any reason. The default is 3 retries. + :param kwargs: Any additional keyword arguments are passed directly to `httpx.get()` function so check httpx documentation for details. + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` + """ + adaptor_arguments = tuple(self.adaptor_arguments.items()) + response_object = await StaticEngine(url, proxy, stealthy_headers, follow_redirects, timeout, retries=retries, adaptor_arguments=adaptor_arguments).async_get(**kwargs) + return response_object + + async def post( + self, url: str, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = 10, stealthy_headers: Optional[bool] = True, + proxy: Optional[str] = None, retries: Optional[int] = 3, **kwargs: Dict) -> Response: + """Make basic HTTP POST request for you but with some added flavors. + + :param url: Target url. + :param follow_redirects: As the name says -- if enabled (default), redirects will be followed. + :param timeout: The time to wait for the request to finish in seconds. The default is 10 seconds. + :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and + create a referer header as if this request came from Google's search of this URL's domain. + :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` + :param retries: The number of retries to do through httpx if the request failed for any reason. The default is 3 retries. + :param kwargs: Any additional keyword arguments are passed directly to `httpx.post()` function so check httpx documentation for details. + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` + """ + adaptor_arguments = tuple(self.adaptor_arguments.items()) + response_object = await StaticEngine(url, proxy, stealthy_headers, follow_redirects, timeout, retries=retries, adaptor_arguments=adaptor_arguments).async_post(**kwargs) + return response_object + + async def put( + self, url: str, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = 10, stealthy_headers: Optional[bool] = True, + proxy: Optional[str] = None, retries: Optional[int] = 3, **kwargs: Dict) -> Response: + """Make basic HTTP PUT request for you but with some added flavors. + + :param url: Target url + :param follow_redirects: As the name says -- if enabled (default), redirects will be followed. + :param timeout: The time to wait for the request to finish in seconds. The default is 10 seconds. + :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and + create a referer header as if this request came from Google's search of this URL's domain. + :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` + :param retries: The number of retries to do through httpx if the request failed for any reason. The default is 3 retries. + :param kwargs: Any additional keyword arguments are passed directly to `httpx.put()` function so check httpx documentation for details. + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` + """ + adaptor_arguments = tuple(self.adaptor_arguments.items()) + response_object = await StaticEngine(url, proxy, stealthy_headers, follow_redirects, timeout, retries=retries, adaptor_arguments=adaptor_arguments).async_post(**kwargs) + return response_object + + async def delete( + self, url: str, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = 10, stealthy_headers: Optional[bool] = True, + proxy: Optional[str] = None, retries: Optional[int] = 3, **kwargs: Dict) -> Response: + """Make basic HTTP DELETE request for you but with some added flavors. + + :param url: Target url + :param follow_redirects: As the name says -- if enabled (default), redirects will be followed. + :param timeout: The time to wait for the request to finish in seconds. The default is 10 seconds. + :param stealthy_headers: If enabled (default), Fetcher will create and add real browser's headers and + create a referer header as if this request came from Google's search of this URL's domain. + :param proxy: A string of a proxy to use for http and https requests, the format accepted is `http://username:password@localhost:8030` + :param retries: The number of retries to do through httpx if the request failed for any reason. The default is 3 retries. + :param kwargs: Any additional keyword arguments are passed directly to `httpx.delete()` function so check httpx documentation for details. + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` + """ + adaptor_arguments = tuple(self.adaptor_arguments.items()) + response_object = await StaticEngine(url, proxy, stealthy_headers, follow_redirects, timeout, retries=retries, adaptor_arguments=adaptor_arguments).async_delete(**kwargs) return response_object From a90192df03890519e184f1e72afee4474721d6c7 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 15 Dec 2024 16:54:52 +0200 Subject: [PATCH 24/46] build: adding tests for `AsyncFetcher` class --- tests/fetchers/test_async_httpx.py | 92 ++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/fetchers/test_async_httpx.py diff --git a/tests/fetchers/test_async_httpx.py b/tests/fetchers/test_async_httpx.py new file mode 100644 index 0000000..ef09bb8 --- /dev/null +++ b/tests/fetchers/test_async_httpx.py @@ -0,0 +1,92 @@ +import asyncio +import unittest + +import pytest_httpbin + +from scrapling.fetchers import AsyncFetcher + + +@pytest_httpbin.use_class_based_httpbin +class TestAsyncFetcher(unittest.TestCase): + def setUp(self): + self.fetcher = AsyncFetcher(auto_match=True) + url = self.httpbin.url + self.status_200 = f'{url}/status/200' + self.status_404 = f'{url}/status/404' + self.status_501 = f'{url}/status/501' + self.basic_url = f'{url}/get' + self.post_url = f'{url}/post' + self.put_url = f'{url}/put' + self.delete_url = f'{url}/delete' + self.html_url = f'{url}/html' + + async def async_test(self, coro): + return await coro + + def test_basic_get(self): + """Test doing basic get request with multiple statuses""" + + async def run_tests(): + self.assertEqual((await self.fetcher.get(self.status_200)).status, 200) + self.assertEqual((await self.fetcher.get(self.status_404)).status, 404) + self.assertEqual((await self.fetcher.get(self.status_501)).status, 501) + + asyncio.run(run_tests()) + + def test_get_properties(self): + """Test if different arguments with GET request breaks the code or not""" + + async def run_tests(): + self.assertEqual((await self.fetcher.get(self.status_200, stealthy_headers=True)).status, 200) + self.assertEqual((await self.fetcher.get(self.status_200, follow_redirects=True)).status, 200) + self.assertEqual((await self.fetcher.get(self.status_200, timeout=None)).status, 200) + self.assertEqual( + (await self.fetcher.get(self.status_200, stealthy_headers=True, follow_redirects=True, timeout=None)).status, + 200 + ) + + asyncio.run(run_tests()) + + def test_post_properties(self): + """Test if different arguments with POST request breaks the code or not""" + + async def run_tests(): + self.assertEqual((await self.fetcher.post(self.post_url, data={'key': 'value'})).status, 200) + self.assertEqual((await self.fetcher.post(self.post_url, data={'key': 'value'}, stealthy_headers=True)).status, 200) + self.assertEqual((await self.fetcher.post(self.post_url, data={'key': 'value'}, follow_redirects=True)).status, 200) + self.assertEqual((await self.fetcher.post(self.post_url, data={'key': 'value'}, timeout=None)).status, 200) + self.assertEqual( + (await self.fetcher.post(self.post_url, data={'key': 'value'}, stealthy_headers=True, follow_redirects=True, timeout=None)).status, + 200 + ) + + asyncio.run(run_tests()) + + def test_put_properties(self): + """Test if different arguments with PUT request breaks the code or not""" + + async def run_tests(): + self.assertIn((await self.fetcher.put(self.put_url, data={'key': 'value'})).status, [200, 405]) + self.assertIn((await self.fetcher.put(self.put_url, data={'key': 'value'}, stealthy_headers=True)).status, [200, 405]) + self.assertIn((await self.fetcher.put(self.put_url, data={'key': 'value'}, follow_redirects=True)).status, [200, 405]) + self.assertIn((await self.fetcher.put(self.put_url, data={'key': 'value'}, timeout=None)).status, [200, 405]) + self.assertIn( + (await self.fetcher.put(self.put_url, data={'key': 'value'}, stealthy_headers=True, follow_redirects=True, timeout=None)).status, + [200, 405] + ) + + asyncio.run(run_tests()) + + def test_delete_properties(self): + """Test if different arguments with DELETE request breaks the code or not""" + + async def run_tests(): + self.assertEqual((await self.fetcher.delete(self.delete_url, stealthy_headers=True)).status, 200) + self.assertEqual((await self.fetcher.delete(self.delete_url, follow_redirects=True)).status, 200) + self.assertEqual((await self.fetcher.delete(self.delete_url, timeout=None)).status, 200) + self.assertEqual( + (await self.fetcher.delete(self.delete_url, stealthy_headers=True, follow_redirects=True, timeout=None)).status, + 200 + ) + + asyncio.run(run_tests()) From 6c17bd886e4801b26b72ec977eb9da7dcf245881 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 15 Dec 2024 23:19:47 +0200 Subject: [PATCH 25/46] style: using better data structures for constants This will cause a slight performance increase --- scrapling/engines/constants.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scrapling/engines/constants.py b/scrapling/engines/constants.py index 926e238..e26c460 100644 --- a/scrapling/engines/constants.py +++ b/scrapling/engines/constants.py @@ -1,5 +1,5 @@ # Disable loading these resources for speed -DEFAULT_DISABLED_RESOURCES = [ +DEFAULT_DISABLED_RESOURCES = { 'font', 'image', 'media', @@ -10,9 +10,9 @@ 'websocket', 'csp_report', 'stylesheet', -] +} -DEFAULT_STEALTH_FLAGS = [ +DEFAULT_STEALTH_FLAGS = ( # Explanation: https://peter.sh/experiments/chromium-command-line-switches/ # Generally this will make the browser faster and less detectable '--no-pings', @@ -87,7 +87,7 @@ '--enable-features=NetworkService,NetworkServiceInProcess,TrustTokens,TrustTokensAlwaysAllowIssuance', '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4', '--disable-features=AudioServiceOutOfProcess,IsolateOrigins,site-per-process,TranslateUI,BlinkGenPropertyTrees', -] +) # Defaulting to the docker mode, token doesn't matter in it as it's passed for the container NSTBROWSER_DEFAULT_QUERY = { From 889c111855bfd3c24bf1292cff7d48cddcb286af Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Sun, 15 Dec 2024 23:25:02 +0200 Subject: [PATCH 26/46] refactor(Playwright Engine): Separate what we can for cleaner code and the async function later The caching will give a slight performance increase with bulk requests --- scrapling/engines/pw.py | 145 +++++++++++++------------ scrapling/engines/toolbelt/__init__.py | 3 +- scrapling/engines/toolbelt/custom.py | 5 + 3 files changed, 80 insertions(+), 73 deletions(-) diff --git a/scrapling/engines/pw.py b/scrapling/engines/pw.py index 8b2895c..e210a7b 100644 --- a/scrapling/engines/pw.py +++ b/scrapling/engines/pw.py @@ -1,12 +1,13 @@ import json -from scrapling.core._types import Callable, Dict, List, Optional, Union -from scrapling.core.utils import log +from scrapling.core._types import Callable, Dict, Optional, Union +from scrapling.core.utils import log, lru_cache from scrapling.engines.constants import (DEFAULT_STEALTH_FLAGS, NSTBROWSER_DEFAULT_QUERY) from scrapling.engines.toolbelt import (Response, StatusText, check_type_validity, construct_cdp_url, construct_proxy_dict, do_nothing, + do_nothing_async, generate_convincing_referer, generate_headers, intercept_route, js_bypass_path) @@ -94,10 +95,8 @@ def __init__( # '--disable-extensions', ] - def _cdp_url_logic(self, flags: Optional[List] = None) -> str: + def _cdp_url_logic(self) -> str: """Constructs new CDP URL if NSTBrowser is enabled otherwise return CDP URL as it is - - :param flags: Chrome flags to be added to NSTBrowser query :return: CDP URL """ cdp_url = self.cdp_url @@ -106,7 +105,8 @@ def _cdp_url_logic(self, flags: Optional[List] = None) -> str: config = self.nstbrowser_config else: query = NSTBROWSER_DEFAULT_QUERY.copy() - if flags: + if self.stealth: + flags = self.__set_flags() query.update({ "args": dict(zip(flags, [''] * len(flags))), # browser args should be a dictionary }) @@ -122,6 +122,68 @@ def _cdp_url_logic(self, flags: Optional[List] = None) -> str: return cdp_url + @lru_cache(typed=True) + def __set_flags(self): + """Returns the flags that will be used while launching the browser if stealth mode is enabled""" + flags = DEFAULT_STEALTH_FLAGS + if self.hide_canvas: + flags += ('--fingerprinting-canvas-image-data-noise',) + if self.disable_webgl: + flags += ('--disable-webgl', '--disable-webgl-image-chromium', '--disable-webgl2',) + + return flags + + def __launch_kwargs(self): + """Creates the arguments we will use while launching playwright's browser""" + launch_kwargs = {'headless': self.headless, 'ignore_default_args': self.harmful_default_args, 'channel': 'chrome' if self.real_chrome else 'chromium'} + if self.stealth: + launch_kwargs.update({'args': self.__set_flags(), 'chromium_sandbox': True}) + + return launch_kwargs + + def __context_kwargs(self): + """Creates the arguments for the browser context""" + context_kwargs = { + "proxy": self.proxy, + "locale": self.locale, + "color_scheme": 'dark', # Bypasses the 'prefersLightColor' check in creepjs + "device_scale_factor": 2, + "extra_http_headers": self.extra_headers if self.extra_headers else {}, + "user_agent": self.useragent if self.useragent else generate_headers(browser_mode=True).get('User-Agent'), + } + if self.stealth: + context_kwargs.update({ + 'is_mobile': False, + 'has_touch': False, + # I'm thinking about disabling it to rest from all Service Workers headache but let's keep it as it is for now + 'service_workers': 'allow', + 'ignore_https_errors': True, + 'screen': {'width': 1920, 'height': 1080}, + 'viewport': {'width': 1920, 'height': 1080}, + 'permissions': ['geolocation', 'notifications'] + }) + + return context_kwargs + + @lru_cache() + def __stealth_scripts(self): + # Basic bypasses nothing fancy as I'm still working on it + # But with adding these bypasses to the above config, it bypasses many online tests like + # https://bot.sannysoft.com/ + # https://kaliiiiiiiiii.github.io/brotector/ + # https://pixelscan.net/ + # https://iphey.com/ + # https://www.browserscan.net/bot-detection <== this one also checks for the CDP runtime fingerprint + # https://arh.antoinevastel.com/bots/areyouheadless/ + # https://prescience-data.github.io/execution-monitor.html + return tuple( + js_bypass_path(script) for script in ( + # Order is important + 'webdriver_fully.js', 'window_chrome.js', 'navigator_plugins.js', 'pdf_viewer.js', + 'notification_permission.js', 'screen_props.js', 'playwright_fingerprint.js' + ) + ) + def fetch(self, url: str) -> Response: """Opens up the browser and do your request based on your chosen options. @@ -135,61 +197,14 @@ def fetch(self, url: str) -> Response: from rebrowser_playwright.sync_api import sync_playwright with sync_playwright() as p: - # Handle the UserAgent early - if self.useragent: - extra_headers = {} - useragent = self.useragent - else: - extra_headers = {} - useragent = generate_headers(browser_mode=True).get('User-Agent') - - # Prepare the flags before diving - flags = DEFAULT_STEALTH_FLAGS - if self.hide_canvas: - flags += ['--fingerprinting-canvas-image-data-noise'] - if self.disable_webgl: - flags += ['--disable-webgl', '--disable-webgl-image-chromium', '--disable-webgl2'] - # Creating the browser if self.cdp_url: - cdp_url = self._cdp_url_logic(flags if self.stealth else None) + cdp_url = self._cdp_url_logic() browser = p.chromium.connect_over_cdp(endpoint_url=cdp_url) else: - if self.stealth: - browser = p.chromium.launch( - headless=self.headless, args=flags, ignore_default_args=self.harmful_default_args, chromium_sandbox=True, channel='chrome' if self.real_chrome else 'chromium' - ) - else: - browser = p.chromium.launch(headless=self.headless, ignore_default_args=self.harmful_default_args, channel='chrome' if self.real_chrome else 'chromium') - - # Creating the context - if self.stealth: - context = browser.new_context( - locale=self.locale, - is_mobile=False, - has_touch=False, - proxy=self.proxy, - color_scheme='dark', # Bypasses the 'prefersLightColor' check in creepjs - user_agent=useragent, - device_scale_factor=2, - # I'm thinking about disabling it to rest from all Service Workers headache but let's keep it as it is for now - service_workers="allow", - ignore_https_errors=True, - extra_http_headers=extra_headers, - screen={"width": 1920, "height": 1080}, - viewport={"width": 1920, "height": 1080}, - permissions=["geolocation", 'notifications'], - ) - else: - context = browser.new_context( - locale=self.locale, - proxy=self.proxy, - color_scheme='dark', - user_agent=useragent, - device_scale_factor=2, - extra_http_headers=extra_headers - ) + browser = p.chromium.launch(**self.__launch_kwargs()) + context = browser.new_context(**self.__context_kwargs()) # Finally we are in business page = context.new_page() page.set_default_navigation_timeout(self.timeout) @@ -202,22 +217,8 @@ def fetch(self, url: str) -> Response: page.route("**/*", intercept_route) if self.stealth: - # Basic bypasses nothing fancy as I'm still working on it - # But with adding these bypasses to the above config, it bypasses many online tests like - # https://bot.sannysoft.com/ - # https://kaliiiiiiiiii.github.io/brotector/ - # https://pixelscan.net/ - # https://iphey.com/ - # https://www.browserscan.net/bot-detection <== this one also checks for the CDP runtime fingerprint - # https://arh.antoinevastel.com/bots/areyouheadless/ - # https://prescience-data.github.io/execution-monitor.html - page.add_init_script(path=js_bypass_path('webdriver_fully.js')) - page.add_init_script(path=js_bypass_path('window_chrome.js')) - page.add_init_script(path=js_bypass_path('navigator_plugins.js')) - page.add_init_script(path=js_bypass_path('pdf_viewer.js')) - page.add_init_script(path=js_bypass_path('notification_permission.js')) - page.add_init_script(path=js_bypass_path('screen_props.js')) - page.add_init_script(path=js_bypass_path('playwright_fingerprint.js')) + for script in self.__stealth_scripts(): + page.add_init_script(path=script) res = page.goto(url, referer=generate_convincing_referer(url) if self.google_search else None) page.wait_for_load_state(state="domcontentloaded") diff --git a/scrapling/engines/toolbelt/__init__.py b/scrapling/engines/toolbelt/__init__.py index 595929c..4f31f6a 100644 --- a/scrapling/engines/toolbelt/__init__.py +++ b/scrapling/engines/toolbelt/__init__.py @@ -1,5 +1,6 @@ from .custom import (BaseFetcher, Response, StatusText, check_if_engine_usable, - check_type_validity, do_nothing, get_variable_name) + check_type_validity, do_nothing, do_nothing_async, + get_variable_name) from .fingerprints import (generate_convincing_referer, generate_headers, get_os_name) from .navigation import (construct_cdp_url, construct_proxy_dict, diff --git a/scrapling/engines/toolbelt/custom.py b/scrapling/engines/toolbelt/custom.py index 6632b6b..0db3088 100644 --- a/scrapling/engines/toolbelt/custom.py +++ b/scrapling/engines/toolbelt/custom.py @@ -302,3 +302,8 @@ def check_type_validity(variable: Any, valid_types: Union[List[Type], None], def def do_nothing(page): # Just works as a filler for `page_action` argument in browser engines return page + + +async def do_nothing_async(page): + # Just works as a filler for `page_action` argument in browser engines + return page From af4f2c0f7486329fae0563c1a10896b5dc0dadbe Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 00:15:47 +0200 Subject: [PATCH 27/46] feat(PlaywrightFetcher): Add async support for PlaywrightFetcher --- pytest.ini | 2 + scrapling/engines/camo.py | 9 +-- scrapling/engines/pw.py | 91 +++++++++++++++++++++--- scrapling/engines/toolbelt/__init__.py | 7 +- scrapling/engines/toolbelt/custom.py | 11 --- scrapling/engines/toolbelt/navigation.py | 19 ++++- scrapling/fetchers.py | 66 ++++++++++++++++- 7 files changed, 170 insertions(+), 35 deletions(-) diff --git a/pytest.ini b/pytest.ini index df7eb7e..11c2331 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,4 @@ [pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function addopts = -p no:warnings --doctest-modules --ignore=setup.py --verbose \ No newline at end of file diff --git a/scrapling/engines/camo.py b/scrapling/engines/camo.py index 955a700..ec64e6d 100644 --- a/scrapling/engines/camo.py +++ b/scrapling/engines/camo.py @@ -6,7 +6,7 @@ from scrapling.core.utils import log from scrapling.engines.toolbelt import (Response, StatusText, check_type_validity, - construct_proxy_dict, do_nothing, + construct_proxy_dict, generate_convincing_referer, get_os_name, intercept_route) @@ -15,7 +15,7 @@ class CamoufoxEngine: def __init__( self, headless: Optional[Union[bool, Literal['virtual']]] = True, block_images: Optional[bool] = False, disable_resources: Optional[bool] = False, block_webrtc: Optional[bool] = False, allow_webgl: Optional[bool] = True, network_idle: Optional[bool] = False, humanize: Optional[Union[bool, float]] = True, - timeout: Optional[float] = 30000, page_action: Callable = do_nothing, wait_selector: Optional[str] = None, addons: Optional[List[str]] = None, + timeout: Optional[float] = 30000, page_action: Callable = None, wait_selector: Optional[str] = None, addons: Optional[List[str]] = None, wait_selector_state: str = 'attached', google_search: Optional[bool] = True, extra_headers: Optional[Dict[str, str]] = None, proxy: Optional[Union[str, Dict[str, str]]] = None, os_randomize: Optional[bool] = None, disable_ads: Optional[bool] = True, geoip: Optional[bool] = False, @@ -65,7 +65,7 @@ def __init__( if callable(page_action): self.page_action = page_action else: - self.page_action = do_nothing + self.page_action = None log.error('[Ignored] Argument "page_action" must be callable') self.wait_selector = wait_selector @@ -106,7 +106,8 @@ def fetch(self, url: str) -> Response: if self.network_idle: page.wait_for_load_state('networkidle') - page = self.page_action(page) + if self.page_action is not None: + page = self.page_action(page) if self.wait_selector and type(self.wait_selector) is str: waiter = page.locator(self.wait_selector) diff --git a/scrapling/engines/pw.py b/scrapling/engines/pw.py index e210a7b..16d7a8a 100644 --- a/scrapling/engines/pw.py +++ b/scrapling/engines/pw.py @@ -5,9 +5,9 @@ from scrapling.engines.constants import (DEFAULT_STEALTH_FLAGS, NSTBROWSER_DEFAULT_QUERY) from scrapling.engines.toolbelt import (Response, StatusText, + async_intercept_route, check_type_validity, construct_cdp_url, - construct_proxy_dict, do_nothing, - do_nothing_async, + construct_proxy_dict, generate_convincing_referer, generate_headers, intercept_route, js_bypass_path) @@ -20,7 +20,7 @@ def __init__( useragent: Optional[str] = None, network_idle: Optional[bool] = False, timeout: Optional[float] = 30000, - page_action: Callable = do_nothing, + page_action: Callable = None, wait_selector: Optional[str] = None, locale: Optional[str] = 'en-US', wait_selector_state: Optional[str] = 'attached', @@ -75,10 +75,10 @@ def __init__( self.cdp_url = cdp_url self.useragent = useragent self.timeout = check_type_validity(timeout, [int, float], 30000) - if callable(page_action): + if page_action is not None and callable(page_action): self.page_action = page_action else: - self.page_action = do_nothing + self.page_action = None log.error('[Ignored] Argument "page_action" must be callable') self.wait_selector = wait_selector @@ -225,7 +225,8 @@ def fetch(self, url: str) -> Response: if self.network_idle: page.wait_for_load_state('networkidle') - page = self.page_action(page) + if self.page_action is not None: + page = self.page_action(page) if self.wait_selector and type(self.wait_selector) is str: waiter = page.locator(self.wait_selector) @@ -238,11 +239,8 @@ def fetch(self, url: str) -> Response: # This will be parsed inside `Response` encoding = res.headers.get('content-type', '') or 'utf-8' # default encoding - - status_text = res.status_text # PlayWright API sometimes give empty status text for some reason! - if not status_text: - status_text = StatusText.get(res.status) + status_text = res.status_text or StatusText.get(res.status) response = Response( url=res.url, @@ -258,3 +256,76 @@ def fetch(self, url: str) -> Response: ) page.close() return response + + async def async_fetch(self, url: str) -> Response: + """Async version of `fetch` + + :param url: Target url. + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` + """ + if not self.stealth or self.real_chrome: + # Because rebrowser_playwright doesn't play well with real browsers + from playwright.async_api import async_playwright + else: + from rebrowser_playwright.async_api import async_playwright + + async with async_playwright() as p: + # Creating the browser + if self.cdp_url: + cdp_url = self._cdp_url_logic() + browser = await p.chromium.connect_over_cdp(endpoint_url=cdp_url) + else: + browser = await p.chromium.launch(**self.__launch_kwargs()) + + context = await browser.new_context(**self.__context_kwargs()) + # Finally we are in business + page = await context.new_page() + page.set_default_navigation_timeout(self.timeout) + page.set_default_timeout(self.timeout) + + if self.extra_headers: + await page.set_extra_http_headers(self.extra_headers) + + if self.disable_resources: + await page.route("**/*", async_intercept_route) + + if self.stealth: + for script in self.__stealth_scripts(): + await page.add_init_script(path=script) + + res = await page.goto(url, referer=generate_convincing_referer(url) if self.google_search else None) + await page.wait_for_load_state(state="domcontentloaded") + if self.network_idle: + await page.wait_for_load_state('networkidle') + + if self.page_action is not None: + page = await self.page_action(page) + + if self.wait_selector and type(self.wait_selector) is str: + waiter = page.locator(self.wait_selector) + await waiter.first.wait_for(state=self.wait_selector_state) + # Wait again after waiting for the selector, helpful with protections like Cloudflare + await page.wait_for_load_state(state="load") + await page.wait_for_load_state(state="domcontentloaded") + if self.network_idle: + await page.wait_for_load_state('networkidle') + + # This will be parsed inside `Response` + encoding = res.headers.get('content-type', '') or 'utf-8' # default encoding + # PlayWright API sometimes give empty status text for some reason! + status_text = res.status_text or StatusText.get(res.status) + + response = Response( + url=res.url, + text=await page.content(), + body=(await page.content()).encode('utf-8'), + status=res.status, + reason=status_text, + encoding=encoding, + cookies={cookie['name']: cookie['value'] for cookie in await page.context.cookies()}, + headers=await res.all_headers(), + request_headers=await res.request.all_headers(), + **self.adaptor_arguments + ) + await page.close() + return response diff --git a/scrapling/engines/toolbelt/__init__.py b/scrapling/engines/toolbelt/__init__.py index 4f31f6a..ccf2afa 100644 --- a/scrapling/engines/toolbelt/__init__.py +++ b/scrapling/engines/toolbelt/__init__.py @@ -1,7 +1,6 @@ from .custom import (BaseFetcher, Response, StatusText, check_if_engine_usable, - check_type_validity, do_nothing, do_nothing_async, - get_variable_name) + check_type_validity, get_variable_name) from .fingerprints import (generate_convincing_referer, generate_headers, get_os_name) -from .navigation import (construct_cdp_url, construct_proxy_dict, - intercept_route, js_bypass_path) +from .navigation import (async_intercept_route, construct_cdp_url, + construct_proxy_dict, intercept_route, js_bypass_path) diff --git a/scrapling/engines/toolbelt/custom.py b/scrapling/engines/toolbelt/custom.py index 0db3088..9705eb3 100644 --- a/scrapling/engines/toolbelt/custom.py +++ b/scrapling/engines/toolbelt/custom.py @@ -296,14 +296,3 @@ def check_type_validity(variable: Any, valid_types: Union[List[Type], None], def return default_value return variable - - -# Pew Pew -def do_nothing(page): - # Just works as a filler for `page_action` argument in browser engines - return page - - -async def do_nothing_async(page): - # Just works as a filler for `page_action` argument in browser engines - return page diff --git a/scrapling/engines/toolbelt/navigation.py b/scrapling/engines/toolbelt/navigation.py index 5811a76..0a9ff86 100644 --- a/scrapling/engines/toolbelt/navigation.py +++ b/scrapling/engines/toolbelt/navigation.py @@ -4,6 +4,7 @@ import os from urllib.parse import urlencode, urlparse +from playwright.async_api import Route as async_Route from playwright.sync_api import Route from scrapling.core._types import Dict, Optional, Union @@ -11,7 +12,7 @@ from scrapling.engines.constants import DEFAULT_DISABLED_RESOURCES -def intercept_route(route: Route) -> Union[Route, None]: +def intercept_route(route: Route): """This is just a route handler but it drops requests that its type falls in `DEFAULT_DISABLED_RESOURCES` :param route: PlayWright `Route` object of the current page @@ -19,8 +20,20 @@ def intercept_route(route: Route) -> Union[Route, None]: """ if route.request.resource_type in DEFAULT_DISABLED_RESOURCES: log.debug(f'Blocking background resource "{route.request.url}" of type "{route.request.resource_type}"') - return route.abort() - return route.continue_() + route.abort() + route.continue_() + + +async def async_intercept_route(route: async_Route): + """This is just a route handler but it drops requests that its type falls in `DEFAULT_DISABLED_RESOURCES` + + :param route: PlayWright `Route` object of the current page + :return: PlayWright `Route` object + """ + if route.request.resource_type in DEFAULT_DISABLED_RESOURCES: + log.debug(f'Blocking background resource "{route.request.url}" of type "{route.request.resource_type}"') + await route.abort() + await route.continue_() def construct_proxy_dict(proxy_string: Union[str, Dict[str, str]]) -> Union[Dict, None]: diff --git a/scrapling/fetchers.py b/scrapling/fetchers.py index 6f6e4a5..6ba6689 100644 --- a/scrapling/fetchers.py +++ b/scrapling/fetchers.py @@ -2,7 +2,7 @@ Union) from scrapling.engines import (CamoufoxEngine, PlaywrightEngine, StaticEngine, check_if_engine_usable) -from scrapling.engines.toolbelt import BaseFetcher, Response, do_nothing +from scrapling.engines.toolbelt import BaseFetcher, Response class Fetcher(BaseFetcher): @@ -175,7 +175,7 @@ class StealthyFetcher(BaseFetcher): def fetch( self, url: str, headless: Optional[Union[bool, Literal['virtual']]] = True, block_images: Optional[bool] = False, disable_resources: Optional[bool] = False, block_webrtc: Optional[bool] = False, allow_webgl: Optional[bool] = True, network_idle: Optional[bool] = False, addons: Optional[List[str]] = None, - timeout: Optional[float] = 30000, page_action: Callable = do_nothing, wait_selector: Optional[str] = None, humanize: Optional[Union[bool, float]] = True, + timeout: Optional[float] = 30000, page_action: Callable = None, wait_selector: Optional[str] = None, humanize: Optional[Union[bool, float]] = True, wait_selector_state: str = 'attached', google_search: Optional[bool] = True, extra_headers: Optional[Dict[str, str]] = None, proxy: Optional[Union[str, Dict[str, str]]] = None, os_randomize: Optional[bool] = None, disable_ads: Optional[bool] = True, geoip: Optional[bool] = False, ) -> Response: @@ -250,7 +250,7 @@ class PlayWrightFetcher(BaseFetcher): def fetch( self, url: str, headless: Union[bool, str] = True, disable_resources: bool = None, useragent: Optional[str] = None, network_idle: Optional[bool] = False, timeout: Optional[float] = 30000, - page_action: Optional[Callable] = do_nothing, wait_selector: Optional[str] = None, wait_selector_state: Optional[str] = 'attached', + page_action: Optional[Callable] = None, wait_selector: Optional[str] = None, wait_selector_state: Optional[str] = 'attached', hide_canvas: Optional[bool] = False, disable_webgl: Optional[bool] = False, extra_headers: Optional[Dict[str, str]] = None, google_search: Optional[bool] = True, proxy: Optional[Union[str, Dict[str, str]]] = None, locale: Optional[str] = 'en-US', stealth: Optional[bool] = False, real_chrome: Optional[bool] = False, @@ -307,6 +307,66 @@ def fetch( ) return engine.fetch(url) + async def async_fetch( + self, url: str, headless: Union[bool, str] = True, disable_resources: bool = None, + useragent: Optional[str] = None, network_idle: Optional[bool] = False, timeout: Optional[float] = 30000, + page_action: Optional[Callable] = None, wait_selector: Optional[str] = None, wait_selector_state: Optional[str] = 'attached', + hide_canvas: Optional[bool] = False, disable_webgl: Optional[bool] = False, extra_headers: Optional[Dict[str, str]] = None, google_search: Optional[bool] = True, + proxy: Optional[Union[str, Dict[str, str]]] = None, locale: Optional[str] = 'en-US', + stealth: Optional[bool] = False, real_chrome: Optional[bool] = False, + cdp_url: Optional[str] = None, + nstbrowser_mode: Optional[bool] = False, nstbrowser_config: Optional[Dict] = None, + ) -> Response: + """Opens up a browser and do your request based on your chosen options below. + + :param url: Target url. + :param headless: Run the browser in headless/hidden (default), or headful/visible mode. + :param disable_resources: Drop requests of unnecessary resources for speed boost. It depends but it made requests ~25% faster in my tests for some websites. + Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`. + This can help save your proxy usage but be careful with this option as it makes some websites never finish loading. + :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 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 30000 + :param locale: Set the locale for the browser if wanted. The default value is `en-US`. + :param page_action: Added for automation. A function that takes the `page` object, does the automation you need, then returns `page` again. + :param wait_selector: Wait for a specific css selector to be in a specific state. + :param wait_selector_state: The state to wait for the selector given with `wait_selector`. Default state is `attached`. + :param stealth: Enables stealth mode, check the documentation to see what stealth mode does currently. + :param real_chrome: If you have chrome browser installed on your device, enable this and the Fetcher will launch an instance of your browser and use it. + :param hide_canvas: Add random noise to canvas operations to prevent fingerprinting. + :param disable_webgl: Disables WebGL and WebGL 2.0 support entirely. + :param google_search: Enabled by default, Scrapling will set the referer header to be as if this request came from a Google search for this website's domain name. + :param extra_headers: A dictionary of extra headers to add to the request. _The referer set by the `google_search` argument takes priority over the referer set here if used together._ + :param proxy: The proxy to be used with requests, it can be a string or a dictionary with the keys 'server', 'username', and 'password' only. + :param cdp_url: Instead of launching a new browser instance, connect to this CDP URL to control real browsers/NSTBrowser through CDP. + :param nstbrowser_mode: Enables NSTBrowser mode, it have to be used with `cdp_url` argument or it will get completely ignored. + :param nstbrowser_config: The config you want to send with requests to the NSTBrowser. If left empty, Scrapling defaults to an optimized NSTBrowser's docker browserless config. + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` + """ + engine = PlaywrightEngine( + proxy=proxy, + locale=locale, + timeout=timeout, + stealth=stealth, + cdp_url=cdp_url, + headless=headless, + useragent=useragent, + real_chrome=real_chrome, + page_action=page_action, + hide_canvas=hide_canvas, + network_idle=network_idle, + google_search=google_search, + extra_headers=extra_headers, + wait_selector=wait_selector, + disable_webgl=disable_webgl, + nstbrowser_mode=nstbrowser_mode, + nstbrowser_config=nstbrowser_config, + disable_resources=disable_resources, + wait_selector_state=wait_selector_state, + adaptor_arguments=self.adaptor_arguments, + ) + return await engine.async_fetch(url) + class CustomFetcher(BaseFetcher): def fetch(self, url: str, browser_engine, **kwargs) -> Response: From aac77e488c6d8580b6be545ad00b1dbb3ca920b4 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 00:17:13 +0200 Subject: [PATCH 28/46] test: add test for PlaywrightFetcher Async support --- tests/fetchers/async/test_async_playwright.py | 99 +++++++++++++++++++ tests/fetchers/sync/__init__.py | 0 2 files changed, 99 insertions(+) create mode 100644 tests/fetchers/async/test_async_playwright.py create mode 100644 tests/fetchers/sync/__init__.py diff --git a/tests/fetchers/async/test_async_playwright.py b/tests/fetchers/async/test_async_playwright.py new file mode 100644 index 0000000..a8b4ef4 --- /dev/null +++ b/tests/fetchers/async/test_async_playwright.py @@ -0,0 +1,99 @@ +import pytest +import pytest_httpbin + +from scrapling import PlayWrightFetcher + + +@pytest_httpbin.use_class_based_httpbin +class TestPlayWrightFetcherAsync: + @pytest.fixture + def fetcher(self): + return PlayWrightFetcher(auto_match=False) + + @pytest.fixture + def urls(self, httpbin): + return { + 'status_200': f'{httpbin.url}/status/200', + 'status_404': f'{httpbin.url}/status/404', + 'status_501': f'{httpbin.url}/status/501', + 'basic_url': f'{httpbin.url}/get', + 'html_url': f'{httpbin.url}/html', + 'delayed_url': f'{httpbin.url}/delay/10', + 'cookies_url': f"{httpbin.url}/cookies/set/test/value" + } + + @pytest.mark.asyncio + async def test_basic_fetch(self, fetcher, urls): + """Test doing basic fetch request with multiple statuses""" + response = await fetcher.async_fetch(urls['status_200']) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_networkidle(self, fetcher, urls): + """Test if waiting for `networkidle` make page does not finish loading or not""" + response = await fetcher.async_fetch(urls['basic_url'], network_idle=True) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_blocking_resources(self, fetcher, urls): + """Test if blocking resources make page does not finish loading or not""" + response = await fetcher.async_fetch(urls['basic_url'], disable_resources=True) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_waiting_selector(self, fetcher, urls): + """Test if waiting for a selector make page does not finish loading or not""" + response1 = await fetcher.async_fetch(urls['html_url'], wait_selector='h1') + assert response1.status == 200 + + response2 = await fetcher.async_fetch(urls['html_url'], wait_selector='h1', wait_selector_state='visible') + assert response2.status == 200 + + @pytest.mark.asyncio + async def test_cookies_loading(self, fetcher, urls): + """Test if cookies are set after the request""" + response = await fetcher.async_fetch(urls['cookies_url']) + assert response.cookies == {'test': 'value'} + + @pytest.mark.asyncio + async def test_automation(self, fetcher, urls): + """Test if automation break the code or not""" + async def scroll_page(page): + await page.mouse.wheel(10, 0) + await page.mouse.move(100, 400) + await page.mouse.up() + return page + + response = await fetcher.async_fetch(urls['html_url'], page_action=scroll_page) + assert response.status == 200 + + @pytest.mark.parametrize("kwargs", [ + {"disable_webgl": True, "hide_canvas": False}, + {"disable_webgl": False, "hide_canvas": True}, + {"stealth": True}, + {"useragent": 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0'}, + {"extra_headers": {'ayo': ''}} + ]) + @pytest.mark.asyncio + async def test_properties(self, fetcher, urls, kwargs): + """Test if different arguments breaks the code or not""" + response = await fetcher.async_fetch(urls['html_url'], **kwargs) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_cdp_url_invalid(self, fetcher, urls): + """Test if invalid CDP URLs raise appropriate exceptions""" + with pytest.raises(ValueError): + await fetcher.async_fetch(urls['html_url'], cdp_url='blahblah') + + with pytest.raises(ValueError): + await fetcher.async_fetch(urls['html_url'], cdp_url='blahblah', nstbrowser_mode=True) + + with pytest.raises(Exception): + await fetcher.async_fetch(urls['html_url'], cdp_url='ws://blahblah') + + @pytest.mark.asyncio + async def test_infinite_timeout(self, fetcher, urls): + """Test if infinite timeout breaks the code or not""" + response = await fetcher.async_fetch(urls['delayed_url'], timeout=None) + assert response.status == 200 diff --git a/tests/fetchers/sync/__init__.py b/tests/fetchers/sync/__init__.py new file mode 100644 index 0000000..e69de29 From de015f2b18b8545240a1c28ff7b41128cdb3d5d3 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 00:19:48 +0200 Subject: [PATCH 29/46] chore: Restructuring fetchers into cleaner structure --- tests/fetchers/async/__init__.py | 0 tests/fetchers/{ => async}/test_async_httpx.py | 0 tests/fetchers/{ => sync}/test_camoufox.py | 0 tests/fetchers/{ => sync}/test_httpx.py | 0 tests/fetchers/{ => sync}/test_playwright.py | 0 5 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/fetchers/async/__init__.py rename tests/fetchers/{ => async}/test_async_httpx.py (100%) rename tests/fetchers/{ => sync}/test_camoufox.py (100%) rename tests/fetchers/{ => sync}/test_httpx.py (100%) rename tests/fetchers/{ => sync}/test_playwright.py (100%) diff --git a/tests/fetchers/async/__init__.py b/tests/fetchers/async/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fetchers/test_async_httpx.py b/tests/fetchers/async/test_async_httpx.py similarity index 100% rename from tests/fetchers/test_async_httpx.py rename to tests/fetchers/async/test_async_httpx.py diff --git a/tests/fetchers/test_camoufox.py b/tests/fetchers/sync/test_camoufox.py similarity index 100% rename from tests/fetchers/test_camoufox.py rename to tests/fetchers/sync/test_camoufox.py diff --git a/tests/fetchers/test_httpx.py b/tests/fetchers/sync/test_httpx.py similarity index 100% rename from tests/fetchers/test_httpx.py rename to tests/fetchers/sync/test_httpx.py diff --git a/tests/fetchers/test_playwright.py b/tests/fetchers/sync/test_playwright.py similarity index 100% rename from tests/fetchers/test_playwright.py rename to tests/fetchers/sync/test_playwright.py From 361ee440aa1d86dc8a457f913df95653b07a29f2 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 00:20:26 +0200 Subject: [PATCH 30/46] test: adding `pytest-asyncio` plugin to tests requirements file --- tests/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/requirements.txt b/tests/requirements.txt index 394a2ea..52f672c 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -4,5 +4,6 @@ playwright camoufox werkzeug<3.0.0 pytest-httpbin==2.1.0 +pytest-asyncio httpbin~=0.10.0 pytest-xdist From efb3270c670d734333da7e63e63666135b514ecf Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 00:47:02 +0200 Subject: [PATCH 31/46] style: Rewrite asyncFetcher tests to a cleaner version --- tests/fetchers/async/test_async_httpx.py | 151 +++++++++++------------ 1 file changed, 71 insertions(+), 80 deletions(-) diff --git a/tests/fetchers/async/test_async_httpx.py b/tests/fetchers/async/test_async_httpx.py index ef09bb8..67cc037 100644 --- a/tests/fetchers/async/test_async_httpx.py +++ b/tests/fetchers/async/test_async_httpx.py @@ -1,92 +1,83 @@ -import asyncio -import unittest - +import pytest import pytest_httpbin from scrapling.fetchers import AsyncFetcher @pytest_httpbin.use_class_based_httpbin -class TestAsyncFetcher(unittest.TestCase): - def setUp(self): - self.fetcher = AsyncFetcher(auto_match=True) - url = self.httpbin.url - self.status_200 = f'{url}/status/200' - self.status_404 = f'{url}/status/404' - self.status_501 = f'{url}/status/501' - self.basic_url = f'{url}/get' - self.post_url = f'{url}/post' - self.put_url = f'{url}/put' - self.delete_url = f'{url}/delete' - self.html_url = f'{url}/html' - - async def async_test(self, coro): - return await coro - - def test_basic_get(self): +@pytest.mark.asyncio +class TestAsyncFetcher: + @pytest.fixture(scope="class") + def fetcher(self): + return AsyncFetcher(auto_match=True) + + @pytest.fixture(scope="class") + def urls(self, httpbin): + return { + 'status_200': f'{httpbin.url}/status/200', + 'status_404': f'{httpbin.url}/status/404', + 'status_501': f'{httpbin.url}/status/501', + 'basic_url': f'{httpbin.url}/get', + 'post_url': f'{httpbin.url}/post', + 'put_url': f'{httpbin.url}/put', + 'delete_url': f'{httpbin.url}/delete', + 'html_url': f'{httpbin.url}/html' + } + + async def test_basic_get(self, fetcher, urls): """Test doing basic get request with multiple statuses""" + assert (await fetcher.get(urls['status_200'])).status == 200 + assert (await fetcher.get(urls['status_404'])).status == 404 + assert (await fetcher.get(urls['status_501'])).status == 501 - async def run_tests(): - self.assertEqual((await self.fetcher.get(self.status_200)).status, 200) - self.assertEqual((await self.fetcher.get(self.status_404)).status, 404) - self.assertEqual((await self.fetcher.get(self.status_501)).status, 501) - - asyncio.run(run_tests()) - - def test_get_properties(self): + async def test_get_properties(self, fetcher, urls): """Test if different arguments with GET request breaks the code or not""" - - async def run_tests(): - self.assertEqual((await self.fetcher.get(self.status_200, stealthy_headers=True)).status, 200) - self.assertEqual((await self.fetcher.get(self.status_200, follow_redirects=True)).status, 200) - self.assertEqual((await self.fetcher.get(self.status_200, timeout=None)).status, 200) - self.assertEqual( - (await self.fetcher.get(self.status_200, stealthy_headers=True, follow_redirects=True, timeout=None)).status, - 200 - ) - - asyncio.run(run_tests()) - - def test_post_properties(self): + assert (await fetcher.get(urls['status_200'], stealthy_headers=True)).status == 200 + assert (await fetcher.get(urls['status_200'], follow_redirects=True)).status == 200 + assert (await fetcher.get(urls['status_200'], timeout=None)).status == 200 + assert (await fetcher.get( + urls['status_200'], + stealthy_headers=True, + follow_redirects=True, + timeout=None + )).status == 200 + + async def test_post_properties(self, fetcher, urls): """Test if different arguments with POST request breaks the code or not""" - - async def run_tests(): - self.assertEqual((await self.fetcher.post(self.post_url, data={'key': 'value'})).status, 200) - self.assertEqual((await self.fetcher.post(self.post_url, data={'key': 'value'}, stealthy_headers=True)).status, 200) - self.assertEqual((await self.fetcher.post(self.post_url, data={'key': 'value'}, follow_redirects=True)).status, 200) - self.assertEqual((await self.fetcher.post(self.post_url, data={'key': 'value'}, timeout=None)).status, 200) - self.assertEqual( - (await self.fetcher.post(self.post_url, data={'key': 'value'}, stealthy_headers=True, follow_redirects=True, timeout=None)).status, - 200 - ) - - asyncio.run(run_tests()) - - def test_put_properties(self): + assert (await fetcher.post(urls['post_url'], data={'key': 'value'})).status == 200 + assert (await fetcher.post(urls['post_url'], data={'key': 'value'}, stealthy_headers=True)).status == 200 + assert (await fetcher.post(urls['post_url'], data={'key': 'value'}, follow_redirects=True)).status == 200 + assert (await fetcher.post(urls['post_url'], data={'key': 'value'}, timeout=None)).status == 200 + assert (await fetcher.post( + urls['post_url'], + data={'key': 'value'}, + stealthy_headers=True, + follow_redirects=True, + timeout=None + )).status == 200 + + async def test_put_properties(self, fetcher, urls): """Test if different arguments with PUT request breaks the code or not""" - - async def run_tests(): - self.assertIn((await self.fetcher.put(self.put_url, data={'key': 'value'})).status, [200, 405]) - self.assertIn((await self.fetcher.put(self.put_url, data={'key': 'value'}, stealthy_headers=True)).status, [200, 405]) - self.assertIn((await self.fetcher.put(self.put_url, data={'key': 'value'}, follow_redirects=True)).status, [200, 405]) - self.assertIn((await self.fetcher.put(self.put_url, data={'key': 'value'}, timeout=None)).status, [200, 405]) - self.assertIn( - (await self.fetcher.put(self.put_url, data={'key': 'value'}, stealthy_headers=True, follow_redirects=True, timeout=None)).status, - [200, 405] - ) - - asyncio.run(run_tests()) - - def test_delete_properties(self): + assert (await fetcher.put(urls['put_url'], data={'key': 'value'})).status in [200, 405] + assert (await fetcher.put(urls['put_url'], data={'key': 'value'}, stealthy_headers=True)).status in [200, 405] + assert (await fetcher.put(urls['put_url'], data={'key': 'value'}, follow_redirects=True)).status in [200, 405] + assert (await fetcher.put(urls['put_url'], data={'key': 'value'}, timeout=None)).status in [200, 405] + assert (await fetcher.put( + urls['put_url'], + data={'key': 'value'}, + stealthy_headers=True, + follow_redirects=True, + timeout=None + )).status in [200, 405] + + async def test_delete_properties(self, fetcher, urls): """Test if different arguments with DELETE request breaks the code or not""" - - async def run_tests(): - self.assertEqual((await self.fetcher.delete(self.delete_url, stealthy_headers=True)).status, 200) - self.assertEqual((await self.fetcher.delete(self.delete_url, follow_redirects=True)).status, 200) - self.assertEqual((await self.fetcher.delete(self.delete_url, timeout=None)).status, 200) - self.assertEqual( - (await self.fetcher.delete(self.delete_url, stealthy_headers=True, follow_redirects=True, timeout=None)).status, - 200 - ) - - asyncio.run(run_tests()) + assert (await fetcher.delete(urls['delete_url'], stealthy_headers=True)).status == 200 + assert (await fetcher.delete(urls['delete_url'], follow_redirects=True)).status == 200 + assert (await fetcher.delete(urls['delete_url'], timeout=None)).status == 200 + assert (await fetcher.delete( + urls['delete_url'], + stealthy_headers=True, + follow_redirects=True, + timeout=None + )).status == 200 From 79a911e24ed7658bbc3948e94dba34e0aa936247 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 00:59:19 +0200 Subject: [PATCH 32/46] feat(StealthyFetcher): Add async fetch support --- scrapling/engines/camo.py | 74 ++++++++++++++++++++++++++++++++++++--- scrapling/fetchers.py | 58 ++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 4 deletions(-) diff --git a/scrapling/engines/camo.py b/scrapling/engines/camo.py index ec64e6d..5d22aa5 100644 --- a/scrapling/engines/camo.py +++ b/scrapling/engines/camo.py @@ -1,10 +1,12 @@ from camoufox import DefaultAddons +from camoufox.async_api import AsyncCamoufox from camoufox.sync_api import Camoufox from scrapling.core._types import (Callable, Dict, List, Literal, Optional, Union) from scrapling.core.utils import log from scrapling.engines.toolbelt import (Response, StatusText, + async_intercept_route, check_type_validity, construct_proxy_dict, generate_convincing_referer, @@ -120,11 +122,8 @@ def fetch(self, url: str) -> Response: # This will be parsed inside `Response` encoding = res.headers.get('content-type', '') or 'utf-8' # default encoding - - status_text = res.status_text # PlayWright API sometimes give empty status text for some reason! - if not status_text: - status_text = StatusText.get(res.status) + status_text = res.status_text or StatusText.get(res.status) response = Response( url=res.url, @@ -141,3 +140,70 @@ def fetch(self, url: str) -> Response: page.close() return response + + async def async_fetch(self, url: str) -> Response: + """Opens up the browser and do your request based on your chosen options. + + :param url: Target url. + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` + """ + addons = [] if self.disable_ads else [DefaultAddons.UBO] + async with AsyncCamoufox( + geoip=self.geoip, + proxy=self.proxy, + addons=self.addons, + exclude_addons=addons, + headless=self.headless, + humanize=self.humanize, + i_know_what_im_doing=True, # To turn warnings off with the user configurations + allow_webgl=self.allow_webgl, + block_webrtc=self.block_webrtc, + block_images=self.block_images, # Careful! it makes some websites doesn't finish loading at all like stackoverflow even in headful + os=None if self.os_randomize else get_os_name(), + ) as browser: + page = await browser.new_page() + page.set_default_navigation_timeout(self.timeout) + page.set_default_timeout(self.timeout) + if self.disable_resources: + await page.route("**/*", async_intercept_route) + + if self.extra_headers: + await page.set_extra_http_headers(self.extra_headers) + + res = await page.goto(url, referer=generate_convincing_referer(url) if self.google_search else None) + await page.wait_for_load_state(state="domcontentloaded") + if self.network_idle: + await page.wait_for_load_state('networkidle') + + if self.page_action is not None: + page = await self.page_action(page) + + if self.wait_selector and type(self.wait_selector) is str: + waiter = page.locator(self.wait_selector) + await waiter.first.wait_for(state=self.wait_selector_state) + # Wait again after waiting for the selector, helpful with protections like Cloudflare + await page.wait_for_load_state(state="load") + await page.wait_for_load_state(state="domcontentloaded") + if self.network_idle: + await page.wait_for_load_state('networkidle') + + # This will be parsed inside `Response` + encoding = res.headers.get('content-type', '') or 'utf-8' # default encoding + # PlayWright API sometimes give empty status text for some reason! + status_text = res.status_text or StatusText.get(res.status) + + response = Response( + url=res.url, + text=await page.content(), + body=(await page.content()).encode('utf-8'), + status=res.status, + reason=status_text, + encoding=encoding, + cookies={cookie['name']: cookie['value'] for cookie in await page.context.cookies()}, + headers=await res.all_headers(), + request_headers=await res.request.all_headers(), + **self.adaptor_arguments + ) + await page.close() + + return response diff --git a/scrapling/fetchers.py b/scrapling/fetchers.py index 6ba6689..86f77ae 100644 --- a/scrapling/fetchers.py +++ b/scrapling/fetchers.py @@ -230,6 +230,64 @@ def fetch( ) return engine.fetch(url) + async def async_fetch( + self, url: str, headless: Optional[Union[bool, Literal['virtual']]] = True, block_images: Optional[bool] = False, disable_resources: Optional[bool] = False, + block_webrtc: Optional[bool] = False, allow_webgl: Optional[bool] = True, network_idle: Optional[bool] = False, addons: Optional[List[str]] = None, + timeout: Optional[float] = 30000, page_action: Callable = None, wait_selector: Optional[str] = None, humanize: Optional[Union[bool, float]] = True, + wait_selector_state: str = 'attached', google_search: Optional[bool] = True, extra_headers: Optional[Dict[str, str]] = None, proxy: Optional[Union[str, Dict[str, str]]] = None, + os_randomize: Optional[bool] = None, disable_ads: Optional[bool] = True, geoip: Optional[bool] = False, + ) -> Response: + """ + Opens up a browser and do your request based on your chosen options below. + + :param url: Target url. + :param headless: Run the browser in headless/hidden (default), 'virtual' screen mode, or headful/visible mode. + :param block_images: Prevent the loading of images through Firefox preferences. + This can help save your proxy usage but be careful with this option as it makes some websites never finish loading. + :param disable_resources: Drop requests of unnecessary resources for a speed boost. It depends but it made requests ~25% faster in my tests for some websites. + Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`. + This can help save your proxy usage but be careful with this option as it makes some websites never finish loading. + :param block_webrtc: Blocks WebRTC entirely. + :param addons: List of Firefox addons to use. Must be paths to extracted addons. + :param disable_ads: Enabled by default, this installs `uBlock Origin` addon on the browser if enabled. + :param humanize: Humanize the cursor movement. Takes either True or the MAX duration in seconds of the cursor movement. The cursor typically takes up to 1.5 seconds to move across the window. + :param allow_webgl: Enabled by default. Disabling it WebGL not recommended as many WAFs now checks if WebGL is enabled. + :param geoip: Recommended to use with proxies; Automatically use IP's longitude, latitude, timezone, country, locale, & spoof the WebRTC IP address. + It will also calculate and spoof the browser's language based on the distribution of language speakers in the target region. + :param network_idle: Wait for the page until there are no network connections for at least 500 ms. + :param os_randomize: If enabled, Scrapling will randomize the OS fingerprints used. The default is Scrapling matching the fingerprints with the current OS. + :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30000 + :param page_action: Added for automation. A function that takes the `page` object, does the automation you need, then returns `page` again. + :param wait_selector: Wait for a specific css selector to be in a specific state. + :param wait_selector_state: The state to wait for the selector given with `wait_selector`. Default state is `attached`. + :param google_search: Enabled by default, Scrapling will set the referer header to be as if this request came from a Google search for this website's domain name. + :param extra_headers: A dictionary of extra headers to add to the request. _The referer set by the `google_search` argument takes priority over the referer set here if used together._ + :param proxy: The proxy to be used with requests, it can be a string or a dictionary with the keys 'server', 'username', and 'password' only. + :return: A `Response` object that is the same as `Adaptor` object except it has these added attributes: `status`, `reason`, `cookies`, `headers`, and `request_headers` + """ + engine = CamoufoxEngine( + proxy=proxy, + geoip=geoip, + addons=addons, + timeout=timeout, + headless=headless, + humanize=humanize, + disable_ads=disable_ads, + allow_webgl=allow_webgl, + page_action=page_action, + network_idle=network_idle, + block_images=block_images, + block_webrtc=block_webrtc, + os_randomize=os_randomize, + wait_selector=wait_selector, + google_search=google_search, + extra_headers=extra_headers, + disable_resources=disable_resources, + wait_selector_state=wait_selector_state, + adaptor_arguments=self.adaptor_arguments, + ) + return await engine.async_fetch(url) + class PlayWrightFetcher(BaseFetcher): """A `Fetcher` class type that provide many options, all of them are based on PlayWright. From 448587ff414a5aa48a6b110f7ef7d66d592d6cef Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 01:00:05 +0200 Subject: [PATCH 33/46] test: add tests for StealthyFetcher async fetch --- tests/fetchers/async/test_camoufox.py | 95 +++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/fetchers/async/test_camoufox.py diff --git a/tests/fetchers/async/test_camoufox.py b/tests/fetchers/async/test_camoufox.py new file mode 100644 index 0000000..f2f495d --- /dev/null +++ b/tests/fetchers/async/test_camoufox.py @@ -0,0 +1,95 @@ +import pytest +import pytest_httpbin + +from scrapling import StealthyFetcher + + +@pytest_httpbin.use_class_based_httpbin +@pytest.mark.asyncio +class TestStealthyFetcher: + @pytest.fixture(scope="class") + def fetcher(self): + return StealthyFetcher(auto_match=False) + + @pytest.fixture(scope="class") + def urls(self, httpbin): + url = httpbin.url + return { + 'status_200': f'{url}/status/200', + 'status_404': f'{url}/status/404', + 'status_501': f'{url}/status/501', + 'basic_url': f'{url}/get', + 'html_url': f'{url}/html', + 'delayed_url': f'{url}/delay/10', # 10 Seconds delay response + 'cookies_url': f"{url}/cookies/set/test/value" + } + + async def test_basic_fetch(self, fetcher, urls): + """Test doing basic fetch request with multiple statuses""" + assert (await fetcher.async_fetch(urls['status_200'])).status == 200 + assert (await fetcher.async_fetch(urls['status_404'])).status == 404 + assert (await fetcher.async_fetch(urls['status_501'])).status == 501 + + async def test_networkidle(self, fetcher, urls): + """Test if waiting for `networkidle` make page does not finish loading or not""" + assert (await fetcher.async_fetch(urls['basic_url'], network_idle=True)).status == 200 + + async def test_blocking_resources(self, fetcher, urls): + """Test if blocking resources make page does not finish loading or not""" + assert (await fetcher.async_fetch(urls['basic_url'], block_images=True)).status == 200 + assert (await fetcher.async_fetch(urls['basic_url'], disable_resources=True)).status == 200 + + async def test_waiting_selector(self, fetcher, urls): + """Test if waiting for a selector make page does not finish loading or not""" + assert (await fetcher.async_fetch(urls['html_url'], wait_selector='h1')).status == 200 + assert (await fetcher.async_fetch( + urls['html_url'], + wait_selector='h1', + wait_selector_state='visible' + )).status == 200 + + async def test_cookies_loading(self, fetcher, urls): + """Test if cookies are set after the request""" + response = await fetcher.async_fetch(urls['cookies_url']) + assert response.cookies == {'test': 'value'} + + async def test_automation(self, fetcher, urls): + """Test if automation break the code or not""" + + async def scroll_page(page): + await page.mouse.wheel(10, 0) + await page.mouse.move(100, 400) + await page.mouse.up() + return page + + assert (await fetcher.async_fetch(urls['html_url'], page_action=scroll_page)).status == 200 + + async def test_properties(self, fetcher, urls): + """Test if different arguments breaks the code or not""" + assert (await fetcher.async_fetch( + urls['html_url'], + block_webrtc=True, + allow_webgl=True + )).status == 200 + + assert (await fetcher.async_fetch( + urls['html_url'], + block_webrtc=False, + allow_webgl=True + )).status == 200 + + assert (await fetcher.async_fetch( + urls['html_url'], + block_webrtc=True, + allow_webgl=False + )).status == 200 + + assert (await fetcher.async_fetch( + urls['html_url'], + extra_headers={'ayo': ''}, + os_randomize=True + )).status == 200 + + async def test_infinite_timeout(self, fetcher, urls): + """Test if infinite timeout breaks the code or not""" + assert (await fetcher.async_fetch(urls['delayed_url'], timeout=None)).status == 200 From 2606f7a12d486168db4b4c818bbc5414b9a20fac Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 01:01:59 +0200 Subject: [PATCH 34/46] test: clearer naming for async tests --- tests/fetchers/async/{test_async_httpx.py => test_httpx.py} | 0 .../async/{test_async_playwright.py => test_playwright.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/fetchers/async/{test_async_httpx.py => test_httpx.py} (100%) rename tests/fetchers/async/{test_async_playwright.py => test_playwright.py} (100%) diff --git a/tests/fetchers/async/test_async_httpx.py b/tests/fetchers/async/test_httpx.py similarity index 100% rename from tests/fetchers/async/test_async_httpx.py rename to tests/fetchers/async/test_httpx.py diff --git a/tests/fetchers/async/test_async_playwright.py b/tests/fetchers/async/test_playwright.py similarity index 100% rename from tests/fetchers/async/test_async_playwright.py rename to tests/fetchers/async/test_playwright.py From 3ecffcb81c931bb99f8ff5fff350539cdd5a1579 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 01:27:18 +0200 Subject: [PATCH 35/46] style: Rewrite sync Fetchers tests to a cleaner version --- tests/fetchers/sync/test_camoufox.py | 77 ++++++++-------- tests/fetchers/sync/test_httpx.py | 118 ++++++++++++++----------- tests/fetchers/sync/test_playwright.py | 103 +++++++++++---------- 3 files changed, 162 insertions(+), 136 deletions(-) diff --git a/tests/fetchers/sync/test_camoufox.py b/tests/fetchers/sync/test_camoufox.py index fcbf3b7..33800f4 100644 --- a/tests/fetchers/sync/test_camoufox.py +++ b/tests/fetchers/sync/test_camoufox.py @@ -1,49 +1,52 @@ -import unittest - +import pytest import pytest_httpbin from scrapling import StealthyFetcher @pytest_httpbin.use_class_based_httpbin -# @pytest_httpbin.use_class_based_httpbin_secure -class TestStealthyFetcher(unittest.TestCase): - def setUp(self): - self.fetcher = StealthyFetcher(auto_match=False) - url = self.httpbin.url - self.status_200 = f'{url}/status/200' - self.status_404 = f'{url}/status/404' - self.status_501 = f'{url}/status/501' - self.basic_url = f'{url}/get' - self.html_url = f'{url}/html' - self.delayed_url = f'{url}/delay/10' # 10 Seconds delay response - self.cookies_url = f"{url}/cookies/set/test/value" +class TestStealthyFetcher: + @pytest.fixture(scope="class") + def fetcher(self): + """Fixture to create a StealthyFetcher instance for the entire test class""" + return StealthyFetcher(auto_match=False) + + @pytest.fixture(autouse=True) + def setup_urls(self, httpbin): + """Fixture to set up URLs for testing""" + self.status_200 = f'{httpbin.url}/status/200' + self.status_404 = f'{httpbin.url}/status/404' + self.status_501 = f'{httpbin.url}/status/501' + self.basic_url = f'{httpbin.url}/get' + self.html_url = f'{httpbin.url}/html' + self.delayed_url = f'{httpbin.url}/delay/10' # 10 Seconds delay response + self.cookies_url = f"{httpbin.url}/cookies/set/test/value" - def test_basic_fetch(self): + def test_basic_fetch(self, fetcher): """Test doing basic fetch request with multiple statuses""" - self.assertEqual(self.fetcher.fetch(self.status_200).status, 200) - self.assertEqual(self.fetcher.fetch(self.status_404).status, 404) - self.assertEqual(self.fetcher.fetch(self.status_501).status, 501) + assert fetcher.fetch(self.status_200).status == 200 + assert fetcher.fetch(self.status_404).status == 404 + assert fetcher.fetch(self.status_501).status == 501 - def test_networkidle(self): + def test_networkidle(self, fetcher): """Test if waiting for `networkidle` make page does not finish loading or not""" - self.assertEqual(self.fetcher.fetch(self.basic_url, network_idle=True).status, 200) + assert fetcher.fetch(self.basic_url, network_idle=True).status == 200 - def test_blocking_resources(self): + def test_blocking_resources(self, fetcher): """Test if blocking resources make page does not finish loading or not""" - self.assertEqual(self.fetcher.fetch(self.basic_url, block_images=True).status, 200) - self.assertEqual(self.fetcher.fetch(self.basic_url, disable_resources=True).status, 200) + assert fetcher.fetch(self.basic_url, block_images=True).status == 200 + assert fetcher.fetch(self.basic_url, disable_resources=True).status == 200 - def test_waiting_selector(self): + def test_waiting_selector(self, fetcher): """Test if waiting for a selector make page does not finish loading or not""" - self.assertEqual(self.fetcher.fetch(self.html_url, wait_selector='h1').status, 200) - self.assertEqual(self.fetcher.fetch(self.html_url, wait_selector='h1', wait_selector_state='visible').status, 200) + assert fetcher.fetch(self.html_url, wait_selector='h1').status == 200 + assert fetcher.fetch(self.html_url, wait_selector='h1', wait_selector_state='visible').status == 200 - def test_cookies_loading(self): + def test_cookies_loading(self, fetcher): """Test if cookies are set after the request""" - self.assertEqual(self.fetcher.fetch(self.cookies_url).cookies, {'test': 'value'}) + assert fetcher.fetch(self.cookies_url).cookies == {'test': 'value'} - def test_automation(self): + def test_automation(self, fetcher): """Test if automation break the code or not""" def scroll_page(page): page.mouse.wheel(10, 0) @@ -51,15 +54,15 @@ def scroll_page(page): page.mouse.up() return page - self.assertEqual(self.fetcher.fetch(self.html_url, page_action=scroll_page).status, 200) + assert fetcher.fetch(self.html_url, page_action=scroll_page).status == 200 - def test_properties(self): + def test_properties(self, fetcher): """Test if different arguments breaks the code or not""" - self.assertEqual(self.fetcher.fetch(self.html_url, block_webrtc=True, allow_webgl=True).status, 200) - self.assertEqual(self.fetcher.fetch(self.html_url, block_webrtc=False, allow_webgl=True).status, 200) - self.assertEqual(self.fetcher.fetch(self.html_url, block_webrtc=True, allow_webgl=False).status, 200) - self.assertEqual(self.fetcher.fetch(self.html_url, extra_headers={'ayo': ''}, os_randomize=True).status, 200) + assert fetcher.fetch(self.html_url, block_webrtc=True, allow_webgl=True).status == 200 + assert fetcher.fetch(self.html_url, block_webrtc=False, allow_webgl=True).status == 200 + assert fetcher.fetch(self.html_url, block_webrtc=True, allow_webgl=False).status == 200 + assert fetcher.fetch(self.html_url, extra_headers={'ayo': ''}, os_randomize=True).status == 200 - def test_infinite_timeout(self): + def test_infinite_timeout(self, fetcher): """Test if infinite timeout breaks the code or not""" - self.assertEqual(self.fetcher.fetch(self.delayed_url, timeout=None).status, 200) + assert fetcher.fetch(self.delayed_url, timeout=None).status == 200 diff --git a/tests/fetchers/sync/test_httpx.py b/tests/fetchers/sync/test_httpx.py index 1a5cc02..9f5ca80 100644 --- a/tests/fetchers/sync/test_httpx.py +++ b/tests/fetchers/sync/test_httpx.py @@ -1,68 +1,82 @@ -import unittest - +import pytest import pytest_httpbin from scrapling import Fetcher @pytest_httpbin.use_class_based_httpbin -class TestFetcher(unittest.TestCase): - def setUp(self): - self.fetcher = Fetcher(auto_match=False) - url = self.httpbin.url - self.status_200 = f'{url}/status/200' - self.status_404 = f'{url}/status/404' - self.status_501 = f'{url}/status/501' - self.basic_url = f'{url}/get' - self.post_url = f'{url}/post' - self.put_url = f'{url}/put' - self.delete_url = f'{url}/delete' - self.html_url = f'{url}/html' +class TestFetcher: + @pytest.fixture(scope="class") + def fetcher(self): + """Fixture to create a Fetcher instance for the entire test class""" + return Fetcher(auto_match=False) + + @pytest.fixture(autouse=True) + def setup_urls(self, httpbin): + """Fixture to set up URLs for testing""" + self.status_200 = f'{httpbin.url}/status/200' + self.status_404 = f'{httpbin.url}/status/404' + self.status_501 = f'{httpbin.url}/status/501' + self.basic_url = f'{httpbin.url}/get' + self.post_url = f'{httpbin.url}/post' + self.put_url = f'{httpbin.url}/put' + self.delete_url = f'{httpbin.url}/delete' + self.html_url = f'{httpbin.url}/html' - def test_basic_get(self): + def test_basic_get(self, fetcher): """Test doing basic get request with multiple statuses""" - self.assertEqual(self.fetcher.get(self.status_200).status, 200) - self.assertEqual(self.fetcher.get(self.status_404).status, 404) - self.assertEqual(self.fetcher.get(self.status_501).status, 501) + assert fetcher.get(self.status_200).status == 200 + assert fetcher.get(self.status_404).status == 404 + assert fetcher.get(self.status_501).status == 501 - def test_get_properties(self): + def test_get_properties(self, fetcher): """Test if different arguments with GET request breaks the code or not""" - self.assertEqual(self.fetcher.get(self.status_200, stealthy_headers=True).status, 200) - self.assertEqual(self.fetcher.get(self.status_200, follow_redirects=True).status, 200) - self.assertEqual(self.fetcher.get(self.status_200, timeout=None).status, 200) - self.assertEqual( - self.fetcher.get(self.status_200, stealthy_headers=True, follow_redirects=True, timeout=None).status, - 200 - ) + assert fetcher.get(self.status_200, stealthy_headers=True).status == 200 + assert fetcher.get(self.status_200, follow_redirects=True).status == 200 + assert fetcher.get(self.status_200, timeout=None).status == 200 + assert fetcher.get( + self.status_200, + stealthy_headers=True, + follow_redirects=True, + timeout=None + ).status == 200 - def test_post_properties(self): + def test_post_properties(self, fetcher): """Test if different arguments with POST request breaks the code or not""" - self.assertEqual(self.fetcher.post(self.post_url, data={'key': 'value'}).status, 200) - self.assertEqual(self.fetcher.post(self.post_url, data={'key': 'value'}, stealthy_headers=True).status, 200) - self.assertEqual(self.fetcher.post(self.post_url, data={'key': 'value'}, follow_redirects=True).status, 200) - self.assertEqual(self.fetcher.post(self.post_url, data={'key': 'value'}, timeout=None).status, 200) - self.assertEqual( - self.fetcher.post(self.post_url, data={'key': 'value'}, stealthy_headers=True, follow_redirects=True, timeout=None).status, - 200 - ) + assert fetcher.post(self.post_url, data={'key': 'value'}).status == 200 + assert fetcher.post(self.post_url, data={'key': 'value'}, stealthy_headers=True).status == 200 + assert fetcher.post(self.post_url, data={'key': 'value'}, follow_redirects=True).status == 200 + assert fetcher.post(self.post_url, data={'key': 'value'}, timeout=None).status == 200 + assert fetcher.post( + self.post_url, + data={'key': 'value'}, + stealthy_headers=True, + follow_redirects=True, + timeout=None + ).status == 200 - def test_put_properties(self): + def test_put_properties(self, fetcher): """Test if different arguments with PUT request breaks the code or not""" - self.assertEqual(self.fetcher.put(self.put_url, data={'key': 'value'}).status, 200) - self.assertEqual(self.fetcher.put(self.put_url, data={'key': 'value'}, stealthy_headers=True).status, 200) - self.assertEqual(self.fetcher.put(self.put_url, data={'key': 'value'}, follow_redirects=True).status, 200) - self.assertEqual(self.fetcher.put(self.put_url, data={'key': 'value'}, timeout=None).status, 200) - self.assertEqual( - self.fetcher.put(self.put_url, data={'key': 'value'}, stealthy_headers=True, follow_redirects=True, timeout=None).status, - 200 - ) + assert fetcher.put(self.put_url, data={'key': 'value'}).status == 200 + assert fetcher.put(self.put_url, data={'key': 'value'}, stealthy_headers=True).status == 200 + assert fetcher.put(self.put_url, data={'key': 'value'}, follow_redirects=True).status == 200 + assert fetcher.put(self.put_url, data={'key': 'value'}, timeout=None).status == 200 + assert fetcher.put( + self.put_url, + data={'key': 'value'}, + stealthy_headers=True, + follow_redirects=True, + timeout=None + ).status == 200 - def test_delete_properties(self): + def test_delete_properties(self, fetcher): """Test if different arguments with DELETE request breaks the code or not""" - self.assertEqual(self.fetcher.delete(self.delete_url, stealthy_headers=True).status, 200) - self.assertEqual(self.fetcher.delete(self.delete_url, follow_redirects=True).status, 200) - self.assertEqual(self.fetcher.delete(self.delete_url, timeout=None).status, 200) - self.assertEqual( - self.fetcher.delete(self.delete_url, stealthy_headers=True, follow_redirects=True, timeout=None).status, - 200 - ) + assert fetcher.delete(self.delete_url, stealthy_headers=True).status == 200 + assert fetcher.delete(self.delete_url, follow_redirects=True).status == 200 + assert fetcher.delete(self.delete_url, timeout=None).status == 200 + assert fetcher.delete( + self.delete_url, + stealthy_headers=True, + follow_redirects=True, + timeout=None + ).status == 200 diff --git a/tests/fetchers/sync/test_playwright.py b/tests/fetchers/sync/test_playwright.py index a22ecfd..e1f424c 100644 --- a/tests/fetchers/sync/test_playwright.py +++ b/tests/fetchers/sync/test_playwright.py @@ -1,78 +1,87 @@ -import unittest - +import pytest import pytest_httpbin from scrapling import PlayWrightFetcher @pytest_httpbin.use_class_based_httpbin -# @pytest_httpbin.use_class_based_httpbin_secure -class TestPlayWrightFetcher(unittest.TestCase): - def setUp(self): - self.fetcher = PlayWrightFetcher(auto_match=False) - url = self.httpbin.url - self.status_200 = f'{url}/status/200' - self.status_404 = f'{url}/status/404' - self.status_501 = f'{url}/status/501' - self.basic_url = f'{url}/get' - self.html_url = f'{url}/html' - self.delayed_url = f'{url}/delay/10' # 10 Seconds delay response - self.cookies_url = f"{url}/cookies/set/test/value" - - def test_basic_fetch(self): +class TestPlayWrightFetcher: + + @pytest.fixture(scope="class") + def fetcher(self): + """Fixture to create a StealthyFetcher instance for the entire test class""" + return PlayWrightFetcher(auto_match=False) + + @pytest.fixture(autouse=True) + def setup_urls(self, httpbin): + """Fixture to set up URLs for testing""" + self.status_200 = f'{httpbin.url}/status/200' + self.status_404 = f'{httpbin.url}/status/404' + self.status_501 = f'{httpbin.url}/status/501' + self.basic_url = f'{httpbin.url}/get' + self.html_url = f'{httpbin.url}/html' + self.delayed_url = f'{httpbin.url}/delay/10' # 10 Seconds delay response + self.cookies_url = f"{httpbin.url}/cookies/set/test/value" + + def test_basic_fetch(self, fetcher): """Test doing basic fetch request with multiple statuses""" - self.assertEqual(self.fetcher.fetch(self.status_200).status, 200) + assert fetcher.fetch(self.status_200).status == 200 # There's a bug with playwright makes it crashes if a URL returns status code 4xx/5xx without body, let's disable this till they reply to my issue report - # self.assertEqual(self.fetcher.fetch(self.status_404).status, 404) - # self.assertEqual(self.fetcher.fetch(self.status_501).status, 501) + # assert fetcher.fetch(self.status_404).status == 404 + # assert fetcher.fetch(self.status_501).status == 501 - def test_networkidle(self): + def test_networkidle(self, fetcher): """Test if waiting for `networkidle` make page does not finish loading or not""" - self.assertEqual(self.fetcher.fetch(self.basic_url, network_idle=True).status, 200) + assert fetcher.fetch(self.basic_url, network_idle=True).status == 200 - def test_blocking_resources(self): + def test_blocking_resources(self, fetcher): """Test if blocking resources make page does not finish loading or not""" - self.assertEqual(self.fetcher.fetch(self.basic_url, disable_resources=True).status, 200) + assert fetcher.fetch(self.basic_url, disable_resources=True).status == 200 - def test_waiting_selector(self): + def test_waiting_selector(self, fetcher): """Test if waiting for a selector make page does not finish loading or not""" - self.assertEqual(self.fetcher.fetch(self.html_url, wait_selector='h1').status, 200) - self.assertEqual(self.fetcher.fetch(self.html_url, wait_selector='h1', wait_selector_state='visible').status, 200) + assert fetcher.fetch(self.html_url, wait_selector='h1').status == 200 + assert fetcher.fetch(self.html_url, wait_selector='h1', wait_selector_state='visible').status == 200 - def test_cookies_loading(self): + def test_cookies_loading(self, fetcher): """Test if cookies are set after the request""" - self.assertEqual(self.fetcher.fetch(self.cookies_url).cookies, {'test': 'value'}) + assert fetcher.fetch(self.cookies_url).cookies == {'test': 'value'} - def test_automation(self): + def test_automation(self, fetcher): """Test if automation break the code or not""" + def scroll_page(page): page.mouse.wheel(10, 0) page.mouse.move(100, 400) page.mouse.up() return page - self.assertEqual(self.fetcher.fetch(self.html_url, page_action=scroll_page).status, 200) + assert fetcher.fetch(self.html_url, page_action=scroll_page).status == 200 - def test_properties(self): + @pytest.mark.parametrize("kwargs", [ + {"disable_webgl": True, "hide_canvas": False}, + {"disable_webgl": False, "hide_canvas": True}, + {"stealth": True}, + {"useragent": 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0'}, + {"extra_headers": {'ayo': ''}} + ]) + def test_properties(self, fetcher, kwargs): """Test if different arguments breaks the code or not""" - self.assertEqual(self.fetcher.fetch(self.html_url, disable_webgl=True, hide_canvas=False).status, 200) - self.assertEqual(self.fetcher.fetch(self.html_url, disable_webgl=False, hide_canvas=True).status, 200) - self.assertEqual(self.fetcher.fetch(self.html_url, stealth=True).status, 200) - self.assertEqual(self.fetcher.fetch(self.html_url, useragent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0').status, 200) - self.assertEqual(self.fetcher.fetch(self.html_url, extra_headers={'ayo': ''}).status, 200) + response = fetcher.fetch(self.html_url, **kwargs) + assert response.status == 200 - def test_cdp_url(self): - """Test if it's going to try to connect to cdp url or not""" - with self.assertRaises(ValueError): - _ = self.fetcher.fetch(self.html_url, cdp_url='blahblah') + def test_cdp_url_invalid(self, fetcher): + """Test if invalid CDP URLs raise appropriate exceptions""" + with pytest.raises(ValueError): + fetcher.fetch(self.html_url, cdp_url='blahblah') - with self.assertRaises(ValueError): - _ = self.fetcher.fetch(self.html_url, cdp_url='blahblah', nstbrowser_mode=True) + with pytest.raises(ValueError): + fetcher.fetch(self.html_url, cdp_url='blahblah', nstbrowser_mode=True) - with self.assertRaises(Exception): - # There's no type for this error in PlayWright, it's just `Error` - _ = self.fetcher.fetch(self.html_url, cdp_url='ws://blahblah') + with pytest.raises(Exception): + fetcher.fetch(self.html_url, cdp_url='ws://blahblah') - def test_infinite_timeout(self): + def test_infinite_timeout(self, fetcher, ): """Test if infinite timeout breaks the code or not""" - self.assertEqual(self.fetcher.fetch(self.delayed_url, timeout=None).status, 200) + response = fetcher.fetch(self.delayed_url, timeout=None) + assert response.status == 200 From eceef48ef5f77b3d547206f64d70893df31d68dd Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 12:34:23 +0200 Subject: [PATCH 36/46] fix(Fetchers/page_action): Fixing logic --- scrapling/engines/camo.py | 9 ++++++--- scrapling/engines/pw.py | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/scrapling/engines/camo.py b/scrapling/engines/camo.py index 5d22aa5..1eb9976 100644 --- a/scrapling/engines/camo.py +++ b/scrapling/engines/camo.py @@ -64,11 +64,14 @@ def __init__( self.addons = addons or [] self.humanize = humanize self.timeout = check_type_validity(timeout, [int, float], 30000) - if callable(page_action): - self.page_action = page_action + if page_action is not None: + if callable(page_action): + self.page_action = page_action + else: + self.page_action = None + log.error('[Ignored] Argument "page_action" must be callable') else: self.page_action = None - log.error('[Ignored] Argument "page_action" must be callable') self.wait_selector = wait_selector self.wait_selector_state = wait_selector_state diff --git a/scrapling/engines/pw.py b/scrapling/engines/pw.py index 16d7a8a..e4c80b7 100644 --- a/scrapling/engines/pw.py +++ b/scrapling/engines/pw.py @@ -75,11 +75,14 @@ def __init__( self.cdp_url = cdp_url self.useragent = useragent self.timeout = check_type_validity(timeout, [int, float], 30000) - if page_action is not None and callable(page_action): - self.page_action = page_action + if page_action is not None: + if callable(page_action): + self.page_action = page_action + else: + self.page_action = None + log.error('[Ignored] Argument "page_action" must be callable') else: self.page_action = None - log.error('[Ignored] Argument "page_action" must be callable') self.wait_selector = wait_selector self.wait_selector_state = wait_selector_state From d66ebc19a560a9003f94c03309a6f4e133258dc3 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 12:35:25 +0200 Subject: [PATCH 37/46] fix(Fetchers/disable_resources): Fixing the logic for intercepting resources --- scrapling/engines/toolbelt/navigation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scrapling/engines/toolbelt/navigation.py b/scrapling/engines/toolbelt/navigation.py index 0a9ff86..14a26d0 100644 --- a/scrapling/engines/toolbelt/navigation.py +++ b/scrapling/engines/toolbelt/navigation.py @@ -21,7 +21,8 @@ def intercept_route(route: Route): if route.request.resource_type in DEFAULT_DISABLED_RESOURCES: log.debug(f'Blocking background resource "{route.request.url}" of type "{route.request.resource_type}"') route.abort() - route.continue_() + else: + route.continue_() async def async_intercept_route(route: async_Route): @@ -33,7 +34,8 @@ async def async_intercept_route(route: async_Route): if route.request.resource_type in DEFAULT_DISABLED_RESOURCES: log.debug(f'Blocking background resource "{route.request.url}" of type "{route.request.resource_type}"') await route.abort() - await route.continue_() + else: + await route.continue_() def construct_proxy_dict(proxy_string: Union[str, Dict[str, str]]) -> Union[Dict, None]: From 05c6eeb3870a6fb4750faa1f41fcdd5761c6c8f6 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 12:37:29 +0200 Subject: [PATCH 38/46] fix: add AsyncFetcher to top-level shortcuts --- scrapling/__init__.py | 6 +++--- scrapling/defaults.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scrapling/__init__.py b/scrapling/__init__.py index 081d7ea..b8c7396 100644 --- a/scrapling/__init__.py +++ b/scrapling/__init__.py @@ -1,7 +1,7 @@ # Declare top-level shortcuts from scrapling.core.custom_types import AttributesHandler, TextHandler -from scrapling.fetchers import (CustomFetcher, Fetcher, PlayWrightFetcher, - StealthyFetcher) +from scrapling.fetchers import (AsyncFetcher, CustomFetcher, Fetcher, + PlayWrightFetcher, StealthyFetcher) from scrapling.parser import Adaptor, Adaptors __author__ = "Karim Shoair (karim.shoair@pm.me)" @@ -9,4 +9,4 @@ __copyright__ = "Copyright (c) 2024 Karim Shoair" -__all__ = ['Adaptor', 'Fetcher', 'StealthyFetcher', 'PlayWrightFetcher'] +__all__ = ['Adaptor', 'Fetcher', 'AsyncFetcher', 'StealthyFetcher', 'PlayWrightFetcher'] diff --git a/scrapling/defaults.py b/scrapling/defaults.py index 73618a4..64fd1b7 100644 --- a/scrapling/defaults.py +++ b/scrapling/defaults.py @@ -1,6 +1,7 @@ -from .fetchers import Fetcher, PlayWrightFetcher, StealthyFetcher +from .fetchers import AsyncFetcher, Fetcher, PlayWrightFetcher, StealthyFetcher # If you are going to use Fetchers with the default settings, import them from this file instead for a cleaner looking code Fetcher = Fetcher() +AsyncFetcher = AsyncFetcher() StealthyFetcher = StealthyFetcher() PlayWrightFetcher = PlayWrightFetcher() From 6cf5ce97197c623ff36449bad28115dcf758a50c Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 12:39:44 +0200 Subject: [PATCH 39/46] docs: Adding async examples and fixing some typos --- README.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7477a14..ecf7af1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Dealing with failing web scrapers due to anti-bot protections or website changes Scrapling is a high-performance, intelligent web scraping library for Python that automatically adapts to website changes while significantly outperforming popular alternatives. For both beginners and experts, Scrapling provides powerful features while maintaining simplicity. ```python ->> from scrapling.defaults import Fetcher, StealthyFetcher, PlayWrightFetcher +>> from scrapling.defaults import Fetcher, AsyncFetcher, StealthyFetcher, PlayWrightFetcher # Fetch websites' source under the radar! >> page = StealthyFetcher.fetch('https://example.com', headless=True, network_idle=True) >> print(page.status) @@ -76,7 +76,7 @@ Scrapling is a high-performance, intelligent web scraping library for Python tha ## Key Features -### Fetch websites as you prefer +### Fetch websites as you prefer with async support - **HTTP requests**: Stealthy and fast HTTP requests with `Fetcher` - **Stealthy fetcher**: Annoying anti-bot protection? No problem! Scrapling can bypass almost all of them with `StealthyFetcher` with default configuration! - **Your preferred browser**: Use your real browser with CDP, [NSTbrowser](https://app.nstbrowser.io/r/1vO5e5)'s browserless, PlayWright with stealth mode, or even vanilla PlayWright - All is possible with `PlayWrightFetcher`! @@ -167,7 +167,7 @@ Scrapling can find elements with more methods and it returns full element `Adapt > All benchmarks' results are an average of 100 runs. See our [benchmarks.py](https://github.com/D4Vinci/Scrapling/blob/main/benchmarks.py) for methodology and to run your comparisons. ## Installation -Scrapling is a breeze to get started with - Starting from version 0.2, we require at least Python 3.8 to work. +Scrapling is a breeze to get started with - Starting from version 0.2.9, we require at least Python 3.9 to work. ```bash pip3 install scrapling ``` @@ -223,7 +223,7 @@ All of them can take these initialization arguments: `auto_match`, `huge_tree`, If you don't want to pass arguments to the generated `Adaptor` object and want to use the default values, you can use this import instead for cleaner code: ```python -from scrapling.defaults import Fetcher, StealthyFetcher, PlayWrightFetcher +from scrapling.defaults import Fetcher, AsyncFetcher, StealthyFetcher, PlayWrightFetcher ``` then use it right away without initializing like: ```python @@ -236,21 +236,32 @@ Also, the `Response` object returned from all fetchers is the same as the `Adapt ### Fetcher This class is built on top of [httpx](https://www.python-httpx.org/) with additional configuration options, here you can do `GET`, `POST`, `PUT`, and `DELETE` requests. -For all methods, you have `stealth_headers` which makes `Fetcher` create and use real browser's headers then create a referer header as if this request came from Google's search of this URL's domain. It's enabled by default. You can also set the number of retries with the argument `retries` for all methods and this will make httpx retry requests if it failed for any reason. The default number of retries for all `Fetcher` methods is 3. +For all methods, you have `stealthy_headers` which makes `Fetcher` create and use real browser's headers then create a referer header as if this request came from Google's search of this URL's domain. It's enabled by default. You can also set the number of retries with the argument `retries` for all methods and this will make httpx retry requests if it failed for any reason. The default number of retries for all `Fetcher` methods is 3. You can route all traffic (HTTP and HTTPS) to a proxy for any of these methods in this format `http://username:password@localhost:8030` ```python ->> page = Fetcher().get('https://httpbin.org/get', stealth_headers=True, follow_redirects=True) +>> page = Fetcher().get('https://httpbin.org/get', stealthy_headers=True, follow_redirects=True) >> page = Fetcher().post('https://httpbin.org/post', data={'key': 'value'}, proxy='http://username:password@localhost:8030') >> page = Fetcher().put('https://httpbin.org/put', data={'key': 'value'}) >> page = Fetcher().delete('https://httpbin.org/delete') ``` +For Async requests, you will just replace the import like below: +```python +>> from scrapling import AsyncFetcher +>> page = await AsyncFetcher().get('https://httpbin.org/get', stealthy_headers=True, follow_redirects=True) +>> page = await AsyncFetcher().post('https://httpbin.org/post', data={'key': 'value'}, proxy='http://username:password@localhost:8030') +>> page = await AsyncFetcher().put('https://httpbin.org/put', data={'key': 'value'}) +>> page = await AsyncFetcher().delete('https://httpbin.org/delete') +``` ### StealthyFetcher This class is built on top of [Camoufox](https://github.com/daijro/camoufox), bypassing most anti-bot protections by default. Scrapling adds extra layers of flavors and configurations to increase performance and undetectability even further. ```python >> page = StealthyFetcher().fetch('https://www.browserscan.net/bot-detection') # Running headless by default >> page.status == 200 True +>> page = await StealthyFetcher().async_fetch('https://www.browserscan.net/bot-detection') # the async version of fetch +>> page.status == 200 +True ``` > Note: all requests done by this fetcher are waiting by default for all JS to be fully loaded and executed so you don't have to :) @@ -288,6 +299,9 @@ This class is built on top of [Playwright](https://playwright.dev/python/) which >> page = PlayWrightFetcher().fetch('https://www.google.com/search?q=%22Scrapling%22', disable_resources=True) # Vanilla Playwright option >> page.css_first("#search a::attr(href)") 'https://github.com/D4Vinci/Scrapling' +>> page = await PlayWrightFetcher().async_fetch('https://www.google.com/search?q=%22Scrapling%22', disable_resources=True) # the async version of fetch +>> page.css_first("#search a::attr(href)") +'https://github.com/D4Vinci/Scrapling' ``` > Note: all requests done by this fetcher are waiting by default for all JS to be fully loaded and executed so you don't have to :) @@ -805,7 +819,6 @@ This project includes code adapted from: ## Known Issues - In the auto-matching save process, the unique properties of the first element from the selection results are the only ones that get saved. So if the selector you are using selects different elements on the page that are in different locations, auto-matching will probably return to you the first element only when you relocate it later. This doesn't include combined CSS selectors (Using commas to combine more than one selector for example) as these selectors get separated and each selector gets executed alone. -- Currently, Scrapling is not compatible with async/await. ---
Designed & crafted with ❤️ by Karim Shoair.

From 43b41b373383e352c810101b9d22e069d2a74573 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 12:41:07 +0200 Subject: [PATCH 40/46] docs: fix TOC header --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ecf7af1..dde62c8 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Scrapling is a high-performance, intelligent web scraping library for Python tha ## Table of content * [Key Features](#key-features) - * [Fetch websites as you prefer](#fetch-websites-as-you-prefer) + * [Fetch websites as you prefer](#fetch-websites-as-you-prefer-with-async-support) * [Adaptive Scraping](#adaptive-scraping) * [Performance](#performance) * [Developing Experience](#developing-experience) From 3ff0d55c3eb83520d16fbd9c3073f48487f52226 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 12:47:43 +0200 Subject: [PATCH 41/46] chore: Delete former-sponsor banner --- images/CapSolver.png | Bin 177426 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 images/CapSolver.png diff --git a/images/CapSolver.png b/images/CapSolver.png deleted file mode 100644 index 6f5b3bdf6a5e5c2f849d14e7e44fe1523f1b645c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 177426 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≈shuQ|HF328{h<-! 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 Date: Mon, 16 Dec 2024 13:17:09 +0200 Subject: [PATCH 42/46] test: Rewrite parser tests to a cleaner version and adding more tests --- tests/fetchers/test_utils.py | 212 +++++++--------- tests/parser/test_general.py | 478 +++++++++++++++++++---------------- 2 files changed, 350 insertions(+), 340 deletions(-) diff --git a/tests/fetchers/test_utils.py b/tests/fetchers/test_utils.py index 5fc1906..044c9b5 100644 --- a/tests/fetchers/test_utils.py +++ b/tests/fetchers/test_utils.py @@ -1,129 +1,97 @@ -import unittest +import pytest from scrapling.engines.toolbelt.custom import ResponseEncoding, StatusText -class TestPlayWrightFetcher(unittest.TestCase): - def setUp(self): - self.content_type_map = { - # A map generated by ChatGPT for most possible `content_type` values and the expected outcome - 'text/html; charset=UTF-8': 'UTF-8', - 'text/html; charset=ISO-8859-1': 'ISO-8859-1', - 'text/html': 'ISO-8859-1', - 'application/json; charset=UTF-8': 'UTF-8', - 'application/json': 'utf-8', - 'text/json': 'utf-8', - 'application/javascript; charset=UTF-8': 'UTF-8', - 'application/javascript': 'utf-8', - 'text/plain; charset=UTF-8': 'UTF-8', - 'text/plain; charset=ISO-8859-1': 'ISO-8859-1', - 'text/plain': 'ISO-8859-1', - 'application/xhtml+xml; charset=UTF-8': 'UTF-8', - 'application/xhtml+xml': 'utf-8', - 'text/html; charset=windows-1252': 'windows-1252', - 'application/json; charset=windows-1252': 'windows-1252', - 'text/plain; charset=windows-1252': 'windows-1252', - 'text/html; charset="UTF-8"': 'UTF-8', - 'text/html; charset="ISO-8859-1"': 'ISO-8859-1', - 'text/html; charset="windows-1252"': 'windows-1252', - 'application/json; charset="UTF-8"': 'UTF-8', - 'application/json; charset="ISO-8859-1"': 'ISO-8859-1', - 'application/json; charset="windows-1252"': 'windows-1252', - 'text/json; charset="UTF-8"': 'UTF-8', - 'application/javascript; charset="UTF-8"': 'UTF-8', - 'application/javascript; charset="ISO-8859-1"': 'ISO-8859-1', - 'text/plain; charset="UTF-8"': 'UTF-8', - 'text/plain; charset="ISO-8859-1"': 'ISO-8859-1', - 'text/plain; charset="windows-1252"': 'windows-1252', - 'application/xhtml+xml; charset="UTF-8"': 'UTF-8', - 'application/xhtml+xml; charset="ISO-8859-1"': 'ISO-8859-1', - 'application/xhtml+xml; charset="windows-1252"': 'windows-1252', - 'text/html; charset="US-ASCII"': 'US-ASCII', - 'application/json; charset="US-ASCII"': 'US-ASCII', - 'text/plain; charset="US-ASCII"': 'US-ASCII', - 'text/html; charset="Shift_JIS"': 'Shift_JIS', - 'application/json; charset="Shift_JIS"': 'Shift_JIS', - 'text/plain; charset="Shift_JIS"': 'Shift_JIS', - 'application/xml; charset="UTF-8"': 'UTF-8', - 'application/xml; charset="ISO-8859-1"': 'ISO-8859-1', - 'application/xml': 'utf-8', - 'text/xml; charset="UTF-8"': 'UTF-8', - 'text/xml; charset="ISO-8859-1"': 'ISO-8859-1', - 'text/xml': 'utf-8' - } - self.status_map = { - 100: "Continue", - 101: "Switching Protocols", - 102: "Processing", - 103: "Early Hints", - 200: "OK", - 201: "Created", - 202: "Accepted", - 203: "Non-Authoritative Information", - 204: "No Content", - 205: "Reset Content", - 206: "Partial Content", - 207: "Multi-Status", - 208: "Already Reported", - 226: "IM Used", - 300: "Multiple Choices", - 301: "Moved Permanently", - 302: "Found", - 303: "See Other", - 304: "Not Modified", - 305: "Use Proxy", - 307: "Temporary Redirect", - 308: "Permanent Redirect", - 400: "Bad Request", - 401: "Unauthorized", - 402: "Payment Required", - 403: "Forbidden", - 404: "Not Found", - 405: "Method Not Allowed", - 406: "Not Acceptable", - 407: "Proxy Authentication Required", - 408: "Request Timeout", - 409: "Conflict", - 410: "Gone", - 411: "Length Required", - 412: "Precondition Failed", - 413: "Payload Too Large", - 414: "URI Too Long", - 415: "Unsupported Media Type", - 416: "Range Not Satisfiable", - 417: "Expectation Failed", - 418: "I'm a teapot", - 421: "Misdirected Request", - 422: "Unprocessable Entity", - 423: "Locked", - 424: "Failed Dependency", - 425: "Too Early", - 426: "Upgrade Required", - 428: "Precondition Required", - 429: "Too Many Requests", - 431: "Request Header Fields Too Large", - 451: "Unavailable For Legal Reasons", - 500: "Internal Server Error", - 501: "Not Implemented", - 502: "Bad Gateway", - 503: "Service Unavailable", - 504: "Gateway Timeout", - 505: "HTTP Version Not Supported", - 506: "Variant Also Negotiates", - 507: "Insufficient Storage", - 508: "Loop Detected", - 510: "Not Extended", - 511: "Network Authentication Required" - } +@pytest.fixture +def content_type_map(): + return { + # A map generated by ChatGPT for most possible `content_type` values and the expected outcome + 'text/html; charset=UTF-8': 'UTF-8', + 'text/html; charset=ISO-8859-1': 'ISO-8859-1', + 'text/html': 'ISO-8859-1', + 'application/json; charset=UTF-8': 'UTF-8', + 'application/json': 'utf-8', + 'text/json': 'utf-8', + 'application/javascript; charset=UTF-8': 'UTF-8', + 'application/javascript': 'utf-8', + 'text/plain; charset=UTF-8': 'UTF-8', + 'text/plain; charset=ISO-8859-1': 'ISO-8859-1', + 'text/plain': 'ISO-8859-1', + 'application/xhtml+xml; charset=UTF-8': 'UTF-8', + 'application/xhtml+xml': 'utf-8', + 'text/html; charset=windows-1252': 'windows-1252', + 'application/json; charset=windows-1252': 'windows-1252', + 'text/plain; charset=windows-1252': 'windows-1252', + 'text/html; charset="UTF-8"': 'UTF-8', + 'text/html; charset="ISO-8859-1"': 'ISO-8859-1', + 'text/html; charset="windows-1252"': 'windows-1252', + 'application/json; charset="UTF-8"': 'UTF-8', + 'application/json; charset="ISO-8859-1"': 'ISO-8859-1', + 'application/json; charset="windows-1252"': 'windows-1252', + 'text/json; charset="UTF-8"': 'UTF-8', + 'application/javascript; charset="UTF-8"': 'UTF-8', + 'application/javascript; charset="ISO-8859-1"': 'ISO-8859-1', + 'text/plain; charset="UTF-8"': 'UTF-8', + 'text/plain; charset="ISO-8859-1"': 'ISO-8859-1', + 'text/plain; charset="windows-1252"': 'windows-1252', + 'application/xhtml+xml; charset="UTF-8"': 'UTF-8', + 'application/xhtml+xml; charset="ISO-8859-1"': 'ISO-8859-1', + 'application/xhtml+xml; charset="windows-1252"': 'windows-1252', + 'text/html; charset="US-ASCII"': 'US-ASCII', + 'application/json; charset="US-ASCII"': 'US-ASCII', + 'text/plain; charset="US-ASCII"': 'US-ASCII', + 'text/html; charset="Shift_JIS"': 'Shift_JIS', + 'application/json; charset="Shift_JIS"': 'Shift_JIS', + 'text/plain; charset="Shift_JIS"': 'Shift_JIS', + 'application/xml; charset="UTF-8"': 'UTF-8', + 'application/xml; charset="ISO-8859-1"': 'ISO-8859-1', + 'application/xml': 'utf-8', + 'text/xml; charset="UTF-8"': 'UTF-8', + 'text/xml; charset="ISO-8859-1"': 'ISO-8859-1', + 'text/xml': 'utf-8' + } - def test_parsing_content_type(self): - """Test if parsing different types of content-type returns the expected result""" - for header_value, expected_encoding in self.content_type_map.items(): - self.assertEqual(ResponseEncoding.get_value(header_value), expected_encoding) - def test_parsing_response_status(self): - """Test if using different http responses' status codes returns the expected result""" - for status_code, expected_status_text in self.status_map.items(): - self.assertEqual(StatusText.get(status_code), expected_status_text) +@pytest.fixture +def status_map(): + return { + 100: "Continue", 101: "Switching Protocols", 102: "Processing", 103: "Early Hints", + 200: "OK", 201: "Created", 202: "Accepted", 203: "Non-Authoritative Information", + 204: "No Content", 205: "Reset Content", 206: "Partial Content", 207: "Multi-Status", + 208: "Already Reported", 226: "IM Used", 300: "Multiple Choices", + 301: "Moved Permanently", 302: "Found", 303: "See Other", 304: "Not Modified", + 305: "Use Proxy", 307: "Temporary Redirect", 308: "Permanent Redirect", + 400: "Bad Request", 401: "Unauthorized", 402: "Payment Required", 403: "Forbidden", + 404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable", + 407: "Proxy Authentication Required", 408: "Request Timeout", 409: "Conflict", + 410: "Gone", 411: "Length Required", 412: "Precondition Failed", + 413: "Payload Too Large", 414: "URI Too Long", 415: "Unsupported Media Type", + 416: "Range Not Satisfiable", 417: "Expectation Failed", 418: "I'm a teapot", + 421: "Misdirected Request", 422: "Unprocessable Entity", 423: "Locked", + 424: "Failed Dependency", 425: "Too Early", 426: "Upgrade Required", + 428: "Precondition Required", 429: "Too Many Requests", + 431: "Request Header Fields Too Large", 451: "Unavailable For Legal Reasons", + 500: "Internal Server Error", 501: "Not Implemented", 502: "Bad Gateway", + 503: "Service Unavailable", 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", 506: "Variant Also Negotiates", + 507: "Insufficient Storage", 508: "Loop Detected", 510: "Not Extended", + 511: "Network Authentication Required" + } - self.assertEqual(StatusText.get(1000), "Unknown Status Code") + +def test_parsing_content_type(content_type_map): + """Test if parsing different types of content-type returns the expected result""" + for header_value, expected_encoding in content_type_map.items(): + assert ResponseEncoding.get_value(header_value) == expected_encoding + + +def test_parsing_response_status(status_map): + """Test if using different http responses' status codes returns the expected result""" + for status_code, expected_status_text in status_map.items(): + assert StatusText.get(status_code) == expected_status_text + + +def test_unknown_status_code(): + """Test handling of an unknown status code""" + assert StatusText.get(1000) == "Unknown Status Code" diff --git a/tests/parser/test_general.py b/tests/parser/test_general.py index 8ce369a..62c9fde 100644 --- a/tests/parser/test_general.py +++ b/tests/parser/test_general.py @@ -1,288 +1,330 @@ - import pickle -import unittest +import time +import pytest from cssselect import SelectorError, SelectorSyntaxError from scrapling import Adaptor -class TestParser(unittest.TestCase): - def setUp(self): - self.html = ''' - - - Complex Web Page - - - -

- -
-
-
-

Products

-
-
-

Product 1

-

This is product 1

- $10.99 - -
-
-

Product 2

-

This is product 2

- $20.99 - -
-
-

Product 3

-

This is product 3

- $15.99 - -
+@pytest.fixture +def html_content(): + return ''' + + + Complex Web Page + + + +
+ +
+
+
+

Products

+
+
+

Product 1

+

This is product 1

+ $10.99 + +
+
+

Product 2

+

This is product 2

+ $20.99 + +
+
+

Product 3

+

This is product 3

+ $15.99 + +
+
+
+
+

Customer Reviews

+
+
+

Great product!

+ John Doe
-
-
-

Customer Reviews

-
-
-

Great product!

- John Doe -
-
-

Good value for money.

- Jane Smith -
+
+

Good value for money.

+ Jane Smith
-
-
-
-

© 2024 Our Company

-
- - - - ''' - self.page = Adaptor(self.html, auto_match=False) - - def test_css_selector(self): - """Test Selecting elements with complex CSS selectors""" - elements = self.page.css('main #products .product-list article.product') - self.assertEqual(len(elements), 3) - - in_stock_products = self.page.css( +
+
+
+
+

© 2024 Our Company

+
+ + + + ''' + + +@pytest.fixture +def page(html_content): + return Adaptor(html_content, auto_match=False) + + +# CSS Selector Tests +class TestCSSSelectors: + def test_basic_product_selection(self, page): + """Test selecting all product elements""" + elements = page.css('main #products .product-list article.product') + assert len(elements) == 3 + + def test_in_stock_product_selection(self, page): + """Test selecting in-stock products""" + in_stock_products = page.css( 'main #products .product-list article.product:not(:contains("Out of stock"))') - self.assertEqual(len(in_stock_products), 2) + assert len(in_stock_products) == 2 + - def test_xpath_selector(self): - """Test Selecting elements with Complex XPath selectors""" - reviews = self.page.xpath( +# XPath Selector Tests +class TestXPathSelectors: + def test_high_rating_reviews(self, page): + """Test selecting reviews with high ratings""" + reviews = page.xpath( '//section[@id="reviews"]//div[contains(@class, "review") and @data-rating >= 4]' ) - self.assertEqual(len(reviews), 2) + assert len(reviews) == 2 - high_priced_products = self.page.xpath( + def test_high_priced_products(self, page): + """Test selecting products above a certain price""" + high_priced_products = page.xpath( '//article[contains(@class, "product")]' '[number(translate(substring-after(.//span[@class="price"], "$"), ",", "")) > 15]' ) - self.assertEqual(len(high_priced_products), 2) + assert len(high_priced_products) == 2 + + +# Text Matching Tests +class TestTextMatching: + def test_regex_multiple_matches(self, page): + """Test finding multiple matches with regex""" + stock_info = page.find_by_regex(r'In stock: \d+', first_match=False) + assert len(stock_info) == 2 - def test_find_by_text(self): - """Test Selecting elements with Text matching""" - stock_info = self.page.find_by_regex(r'In stock: \d+', first_match=False) - self.assertEqual(len(stock_info), 2) + def test_regex_first_match(self, page): + """Test finding the first match with regex""" + stock_info = page.find_by_regex(r'In stock: \d+', first_match=True, case_sensitive=True) + assert stock_info.text == 'In stock: 5' - stock_info = self.page.find_by_regex(r'In stock: \d+', first_match=True, case_sensitive=True) - self.assertEqual(stock_info.text, 'In stock: 5') + def test_partial_text_match(self, page): + """Test finding elements with partial text match""" + stock_info = page.find_by_text(r'In stock:', partial=True, first_match=False) + assert len(stock_info) == 2 - stock_info = self.page.find_by_text(r'In stock:', partial=True, first_match=False) - self.assertEqual(len(stock_info), 2) + def test_exact_text_match(self, page): + """Test finding elements with exact text match""" + out_of_stock = page.find_by_text('Out of stock', partial=False, first_match=False) + assert len(out_of_stock) == 1 - out_of_stock = self.page.find_by_text('Out of stock', partial=False, first_match=False) - self.assertEqual(len(out_of_stock), 1) - def test_find_similar_elements(self): - """Test Finding similar elements of an element""" - first_product = self.page.css_first('.product') +# Similar Elements Tests +class TestSimilarElements: + def test_finding_similar_products(self, page): + """Test finding similar product elements""" + first_product = page.css_first('.product') similar_products = first_product.find_similar() - self.assertEqual(len(similar_products), 2) + assert len(similar_products) == 2 - first_review = self.page.find('div', class_='review') + def test_finding_similar_reviews(self, page): + """Test finding similar review elements with additional filtering""" + first_review = page.find('div', class_='review') similar_high_rated_reviews = [ review for review in first_review.find_similar() if int(review.attrib.get('data-rating', 0)) >= 4 ] - self.assertEqual(len(similar_high_rated_reviews), 1) + assert len(similar_high_rated_reviews) == 1 - def test_expected_errors(self): - """Test errors that should raised if it does""" - with self.assertRaises(ValueError): + +# Error Handling Tests +class TestErrorHandling: + def test_invalid_adaptor_initialization(self): + """Test various invalid Adaptor initializations""" + # No arguments + with pytest.raises(ValueError): _ = Adaptor(auto_match=False) - with self.assertRaises(TypeError): + # Invalid argument types + with pytest.raises(TypeError): _ = Adaptor(root="ayo", auto_match=False) - with self.assertRaises(TypeError): + with pytest.raises(TypeError): _ = Adaptor(text=1, auto_match=False) - with self.assertRaises(TypeError): + with pytest.raises(TypeError): _ = Adaptor(body=1, auto_match=False) - with self.assertRaises(ValueError): - _ = Adaptor(self.html, storage=object, auto_match=True) - - def test_pickleable(self): - """Test that objects aren't pickleable""" - table = self.page.css('.product-list')[0] - with self.assertRaises(TypeError): # Adaptors - pickle.dumps(table) - - with self.assertRaises(TypeError): # Adaptor - pickle.dumps(table[0]) - - def test_overridden(self): - """Test overridden functions""" - table = self.page.css('.product-list')[0] - self.assertTrue(issubclass(type(table.__str__()), str)) - self.assertTrue(issubclass(type(table.__repr__()), str)) - self.assertTrue(issubclass(type(table.attrib.__str__()), str)) - self.assertTrue(issubclass(type(table.attrib.__repr__()), str)) - - def test_bad_selector(self): - """Test object can handle bad selector""" - with self.assertRaises((SelectorError, SelectorSyntaxError,)): - self.page.css('4 ayo') + def test_invalid_storage(self, page, html_content): + """Test invalid storage parameter""" + with pytest.raises(ValueError): + _ = Adaptor(html_content, storage=object, auto_match=True) - with self.assertRaises((SelectorError, SelectorSyntaxError,)): - self.page.xpath('4 ayo') + def test_bad_selectors(self, page): + """Test handling of invalid selectors""" + with pytest.raises((SelectorError, SelectorSyntaxError)): + page.css('4 ayo') - def test_selectors_generation(self): - """Try to create selectors for all elements in the page""" - def _traverse(element: Adaptor): - self.assertTrue(type(element.generate_css_selector) is str) - self.assertTrue(type(element.generate_xpath_selector) is str) - for branch in element.children: - _traverse(branch) + with pytest.raises((SelectorError, SelectorSyntaxError)): + page.xpath('4 ayo') - _traverse(self.page) - def test_getting_all_text(self): - """Test getting all text""" - self.assertNotEqual(self.page.get_all_text(), '') - - def test_element_navigation(self): - """Test moving in the page from selected element""" - table = self.page.css('.product-list')[0] +# Pickling and Object Representation Tests +class TestPicklingAndRepresentation: + def test_unpickleable_objects(self, page): + """Test that Adaptor objects cannot be pickled""" + table = page.css('.product-list')[0] + with pytest.raises(TypeError): + pickle.dumps(table) - self.assertIsNot(table.path, []) - self.assertNotEqual(table.html_content, '') - self.assertNotEqual(table.prettify(), '') + with pytest.raises(TypeError): + pickle.dumps(table[0]) + def test_string_representations(self, page): + """Test custom string representations of objects""" + table = page.css('.product-list')[0] + assert issubclass(type(table.__str__()), str) + assert issubclass(type(table.__repr__()), str) + assert issubclass(type(table.attrib.__str__()), str) + assert issubclass(type(table.attrib.__repr__()), str) + + +# Navigation and Traversal Tests +class TestElementNavigation: + def test_basic_navigation_properties(self, page): + """Test basic navigation properties of elements""" + table = page.css('.product-list')[0] + assert table.path is not None + assert table.html_content != '' + assert table.prettify() != '' + + def test_parent_and_sibling_navigation(self, page): + """Test parent and sibling navigation""" + table = page.css('.product-list')[0] parent = table.parent - self.assertEqual(parent.attrib['id'], 'products') - - children = table.children - self.assertEqual(len(children), 3) + assert parent.attrib['id'] == 'products' parent_siblings = parent.siblings - self.assertEqual(len(parent_siblings), 1) + assert len(parent_siblings) == 1 + + def test_child_navigation(self, page): + """Test child navigation""" + table = page.css('.product-list')[0] + children = table.children + assert len(children) == 3 - child = table.find({'data-id': "1"}) + def test_next_and_previous_navigation(self, page): + """Test next and previous element navigation""" + child = page.css('.product-list')[0].find({'data-id': "1"}) next_element = child.next - self.assertEqual(next_element.attrib['data-id'], '2') + assert next_element.attrib['data-id'] == '2' prev_element = next_element.previous - self.assertEqual(prev_element.tag, child.tag) + assert prev_element.tag == child.tag - all_prices = self.page.css('.price') + def test_ancestor_finding(self, page): + """Test finding ancestors of elements""" + all_prices = page.css('.price') products_with_prices = [ price.find_ancestor(lambda p: p.has_class('product')) for price in all_prices ] - self.assertEqual(len(products_with_prices), 3) - - def test_empty_return(self): - """Test cases where functions shouldn't have results""" - test_html = """ - - - - """ - soup = Adaptor(test_html, auto_match=False, keep_comments=False) - html_tag = soup.css('html')[0] - self.assertEqual(html_tag.path, []) - self.assertEqual(html_tag.siblings, []) - self.assertEqual(html_tag.parent, None) - self.assertEqual(html_tag.find_ancestor(lambda e: e), None) - - self.assertEqual(soup.css('#a a')[0].next, None) - self.assertEqual(soup.css('#b a')[0].previous, None) - - def test_text_to_json(self): - """Test converting text to json""" - script_content = self.page.css('#page-data::text')[0] - self.assertTrue(issubclass(type(script_content.sort()), str)) + assert len(products_with_prices) == 3 + + +# JSON and Attribute Tests +class TestJSONAndAttributes: + def test_json_conversion(self, page): + """Test converting content to JSON""" + script_content = page.css('#page-data::text')[0] + assert issubclass(type(script_content.sort()), str) page_data = script_content.json() - self.assertEqual(page_data['totalProducts'], 3) - self.assertTrue('lastUpdated' in page_data) - - def test_regex_on_text(self): - """Test doing regex on a selected text""" - element = self.page.css('[data-id="1"] .price')[0] - match = element.re_first(r'[\.\d]+') - self.assertEqual(match, '10.99') - match = element.text.re(r'(\d+)', replace_entities=False) - self.assertEqual(len(match), 2) - - def test_attribute_operations(self): - """Test operations on elements attributes""" - products = self.page.css('.product') + assert page_data['totalProducts'] == 3 + assert 'lastUpdated' in page_data + + def test_attribute_operations(self, page): + """Test various attribute-related operations""" + # Product ID extraction + products = page.css('.product') product_ids = [product.attrib['data-id'] for product in products] - self.assertEqual(product_ids, ['1', '2', '3']) - self.assertTrue('data-id' in products[0].attrib) + assert product_ids == ['1', '2', '3'] + assert 'data-id' in products[0].attrib - reviews = self.page.css('.review') + # Review rating calculations + reviews = page.css('.review') review_ratings = [int(review.attrib['data-rating']) for review in reviews] - self.assertEqual(sum(review_ratings) / len(review_ratings), 4.5) + assert sum(review_ratings) / len(review_ratings) == 4.5 + # Attribute searching key_value = list(products[0].attrib.search_values('1', partial=False)) - self.assertEqual(list(key_value[0].keys()), ['data-id']) + assert list(key_value[0].keys()) == ['data-id'] key_value = list(products[0].attrib.search_values('1', partial=True)) - self.assertEqual(list(key_value[0].keys()), ['data-id']) + assert list(key_value[0].keys()) == ['data-id'] + + # JSON attribute conversion + attr_json = page.css_first('#products').attrib['schema'].json() + assert attr_json == {'jsonable': 'data'} + assert isinstance(page.css('#products')[0].attrib.json_string, bytes) + + +# Performance Test +def test_large_html_parsing_performance(): + """Test parsing and selecting performance on large HTML""" + large_html = '' + '
' * 5000 + '
' * 5000 + '' + + start_time = time.time() + parsed = Adaptor(large_html, auto_match=False) + elements = parsed.css('.item') + end_time = time.time() + + assert len(elements) == 5000 + # Converting 5000 elements to a class and doing operations on them will take time + # Based on my tests with 100 runs, 1 loop each Scrapling (given the extra work/features) takes 10.4ms on average + assert end_time - start_time < 0.5 # Locally I test on 0.1 but on GitHub actions with browsers and threading sometimes closing adds fractions of seconds + + +# Selector Generation Test +def test_selectors_generation(page): + """Try to create selectors for all elements in the page""" - attr_json = self.page.css_first('#products').attrib['schema'].json() - self.assertEqual(attr_json, {'jsonable': 'data'}) - self.assertEqual(type(self.page.css('#products')[0].attrib.json_string), bytes) + def _traverse(element: Adaptor): + assert isinstance(element.generate_css_selector, str) + assert isinstance(element.generate_xpath_selector, str) + for branch in element.children: + _traverse(branch) - def test_performance(self): - """Test parsing and selecting speed""" - import time - large_html = '' + '
' * 5000 + '
' * 5000 + '' + _traverse(page) - start_time = time.time() - parsed = Adaptor(large_html, auto_match=False) - elements = parsed.css('.item') - end_time = time.time() - self.assertEqual(len(elements), 5000) - # Converting 5000 elements to a class and doing operations on them will take time - # Based on my tests with 100 runs, 1 loop each Scrapling (given the extra work/features) takes 10.4ms on average - self.assertLess(end_time - start_time, 0.5) # Locally I test on 0.1 but on GitHub actions with browsers and threading sometimes closing adds fractions of seconds +# Miscellaneous Tests +def test_getting_all_text(page): + """Test getting all text from the page""" + assert page.get_all_text() != '' -# Use `coverage run -m unittest --verbose tests/test_parser_functions.py` instead for the coverage report -# if __name__ == '__main__': -# unittest.main(verbosity=2) +def test_regex_on_text(page): + """Test regex operations on text""" + element = page.css('[data-id="1"] .price')[0] + match = element.re_first(r'[\.\d]+') + assert match == '10.99' + match = element.text.re(r'(\d+)', replace_entities=False) + assert len(match) == 2 From 20ef45349a833828c93a4ba601fadf8847530559 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 13:18:19 +0200 Subject: [PATCH 43/46] test: Rewrite automatch tests to a cleaner version and adding async test --- tests/parser/test_automatch.py | 69 ++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/tests/parser/test_automatch.py b/tests/parser/test_automatch.py index 79fe6b7..38ad88b 100644 --- a/tests/parser/test_automatch.py +++ b/tests/parser/test_automatch.py @@ -1,10 +1,11 @@ -import unittest +import asyncio -from scrapling import Adaptor +import pytest +from scrapling import Adaptor -class TestParserAutoMatch(unittest.TestCase): +class TestParserAutoMatch: def test_element_relocation(self): """Test relocating element after structure change""" original_html = ''' @@ -50,7 +51,61 @@ def test_element_relocation(self): _ = old_page.css('#p1, #p2', auto_save=True)[0] relocated = new_page.css('#p1', auto_match=True) - self.assertIsNotNone(relocated) - self.assertEqual(relocated[0].attrib['data-id'], 'p1') - self.assertTrue(relocated[0].has_class('new-class')) - self.assertEqual(relocated[0].css('.new-description')[0].text, 'Description 1') + assert relocated is not None + assert relocated[0].attrib['data-id'] == 'p1' + assert relocated[0].has_class('new-class') + assert relocated[0].css('.new-description')[0].text == 'Description 1' + + @pytest.mark.asyncio + async def test_element_relocation_async(self): + """Test relocating element after structure change in async mode""" + original_html = ''' +
+
+
+

Product 1

+

Description 1

+
+
+

Product 2

+

Description 2

+
+
+
+ ''' + changed_html = ''' +
+
+
+
+
+

Product 1

+

Description 1

+
+
+
+
+

Product 2

+

Description 2

+
+
+
+
+
+ ''' + + # Simulate async operation + await asyncio.sleep(0.1) # Minimal async operation + + old_page = Adaptor(original_html, url='example.com', auto_match=True) + new_page = Adaptor(changed_html, url='example.com', auto_match=True) + + # 'p1' was used as ID and now it's not and all the path elements have changes + # Also at the same time testing auto-match vs combined selectors + _ = old_page.css('#p1, #p2', auto_save=True)[0] + relocated = new_page.css('#p1', auto_match=True) + + assert relocated is not None + assert relocated[0].attrib['data-id'] == 'p1' + assert relocated[0].has_class('new-class') + assert relocated[0].css('.new-description')[0].text == 'Description 1' From ac95600650f5110b076508033134d1144e695bf5 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 13:27:14 +0200 Subject: [PATCH 44/46] docs: add an example that uses `page.urljoin` --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index dde62c8..92c76b2 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,9 @@ You can select elements by their text content in multiple ways, here's a full ex >>> page.find_by_text('Tipping the Velvet') # Find the first element whose text fully matches this text +>>> page.urljoin(page.find_by_text('Tipping the Velvet').attrib['href']) # We use `page.urljoin` to return the full URL from the relative `href` +'https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html' + >>> page.find_by_text('Tipping the Velvet', first_match=False) # Get all matches if there are more [] From c282cb3cb7588bd32e201945f057d1dae1ed31e4 Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 13:35:48 +0200 Subject: [PATCH 45/46] fix: Stopped the log spamming that happens with multiple instances creation --- scrapling/engines/toolbelt/custom.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scrapling/engines/toolbelt/custom.py b/scrapling/engines/toolbelt/custom.py index 9705eb3..62c8452 100644 --- a/scrapling/engines/toolbelt/custom.py +++ b/scrapling/engines/toolbelt/custom.py @@ -84,6 +84,8 @@ def get_value(cls, content_type: Optional[str], text: Optional[str] = 'test') -> class Response(Adaptor): """This class is returned by all engines as a way to unify response type between different libraries.""" + _is_response_result_logged = False # Class-level flag, initialized to False + def __init__(self, url: str, text: str, body: bytes, status: int, reason: str, cookies: Dict, headers: Dict, request_headers: Dict, encoding: str = 'utf-8', method: str = 'GET', **adaptor_arguments: Dict): automatch_domain = adaptor_arguments.pop('automatch_domain', None) @@ -97,7 +99,9 @@ def __init__(self, url: str, text: str, body: bytes, status: int, reason: str, c # For back-ward compatibility self.adaptor = self # For easier debugging while working from a Python shell - log.info(f'Fetched ({status}) <{method} {url}> (referer: {request_headers.get("referer")})') + if not Response._is_response_result_logged: + log.info(f'Fetched ({status}) <{method} {url}> (referer: {request_headers.get("referer")})') + Response._is_response_result_logged = True # def __repr__(self): # return f'<{self.__class__.__name__} [{self.status} {self.reason}]>' From 838b8b1196c43582570fbda5a66105980d123b4e Mon Sep 17 00:00:00 2001 From: Karim shoair Date: Mon, 16 Dec 2024 13:39:30 +0200 Subject: [PATCH 46/46] style: fixing a typo in logging --- scrapling/parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scrapling/parser.py b/scrapling/parser.py index 47d2b32..5440a84 100644 --- a/scrapling/parser.py +++ b/scrapling/parser.py @@ -766,7 +766,7 @@ def save(self, element: Union['Adaptor', html.HtmlElement], identifier: str) -> self._storage.save(element, identifier) else: log.critical( - "Can't use Auto-match features with disabled globally, you have to start a new class instance." + "Can't use Auto-match features while disabled globally, you have to start a new class instance." ) def retrieve(self, identifier: str) -> Optional[Dict]: @@ -780,7 +780,7 @@ def retrieve(self, identifier: str) -> Optional[Dict]: return self._storage.retrieve(identifier) log.critical( - "Can't use Auto-match features with disabled globally, you have to start a new class instance." + "Can't use Auto-match features while disabled globally, you have to start a new class instance." ) # Operations on text functions