diff --git a/.bandit.yml b/.bandit.yml index 57a5cc4..9d7a1f7 100644 --- a/.bandit.yml +++ b/.bandit.yml @@ -3,3 +3,5 @@ skips: - B311 - B320 - B410 +- B113 # `Requests call without timeout` these requests are done in the benchmark and examples scripts only +- B403 # We are using pickle for tests only diff --git a/.flake8 b/.flake8 index 5a89b58..fae58af 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,3 @@ [flake8] -ignore = E501 # line too long -exclude = .git,__pycache__,docs,.github,build,dist \ No newline at end of file +ignore = E501, F401 +exclude = .git,.venv,__pycache__,docs,.github,build,dist,tests,benchmarks.py \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a56cd39..473435b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,5 +1,9 @@ name: Tests -on: [push] +on: + push: + branches: + - main + - dev concurrency: group: ${{github.workflow}}-${{ github.ref }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b22d4a..9e3cf04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,19 @@ repos: - repo: https://github.com/PyCQA/bandit - rev: 1.7.8 + rev: 1.8.0 hooks: - id: bandit args: [-r, -c, .bandit.yml] - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 + rev: 7.1.1 hooks: - id: flake8 - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - - id: isort \ No newline at end of file + - id: isort +- repo: https://github.com/netromdk/vermin + rev: v1.6.0 + hooks: + - id: vermin + args: ['-t=3.8-', '--violations', '--eval-annotations', '--no-tips'] diff --git a/README.md b/README.md index ca23a6f..9f7a86e 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.default import Fetcher, StealthyFetcher, PlayWrightFetcher +>> from scrapling.defaults import Fetcher, StealthyFetcher, PlayWrightFetcher # Fetch websites' source under the radar! >> page = StealthyFetcher.fetch('https://example.com', headless=True, network_idle=True) >> print(page.status) @@ -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.default import Fetcher, StealthyFetcher, PlayWrightFetcher +from scrapling.defaults import Fetcher, StealthyFetcher, PlayWrightFetcher ``` then use it right away without initializing like: ```python diff --git a/benchmarks.py b/benchmarks.py index 8e46b37..de647d6 100644 --- a/benchmarks.py +++ b/benchmarks.py @@ -1,17 +1,18 @@ +import functools import time import timeit -import functools -import requests from statistics import mean -from scrapling import Adaptor -from parsel import Selector -from lxml import etree, html -from bs4 import BeautifulSoup -from pyquery import PyQuery as pq +import requests from autoscraper import AutoScraper -from selectolax.parser import HTMLParser +from bs4 import BeautifulSoup +from lxml import etree, html from mechanicalsoup import StatefulBrowser +from parsel import Selector +from pyquery import PyQuery as pq +from selectolax.parser import HTMLParser + +from scrapling import Adaptor large_html = '' + '
' * 5000 + '
' * 5000 + '' diff --git a/cleanup.py b/cleanup.py new file mode 100644 index 0000000..8a1ed3a --- /dev/null +++ b/cleanup.py @@ -0,0 +1,42 @@ +import shutil +from pathlib import Path + + +# Clean up after installing for local development +def clean(): + # Get the current directory + base_dir = Path.cwd() + + # Directories and patterns to clean + cleanup_patterns = [ + 'build', + 'dist', + '*.egg-info', + '__pycache__', + '.eggs', + '.pytest_cache' + ] + + # Clean directories + for pattern in cleanup_patterns: + for path in base_dir.glob(pattern): + try: + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + print(f"Removed: {path}") + except Exception as e: + print(f"Could not remove {path}: {e}") + + # Remove compiled Python files + for path in base_dir.rglob('*.py[co]'): + try: + path.unlink() + print(f"Removed compiled file: {path}") + except Exception as e: + print(f"Could not remove {path}: {e}") + + +if __name__ == '__main__': + clean() diff --git a/docs/Examples/selectorless_stackoverflow.py b/docs/Examples/selectorless_stackoverflow.py index f54007c..8619653 100644 --- a/docs/Examples/selectorless_stackoverflow.py +++ b/docs/Examples/selectorless_stackoverflow.py @@ -4,6 +4,7 @@ I only made this example to show how Scrapling features can be used to scrape a """ import requests + from scrapling import Adaptor response = requests.get('https://stackoverflow.com/questions/tagged/web-scraping?sort=MostVotes&filters=NoAcceptedAnswer&edited=true&pagesize=50&page=2') @@ -22,4 +23,3 @@ if first_question_title and first_question_author: # We will get all the rest of the titles/authors in the page depending on the first title and the first author we got above as a starting point for i, (title, author) in enumerate(zip(first_question_title.find_similar(), first_question_author.find_similar()), start=1): print(i, title.text, author.text) - diff --git a/scrapling/__init__.py b/scrapling/__init__.py index 26d815d..9240821 100644 --- a/scrapling/__init__.py +++ b/scrapling/__init__.py @@ -1,10 +1,11 @@ # Declare top-level shortcuts -from scrapling.fetchers import Fetcher, StealthyFetcher, PlayWrightFetcher, CustomFetcher +from scrapling.core.custom_types import AttributesHandler, TextHandler +from scrapling.fetchers import (CustomFetcher, Fetcher, PlayWrightFetcher, + StealthyFetcher) from scrapling.parser import Adaptor, Adaptors -from scrapling.core.custom_types import TextHandler, AttributesHandler __author__ = "Karim Shoair (karim.shoair@pm.me)" -__version__ = "0.2.7" +__version__ = "0.2.8" __copyright__ = "Copyright (c) 2024 Karim Shoair" diff --git a/scrapling/core/_types.py b/scrapling/core/_types.py index f46dad4..8816b90 100644 --- a/scrapling/core/_types.py +++ b/scrapling/core/_types.py @@ -2,9 +2,8 @@ Type definitions for type checking purposes. """ -from typing import ( - Dict, Optional, Union, Callable, Any, List, Tuple, Pattern, Generator, Iterable, Type, TYPE_CHECKING, Literal -) +from typing import (TYPE_CHECKING, Any, Callable, Dict, Generator, Iterable, + List, Literal, Optional, Pattern, Tuple, Type, Union) try: from typing import Protocol diff --git a/scrapling/core/custom_types.py b/scrapling/core/custom_types.py index 4670701..b8cb44f 100644 --- a/scrapling/core/custom_types.py +++ b/scrapling/core/custom_types.py @@ -1,13 +1,13 @@ import re -from types import MappingProxyType from collections.abc import Mapping +from types import MappingProxyType -from scrapling.core.utils import _is_iterable, flatten -from scrapling.core._types import Dict, List, Union, Pattern, SupportsIndex - -from orjson import loads, dumps +from orjson import dumps, loads from w3lib.html import replace_entities as _replace_entities +from scrapling.core._types import Dict, List, Pattern, SupportsIndex, Union +from scrapling.core.utils import _is_iterable, flatten + class TextHandler(str): """Extends standard Python string by adding more functionality""" diff --git a/scrapling/core/storage_adaptors.py b/scrapling/core/storage_adaptors.py index 675b46d..983e863 100644 --- a/scrapling/core/storage_adaptors.py +++ b/scrapling/core/storage_adaptors.py @@ -1,16 +1,16 @@ -import orjson -import sqlite3 import logging +import sqlite3 import threading -from hashlib import sha256 from abc import ABC, abstractmethod +from hashlib import sha256 + +import orjson +from lxml import html +from tldextract import extract as tld from scrapling.core._types import Dict, Optional, Union from scrapling.core.utils import _StorageTools, cache -from lxml import html -from tldextract import extract as tld - class StorageSystemMixin(ABC): # If you want to make your own storage system, you have to inherit from this diff --git a/scrapling/core/translator.py b/scrapling/core/translator.py index ec1b8ac..aa6211e 100644 --- a/scrapling/core/translator.py +++ b/scrapling/core/translator.py @@ -10,15 +10,14 @@ So you don't have to learn a new selectors/api method like what bs4 done with so import re -from w3lib.html import HTML5_WHITESPACE -from scrapling.core.utils import cache -from scrapling.core._types import Any, Optional, Protocol, Self - -from cssselect.xpath import ExpressionError -from cssselect.xpath import XPathExpr as OriginalXPathExpr from cssselect import HTMLTranslator as OriginalHTMLTranslator from cssselect.parser import Element, FunctionalPseudoElement, PseudoElement +from cssselect.xpath import ExpressionError +from cssselect.xpath import XPathExpr as OriginalXPathExpr +from w3lib.html import HTML5_WHITESPACE +from scrapling.core._types import Any, Optional, Protocol, Self +from scrapling.core.utils import cache regex = f"[{HTML5_WHITESPACE}]+" replace_html5_whitespaces = re.compile(regex).sub diff --git a/scrapling/core/utils.py b/scrapling/core/utils.py index 748020b..35f8d0a 100644 --- a/scrapling/core/utils.py +++ b/scrapling/core/utils.py @@ -1,22 +1,25 @@ -import re import logging +import re from itertools import chain -# Using cache on top of a class is brilliant way to achieve Singleton design pattern without much code -from functools import lru_cache as cache # functools.cache is available on Python 3.9+ only so let's keep lru_cache - -from scrapling.core._types import Dict, Iterable, Any, Union import orjson from lxml import html +from scrapling.core._types import Any, Dict, Iterable, Union + +# 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 + + html_forbidden = {html.HtmlComment, } logging.basicConfig( - level=logging.ERROR, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler() - ] - ) + level=logging.ERROR, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler() + ] +) def is_jsonable(content: Union[bytes, str]) -> bool: @@ -94,7 +97,7 @@ class _StorageTools: parent = element.getparent() return tuple( (element.tag,) if parent is None else ( - cls._get_element_path(parent) + (element.tag,) + cls._get_element_path(parent) + (element.tag,) ) ) diff --git a/scrapling/defaults.py b/scrapling/defaults.py index 79aa2ff..73618a4 100644 --- a/scrapling/defaults.py +++ b/scrapling/defaults.py @@ -1,4 +1,4 @@ -from .fetchers import Fetcher, StealthyFetcher, PlayWrightFetcher +from .fetchers import 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() diff --git a/scrapling/engines/__init__.py b/scrapling/engines/__init__.py index d91e20a..acdbeb0 100644 --- a/scrapling/engines/__init__.py +++ b/scrapling/engines/__init__.py @@ -1,7 +1,7 @@ from .camo import CamoufoxEngine -from .static import StaticEngine -from .pw import PlaywrightEngine from .constants import DEFAULT_DISABLED_RESOURCES, DEFAULT_STEALTH_FLAGS +from .pw import PlaywrightEngine +from .static import StaticEngine from .toolbelt import check_if_engine_usable __all__ = ['CamoufoxEngine', 'PlaywrightEngine'] diff --git a/scrapling/engines/camo.py b/scrapling/engines/camo.py index 90d3378..2741206 100644 --- a/scrapling/engines/camo.py +++ b/scrapling/engines/camo.py @@ -1,20 +1,16 @@ import logging -from scrapling.core._types import Union, Callable, Optional, Dict, List, Literal - -from scrapling.engines.toolbelt import ( - Response, - do_nothing, - StatusText, - get_os_name, - intercept_route, - check_type_validity, - construct_proxy_dict, - generate_convincing_referer, -) from camoufox import DefaultAddons from camoufox.sync_api import Camoufox +from scrapling.core._types import (Callable, Dict, List, Literal, Optional, + Union) +from scrapling.engines.toolbelt import (Response, StatusText, + check_type_validity, + construct_proxy_dict, do_nothing, + generate_convincing_referer, + get_os_name, intercept_route) + class CamoufoxEngine: def __init__( diff --git a/scrapling/engines/pw.py b/scrapling/engines/pw.py index 818720b..7d15174 100644 --- a/scrapling/engines/pw.py +++ b/scrapling/engines/pw.py @@ -1,20 +1,15 @@ import json import logging -from scrapling.core._types import Union, Callable, Optional, List, Dict -from scrapling.engines.constants import DEFAULT_STEALTH_FLAGS, NSTBROWSER_DEFAULT_QUERY -from scrapling.engines.toolbelt import ( - Response, - do_nothing, - StatusText, - js_bypass_path, - intercept_route, - generate_headers, - construct_cdp_url, - check_type_validity, - construct_proxy_dict, - generate_convincing_referer, -) +from scrapling.core._types import Callable, Dict, List, Optional, Union +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, + generate_convincing_referer, + generate_headers, intercept_route, + js_bypass_path) class PlaywrightEngine: diff --git a/scrapling/engines/static.py b/scrapling/engines/static.py index d6b5a6c..a091c4f 100644 --- a/scrapling/engines/static.py +++ b/scrapling/engines/static.py @@ -1,11 +1,12 @@ import logging -from scrapling.core._types import Union, Optional, Dict -from .toolbelt import Response, generate_convincing_referer, generate_headers - import httpx from httpx._models import Response as httpxResponse +from scrapling.core._types import Dict, Optional, Union + +from .toolbelt import Response, generate_convincing_referer, generate_headers + class StaticEngine: def __init__(self, follow_redirects: bool = True, timeout: Optional[Union[int, float]] = None, adaptor_arguments: Dict = None): diff --git a/scrapling/engines/toolbelt/__init__.py b/scrapling/engines/toolbelt/__init__.py index 15fd80c..595929c 100644 --- a/scrapling/engines/toolbelt/__init__.py +++ b/scrapling/engines/toolbelt/__init__.py @@ -1,20 +1,6 @@ -from .fingerprints import ( - get_os_name, - generate_headers, - generate_convincing_referer, -) -from .custom import ( - Response, - do_nothing, - StatusText, - BaseFetcher, - get_variable_name, - check_type_validity, - check_if_engine_usable, -) -from .navigation import ( - js_bypass_path, - intercept_route, - construct_cdp_url, - construct_proxy_dict, -) +from .custom import (BaseFetcher, Response, StatusText, check_if_engine_usable, + check_type_validity, do_nothing, 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) diff --git a/scrapling/engines/toolbelt/custom.py b/scrapling/engines/toolbelt/custom.py index 2c7cd94..6e321cc 100644 --- a/scrapling/engines/toolbelt/custom.py +++ b/scrapling/engines/toolbelt/custom.py @@ -5,10 +5,11 @@ 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.parser import Adaptor, SQLiteStorageSystem -from scrapling.core.utils import setup_basic_logging, cache -from scrapling.core._types import Any, List, Type, Union, Optional, Dict, Callable, Tuple class ResponseEncoding: diff --git a/scrapling/engines/toolbelt/fingerprints.py b/scrapling/engines/toolbelt/fingerprints.py index 9cd337d..5600003 100644 --- a/scrapling/engines/toolbelt/fingerprints.py +++ b/scrapling/engines/toolbelt/fingerprints.py @@ -4,12 +4,12 @@ Functions related to generating headers and fingerprints generally import platform -from scrapling.core.utils import cache -from scrapling.core._types import Union, Dict - +from browserforge.fingerprints import Fingerprint, FingerprintGenerator +from browserforge.headers import Browser, HeaderGenerator from tldextract import extract -from browserforge.headers import HeaderGenerator, Browser -from browserforge.fingerprints import FingerprintGenerator, Fingerprint + +from scrapling.core._types import Dict, Union +from scrapling.core.utils import cache @cache(None, typed=True) diff --git a/scrapling/engines/toolbelt/navigation.py b/scrapling/engines/toolbelt/navigation.py index 363f233..2d24cac 100644 --- a/scrapling/engines/toolbelt/navigation.py +++ b/scrapling/engines/toolbelt/navigation.py @@ -2,16 +2,16 @@ Functions related to files and URLs """ -import os import logging -from urllib.parse import urlparse, urlencode - -from scrapling.core.utils import cache -from scrapling.core._types import Union, Dict, Optional -from scrapling.engines.constants import DEFAULT_DISABLED_RESOURCES +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.engines.constants import DEFAULT_DISABLED_RESOURCES + def intercept_route(route: Route) -> Union[Route, None]: """This is just a route handler but it drops requests that its type falls in `DEFAULT_DISABLED_RESOURCES` @@ -43,7 +43,7 @@ def construct_proxy_dict(proxy_string: Union[str, Dict[str, str]]) -> Union[Dict } except ValueError: # Urllib will say that one of the parameters above can't be casted to the correct type like `int` for port etc... - raise TypeError(f'The proxy argument\'s string is in invalid format!') + raise TypeError('The proxy argument\'s string is in invalid format!') elif isinstance(proxy_string, dict): valid_keys = ('server', 'username', 'password', ) diff --git a/scrapling/fetchers.py b/scrapling/fetchers.py index 552f924..619f2f8 100644 --- a/scrapling/fetchers.py +++ b/scrapling/fetchers.py @@ -1,7 +1,8 @@ -from scrapling.core._types import Dict, Optional, Union, Callable, List, Literal - -from scrapling.engines.toolbelt import Response, BaseFetcher, do_nothing -from scrapling.engines import CamoufoxEngine, PlaywrightEngine, StaticEngine, check_if_engine_usable +from scrapling.core._types import (Callable, Dict, List, Literal, Optional, + Union) +from scrapling.engines import (CamoufoxEngine, PlaywrightEngine, StaticEngine, + check_if_engine_usable) +from scrapling.engines.toolbelt import BaseFetcher, Response, do_nothing class Fetcher(BaseFetcher): diff --git a/scrapling/parser.py b/scrapling/parser.py index 79cfa14..daaa8c4 100644 --- a/scrapling/parser.py +++ b/scrapling/parser.py @@ -1,16 +1,23 @@ +import inspect import os import re -import inspect from difflib import SequenceMatcher -from scrapling.core.translator import HTMLTranslator -from scrapling.core.mixins import SelectorsGeneration -from scrapling.core.custom_types import TextHandler, TextHandlers, AttributesHandler -from scrapling.core.storage_adaptors import SQLiteStorageSystem, StorageSystemMixin, _StorageTools -from scrapling.core.utils import setup_basic_logging, logging, clean_spaces, flatten, html_forbidden, is_jsonable -from scrapling.core._types import Any, Dict, List, Tuple, Optional, Pattern, Union, Callable, Generator, SupportsIndex, Iterable +from cssselect import SelectorError, SelectorSyntaxError +from cssselect import parse as split_selectors from lxml import etree, html -from cssselect import SelectorError, SelectorSyntaxError, parse as split_selectors + +from scrapling.core._types import (Any, Callable, Dict, Generator, Iterable, + List, Optional, Pattern, SupportsIndex, + Tuple, Union) +from scrapling.core.custom_types import (AttributesHandler, TextHandler, + TextHandlers) +from scrapling.core.mixins import SelectorsGeneration +from scrapling.core.storage_adaptors import (SQLiteStorageSystem, + StorageSystemMixin, _StorageTools) +from scrapling.core.translator import HTMLTranslator +from scrapling.core.utils import (clean_spaces, flatten, html_forbidden, + is_jsonable, logging, setup_basic_logging) class Adaptor(SelectorsGeneration): diff --git a/setup.cfg b/setup.cfg index 1aa408a..84169d5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = scrapling -version = 0.2.7 +version = 0.2.8 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 91aa89c..0a29929 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from setuptools import setup, find_packages +from setuptools import find_packages, setup with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() @@ -6,10 +6,10 @@ with open("README.md", "r", encoding="utf-8") as fh: setup( name="scrapling", - version="0.2.7", - 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.""", + version="0.2.8", + 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.""", long_description=long_description, long_description_content_type="text/markdown", author="Karim Shoair", @@ -57,7 +57,7 @@ setup( '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.3.10', + 'camoufox>=0.4.4', 'browserforge', ], python_requires=">=3.8", diff --git a/tests/fetchers/test_camoufox.py b/tests/fetchers/test_camoufox.py index 5a0fe17..fcbf3b7 100644 --- a/tests/fetchers/test_camoufox.py +++ b/tests/fetchers/test_camoufox.py @@ -1,4 +1,5 @@ import unittest + import pytest_httpbin from scrapling import StealthyFetcher diff --git a/tests/fetchers/test_httpx.py b/tests/fetchers/test_httpx.py index 2fcd585..1a5cc02 100644 --- a/tests/fetchers/test_httpx.py +++ b/tests/fetchers/test_httpx.py @@ -1,4 +1,5 @@ import unittest + import pytest_httpbin from scrapling import Fetcher diff --git a/tests/fetchers/test_playwright.py b/tests/fetchers/test_playwright.py index 8f67b3d..dda30e0 100644 --- a/tests/fetchers/test_playwright.py +++ b/tests/fetchers/test_playwright.py @@ -1,4 +1,5 @@ import unittest + import pytest_httpbin from scrapling import PlayWrightFetcher diff --git a/tests/parser/test_general.py b/tests/parser/test_general.py index 1f0dfe2..ea1fb78 100644 --- a/tests/parser/test_general.py +++ b/tests/parser/test_general.py @@ -1,9 +1,11 @@ import pickle import unittest -from scrapling import Adaptor + from cssselect import SelectorError, SelectorSyntaxError +from scrapling import Adaptor + class TestParser(unittest.TestCase): def setUp(self): diff --git a/tox.ini b/tox.ini index 3640ae7..28b09e1 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,8 @@ commands = playwright install chromium playwright install-deps chromium firefox camoufox fetch --browserforge - pytest --cov=scrapling --cov-report=xml -n auto + 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 [testenv:pre-commit] basepython = python3