Merge pull request #33 from D4Vinci/dev

v0.2.93
This commit is contained in:
Karim shoair
2025-01-31 03:43:05 +02:00
committed by GitHub
12 changed files with 295 additions and 271 deletions
+27 -26
View File
@@ -92,27 +92,27 @@ Scrapling is a high-performance, intelligent web scraping library for Python tha
## Key Features
### 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`!
- **HTTP Requests**: Fast and stealthy HTTP requests with the `Fetcher` class.
- **Dynamic Loading & Automation**: Fetch dynamic websites with the `PlayWrightFetcher` class through your real browser, Scrapling's stealth mode, Playwright's Chrome browser, or [NSTbrowser](https://app.nstbrowser.io/r/1vO5e5)'s browserless!
- **Anti-bot Protections Bypass**: Easily bypass protections with `StealthyFetcher` and `PlayWrightFetcher` classes.
### Adaptive Scraping
- 🔄 **Smart Element Tracking**: Locate previously identified elements after website structure changes, using an intelligent similarity system and integrated storage.
- 🎯 **Flexible Querying**: Use CSS selectors, XPath, Elements filters, text search, or regex - chain them however you want!
- 🔍 **Find Similar Elements**: Automatically locate elements similar to the element you want on the page (Ex: other products like the product you found on the page).
- 🔄 **Smart Element Tracking**: Relocate elements after website changes, using an intelligent similarity system and integrated storage.
- 🎯 **Flexible Selection**: CSS selectors, XPath selectors, filters-based search, text search, regex search and more.
- 🔍 **Find Similar Elements**: Automatically locate elements similar to the element you found!
- 🧠 **Smart Content Scraping**: Extract data from multiple websites without specific selectors using Scrapling powerful features.
### Performance
- 🚀 **Lightning Fast**: Built from the ground up with performance in mind, outperforming most popular Python scraping libraries (outperforming BeautifulSoup in parsing by up to 620x in our tests).
### High Performance
- 🚀 **Lightning Fast**: Built from the ground up with performance in mind, outperforming most popular Python scraping libraries.
- 🔋 **Memory Efficient**: Optimized data structures for minimal memory footprint.
-**Fast JSON serialization**: 10x faster JSON serialization than the standard json library with more options.
-**Fast JSON serialization**: 10x faster than standard library.
### Developing Experience
- 🛠️ **Powerful Navigation API**: Traverse the DOM tree easily in all directions and get the info you want (parent, ancestors, sibling, children, next/previous element, and more).
- 🧬 **Rich Text Processing**: All strings have built-in methods for regex matching, cleaning, and more. All elements' attributes are read-only dictionaries that are faster than standard dictionaries with added methods.
- 📝 **Automatic Selector Generation**: Create robust CSS/XPath selectors for any element.
- 🔌 **API Similar to Scrapy/BeautifulSoup**: Familiar methods and similar pseudo-elements for Scrapy and BeautifulSoup users.
- 📘 **Type hints and test coverage**: Complete type coverage and almost full test coverage for better IDE support and fewer bugs, respectively.
### Developer Friendly
- 🛠️ **Powerful Navigation API**: Easy DOM traversal in all directions.
- 🧬 **Rich Text Processing**: All strings have built-in regex, cleaning methods, and more. All elements' attributes are optimized dictionaries that takes less memory than standard dictionaries with added methods.
- 📝 **Auto Selectors Generation**: Generate robust short and full CSS/XPath selectors for any element.
- 🔌 **Familiar API**: Similar to Scrapy/BeautifulSoup and the same pseudo-elements used in Scrapy.
- 📘 **Type hints**: Complete type/doc-strings coverage for future-proofing and best autocompletion support.
## Getting Started
@@ -121,21 +121,22 @@ from scrapling import Fetcher
fetcher = Fetcher(auto_match=False)
# Fetch a web page and create an Adaptor instance
# Do http GET request to a web page and create an Adaptor instance
page = fetcher.get('https://quotes.toscrape.com/', stealthy_headers=True)
# Get all strings in the full page
# Get all text content from all HTML tags in the page except `script` and `style` tags
page.get_all_text(ignore_tags=('script', 'style'))
# Get all quotes, any of these methods will return a list of strings (TextHandlers)
# Get all quotes elements, any of these methods will return a list of strings directly (TextHandlers)
quotes = page.css('.quote .text::text') # CSS selector
quotes = page.xpath('//span[@class="text"]/text()') # XPath
quotes = page.css('.quote').css('.text::text') # Chained selectors
quotes = [element.text for element in page.css('.quote .text')] # Slower than bulk query above
# Get the first quote element
quote = page.css_first('.quote') # / page.css('.quote').first / page.css('.quote')[0]
quote = page.css_first('.quote') # same as page.css('.quote').first or page.css('.quote')[0]
# Tired of selectors? Use find_all/find
# Get all 'div' HTML tags that one of its 'class' values is 'quote'
quotes = page.find_all('div', {'class': 'quote'})
# Same as
quotes = page.find_all('div', class_='quote')
@@ -143,10 +144,10 @@ quotes = page.find_all(['div'], class_='quote')
quotes = page.find_all(class_='quote') # and so on...
# Working with elements
quote.html_content # Inner HTML
quote.prettify() # Prettified version of Inner HTML
quote.attrib # Element attributes
quote.path # DOM path to element (List)
quote.html_content # Get Inner HTML of this element
quote.prettify() # Prettified version of Inner HTML above
quote.attrib # Get that element's attributes
quote.path # DOM path to element (List of all ancestors from <html> tag till the element itself)
```
To keep it simple, all methods can be chained on top of each other!
@@ -262,7 +263,7 @@ True
| 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. | ✔️ |
| disable_ads | Disabled 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. | ✔️ |
| wait_selector | Wait for a specific css selector to be in a specific state. | ✔️ |
@@ -544,7 +545,7 @@ Inspired by BeautifulSoup's `find_all` function you can find elements by using `
* Any string passed is considered a tag name
* Any iterable passed like List/Tuple/Set is considered an iterable of tag names.
* Any dictionary is considered a mapping of HTML element(s) attribute names and attribute values.
* Any regex patterns passed are used as filters
* Any regex patterns passed are used as filters to elements by their text content
* Any functions passed are used as filters
* Any keyword argument passed is considered as an HTML element attribute with its value.
@@ -553,7 +554,7 @@ So the way it works is after collecting all passed arguments and keywords, each
1. All elements with the passed tag name(s).
2. All elements that match all passed attribute(s).
3. All elements that match all passed regex patterns.
3. All elements that its text content match all passed regex patterns.
4. All elements that fulfill all passed function(s).
Note: The filtering process always starts from the first filter it finds in the filtering order above so if no tag name(s) are passed but attributes are passed, the process starts from that layer and so on. **But the order in which you pass the arguments doesn't matter.**
+1 -1
View File
@@ -5,7 +5,7 @@ from scrapling.fetchers import (AsyncFetcher, CustomFetcher, Fetcher,
from scrapling.parser import Adaptor, Adaptors
__author__ = "Karim Shoair (karim.shoair@pm.me)"
__version__ = "0.2.92"
__version__ = "0.2.93"
__copyright__ = "Copyright (c) 2024 Karim Shoair"
+2 -1
View File
@@ -3,7 +3,8 @@ Type definitions for type checking purposes.
"""
from typing import (TYPE_CHECKING, Any, Callable, Dict, Generator, Iterable,
List, Literal, Optional, Pattern, Tuple, Type, Union)
List, Literal, Optional, Pattern, Tuple, Type, TypeVar,
Union)
SelectorWaitStates = Literal["attached", "detached", "hidden", "visible"]
+91 -39
View File
@@ -1,13 +1,18 @@
import re
import typing
from collections.abc import Mapping
from types import MappingProxyType
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._types import (Dict, Iterable, List, Literal, Optional,
Pattern, SupportsIndex, TypeVar, Union)
from scrapling.core.utils import _is_iterable, flatten
# Define type variable for AttributeHandler value type
_TextHandlerType = TypeVar('_TextHandlerType', bound='TextHandler')
class TextHandler(str):
"""Extends standard Python string by adding more functionality"""
@@ -18,72 +23,89 @@ class TextHandler(str):
return super().__new__(cls, string)
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):
@typing.overload
def __getitem__(self, key: SupportsIndex) -> 'TextHandler':
pass
@typing.overload
def __getitem__(self, key: slice) -> "TextHandlers":
pass
def __getitem__(self, key: Union[SupportsIndex, slice]) -> Union["TextHandler", "TextHandlers"]:
lst = super().__getitem__(key)
if isinstance(key, slice):
lst = [TextHandler(s) for s in lst]
return TextHandlers(typing.cast(List[_TextHandlerType], lst))
return typing.cast(_TextHandlerType, TextHandler(lst))
def split(self, sep: str = None, maxsplit: SupportsIndex = -1) -> 'TextHandlers':
return TextHandlers(
typing.cast(List[_TextHandlerType], [TextHandler(s) for s in super().split(sep, maxsplit)])
)
def strip(self, chars: str = None) -> Union[str, 'TextHandler']:
return TextHandler(super().strip(chars))
def lstrip(self, chars=None):
def lstrip(self, chars: str = None) -> Union[str, 'TextHandler']:
return TextHandler(super().lstrip(chars))
def rstrip(self, chars=None):
def rstrip(self, chars: str = None) -> Union[str, 'TextHandler']:
return TextHandler(super().rstrip(chars))
def capitalize(self):
def capitalize(self) -> Union[str, 'TextHandler']:
return TextHandler(super().capitalize())
def casefold(self):
def casefold(self) -> Union[str, 'TextHandler']:
return TextHandler(super().casefold())
def center(self, width, fillchar=' '):
def center(self, width: SupportsIndex, fillchar: str = ' ') -> Union[str, 'TextHandler']:
return TextHandler(super().center(width, fillchar))
def expandtabs(self, tabsize=8):
def expandtabs(self, tabsize: SupportsIndex = 8) -> Union[str, 'TextHandler']:
return TextHandler(super().expandtabs(tabsize))
def format(self, *args, **kwargs):
def format(self, *args: str, **kwargs: str) -> Union[str, 'TextHandler']:
return TextHandler(super().format(*args, **kwargs))
def format_map(self, mapping):
def format_map(self, mapping) -> Union[str, 'TextHandler']:
return TextHandler(super().format_map(mapping))
def join(self, iterable):
def join(self, iterable: Iterable[str]) -> Union[str, 'TextHandler']:
return TextHandler(super().join(iterable))
def ljust(self, width, fillchar=' '):
def ljust(self, width: SupportsIndex, fillchar: str = ' ') -> Union[str, 'TextHandler']:
return TextHandler(super().ljust(width, fillchar))
def rjust(self, width, fillchar=' '):
def rjust(self, width: SupportsIndex, fillchar: str = ' ') -> Union[str, 'TextHandler']:
return TextHandler(super().rjust(width, fillchar))
def swapcase(self):
def swapcase(self) -> Union[str, 'TextHandler']:
return TextHandler(super().swapcase())
def title(self):
def title(self) -> Union[str, 'TextHandler']:
return TextHandler(super().title())
def translate(self, table):
def translate(self, table) -> Union[str, 'TextHandler']:
return TextHandler(super().translate(table))
def zfill(self, width):
def zfill(self, width: SupportsIndex) -> Union[str, 'TextHandler']:
return TextHandler(super().zfill(width))
def replace(self, old, new, count=-1):
def replace(self, old: str, new: str, count: SupportsIndex = -1) -> Union[str, 'TextHandler']:
return TextHandler(super().replace(old, new, count))
def upper(self):
def upper(self) -> Union[str, 'TextHandler']:
return TextHandler(super().upper())
def lower(self):
def lower(self) -> Union[str, 'TextHandler']:
return TextHandler(super().lower())
##############
def sort(self, reverse: bool = False) -> str:
def sort(self, reverse: bool = False) -> Union[str, 'TextHandler']:
"""Return a sorted version of the string"""
return self.__class__("".join(sorted(self, reverse=reverse)))
def clean(self) -> str:
def clean(self) -> Union[str, 'TextHandler']:
"""Return a new version of the string after removing all white spaces and consecutive spaces"""
data = re.sub(r'[\t|\r|\n]', '', self)
data = re.sub(' +', ' ', data)
@@ -105,10 +127,32 @@ class TextHandler(str):
# Check this out: https://github.com/ijl/orjson/issues/445
return loads(str(self))
@typing.overload
def re(
self,
regex: Union[str, Pattern[str]],
check_match: Literal[True],
replace_entities: bool = True,
clean_match: bool = False,
case_sensitive: bool = False,
) -> bool:
...
@typing.overload
def re(
self,
regex: Union[str, Pattern[str]],
replace_entities: bool = True,
clean_match: bool = False,
case_sensitive: bool = False,
check_match: Literal[False] = False,
) -> "TextHandlers[TextHandler]":
...
def re(
self, regex: Union[str, Pattern[str]], replace_entities: bool = True, clean_match: bool = False,
case_sensitive: bool = False, check_match: bool = False
) -> Union[List[str], bool]:
) -> Union["TextHandlers[TextHandler]", bool]:
"""Apply the given regex to the current text and return a list of strings with the matches.
:param regex: Can be either a compiled regular expression or a string.
@@ -133,12 +177,12 @@ class TextHandler(str):
results = flatten(results)
if not replace_entities:
return [TextHandler(string) for string in results]
return TextHandlers(typing.cast(List[_TextHandlerType], [TextHandler(string) for string in results]))
return [TextHandler(_replace_entities(s)) for s in results]
return TextHandlers(typing.cast(List[_TextHandlerType], [TextHandler(_replace_entities(s)) for s in results]))
def re_first(self, regex: Union[str, Pattern[str]], default=None, replace_entities: bool = True,
clean_match: bool = False, case_sensitive: bool = False) -> Union[str, None]:
clean_match: bool = False, case_sensitive: bool = False) -> "TextHandler":
"""Apply the given regex to text and return the first match if found, otherwise return the default value.
:param regex: Can be either a compiled regular expression or a string.
@@ -158,15 +202,23 @@ class TextHandlers(List[TextHandler]):
"""
__slots__ = ()
def __getitem__(self, pos: Union[SupportsIndex, slice]) -> Union[TextHandler, "TextHandlers[TextHandler]"]:
@typing.overload
def __getitem__(self, pos: SupportsIndex) -> TextHandler:
pass
@typing.overload
def __getitem__(self, pos: slice) -> "TextHandlers":
pass
def __getitem__(self, pos: Union[SupportsIndex, slice]) -> Union[TextHandler, "TextHandlers"]:
lst = super().__getitem__(pos)
if isinstance(pos, slice):
return self.__class__(lst)
else:
return lst
lst = [TextHandler(s) for s in lst]
return TextHandlers(typing.cast(List[_TextHandlerType], lst))
return typing.cast(_TextHandlerType, TextHandler(lst))
def re(self, regex: Union[str, Pattern[str]], replace_entities: bool = True, clean_match: bool = False,
case_sensitive: bool = False) -> 'List[str]':
case_sensitive: bool = False) -> 'TextHandlers[TextHandler]':
"""Call the ``.re()`` method for each element in this list and return
their results flattened as TextHandlers.
@@ -178,10 +230,10 @@ class TextHandlers(List[TextHandler]):
results = [
n.re(regex, replace_entities, clean_match, case_sensitive) for n in self
]
return flatten(results)
return TextHandlers(flatten(results))
def re_first(self, regex: Union[str, Pattern[str]], default=None, replace_entities: bool = True,
clean_match: bool = False, case_sensitive: bool = False) -> Union[str, None]:
clean_match: bool = False, case_sensitive: bool = False) -> TextHandler:
"""Call the ``.re_first()`` method for each element in this list and return
the first result or the default value otherwise.
@@ -210,7 +262,7 @@ class TextHandlers(List[TextHandler]):
get_all = extract
class AttributesHandler(Mapping):
class AttributesHandler(Mapping[str, _TextHandlerType]):
"""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.
If standard dictionary is needed, just convert this class to dictionary with `dict` function
"""
@@ -231,7 +283,7 @@ class AttributesHandler(Mapping):
# Fastest read-only mapping type
self._data = MappingProxyType(mapping)
def get(self, key, default=None):
def get(self, key: str, default: Optional[str] = None) -> Union[_TextHandlerType, None]:
"""Acts like standard dictionary `.get()` method"""
return self._data.get(key, default)
@@ -253,7 +305,7 @@ class AttributesHandler(Mapping):
"""Convert current attributes to JSON string if the attributes are JSON serializable otherwise throws error"""
return dumps(dict(self._data))
def __getitem__(self, key):
def __getitem__(self, key: str) -> _TextHandlerType:
return self._data[key]
def __iter__(self):
+1 -1
View File
@@ -139,6 +139,6 @@ class TranslatorMixin:
class HTMLTranslator(TranslatorMixin, OriginalHTMLTranslator):
@lru_cache(maxsize=256)
@lru_cache(maxsize=2048)
def css_to_xpath(self, css: str, prefix: str = "descendant-or-self::") -> str:
return super().css_to_xpath(css, prefix)
+8 -5
View File
@@ -1,7 +1,10 @@
from .fetchers import AsyncFetcher, Fetcher, PlayWrightFetcher, StealthyFetcher
from .fetchers import AsyncFetcher as _AsyncFetcher
from .fetchers import Fetcher as _Fetcher
from .fetchers import PlayWrightFetcher as _PlayWrightFetcher
from .fetchers import StealthyFetcher as _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()
Fetcher = _Fetcher()
AsyncFetcher = _AsyncFetcher()
StealthyFetcher = _StealthyFetcher()
PlayWrightFetcher = _PlayWrightFetcher()
+6 -2
View File
@@ -19,7 +19,7 @@ class CamoufoxEngine:
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 = None, wait_selector: Optional[str] = None, addons: Optional[List[str]] = None,
wait_selector_state: Optional[SelectorWaitStates] = '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,
proxy: Optional[Union[str, Dict[str, str]]] = None, os_randomize: Optional[bool] = None, disable_ads: Optional[bool] = False,
geoip: Optional[bool] = False,
adaptor_arguments: Dict = None,
):
@@ -36,7 +36,7 @@ class CamoufoxEngine:
: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 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 disable_ads: Disabled 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.
: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.
@@ -95,6 +95,8 @@ class CamoufoxEngine:
with Camoufox(
geoip=self.geoip,
proxy=self.proxy,
disable_coop=True,
enable_cache=True,
addons=self.addons,
exclude_addons=addons,
headless=self.headless,
@@ -174,6 +176,8 @@ class CamoufoxEngine:
async with AsyncCamoufox(
geoip=self.geoip,
proxy=self.proxy,
disable_coop=True,
enable_cache=True,
addons=self.addons,
exclude_addons=addons,
headless=self.headless,
+1 -1
View File
@@ -105,7 +105,7 @@ class PlaywrightEngine:
"""
cdp_url = self.cdp_url
if self.nstbrowser_mode:
if self.nstbrowser_config and type(self.nstbrowser_config) is Dict:
if self.nstbrowser_config and isinstance(self.nstbrowser_config, dict):
config = self.nstbrowser_config
else:
query = NSTBROWSER_DEFAULT_QUERY.copy()
+5 -5
View File
@@ -143,7 +143,7 @@ class AsyncFetcher(Fetcher):
: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)
response_object = await StaticEngine(url, proxy, stealthy_headers, follow_redirects, timeout, retries=retries, adaptor_arguments=adaptor_arguments).async_put(**kwargs)
return response_object
async def delete(
@@ -177,7 +177,7 @@ class StealthyFetcher(BaseFetcher):
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: SelectorWaitStates = '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,
proxy: Optional[Union[str, Dict[str, str]]] = None, os_randomize: Optional[bool] = None, disable_ads: Optional[bool] = False, geoip: Optional[bool] = False,
) -> Response:
"""
Opens up a browser and do your request based on your chosen options below.
@@ -191,7 +191,7 @@ class StealthyFetcher(BaseFetcher):
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 disable_ads: Disabled 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.
@@ -235,7 +235,7 @@ class StealthyFetcher(BaseFetcher):
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: SelectorWaitStates = '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,
proxy: Optional[Union[str, Dict[str, str]]] = None, os_randomize: Optional[bool] = None, disable_ads: Optional[bool] = False, geoip: Optional[bool] = False,
) -> Response:
"""
Opens up a browser and do your request based on your chosen options below.
@@ -249,7 +249,7 @@ class StealthyFetcher(BaseFetcher):
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 disable_ads: Disabled 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.
+149 -185
View File
@@ -1,6 +1,7 @@
import inspect
import os
import re
import typing
from difflib import SequenceMatcher
from urllib.parse import urljoin
@@ -145,47 +146,46 @@ class Adaptor(SelectorsGeneration):
# Faster than checking `element.is_attribute or element.is_text or element.is_tail`
return issubclass(type(element), etree._ElementUnicodeResult)
def __get_correct_result(
self, element: Union[html.HtmlElement, etree._ElementUnicodeResult]
) -> Union[TextHandler, html.HtmlElement, 'Adaptor', str]:
"""Used internally in all functions to convert results to type (Adaptor|Adaptors) when possible"""
if self._is_text_node(element):
# etree._ElementUnicodeResult basically inherit from `str` so it's fine
return TextHandler(str(element))
else:
if issubclass(type(element), html.HtmlMixin):
@staticmethod
def __content_convertor(element: Union[html.HtmlElement, etree._ElementUnicodeResult]) -> TextHandler:
"""Used internally to convert a single element's text content to TextHandler directly without checks
return Adaptor(
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=self.__keep_comments, keep_cdata=self.__keep_cdata,
huge_tree=self.__huge_tree_enabled,
**self.__response_data
)
return element
This single line has been isolated like this so when it's used with map we get that slight performance boost vs list comprehension
"""
return TextHandler(str(element))
def __convert_results(
self, result: Union[List[html.HtmlElement], html.HtmlElement]
) -> Union['Adaptors[Adaptor]', 'Adaptor', List, None]:
"""Used internally in all functions to convert results to type (Adaptor|Adaptors) in bulk when possible"""
if result is None:
def __element_convertor(self, element: html.HtmlElement) -> 'Adaptor':
"""Used internally to convert a single HtmlElement to Adaptor directly without checks"""
return Adaptor(
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=self.__keep_comments, keep_cdata=self.__keep_cdata,
huge_tree=self.__huge_tree_enabled,
**self.__response_data
)
def __handle_element(self, element: Union[html.HtmlElement, etree._ElementUnicodeResult]) -> Union[TextHandler, 'Adaptor', None]:
"""Used internally in all functions to convert a single element to type (Adaptor|TextHandler) when possible"""
if element is None:
return None
elif result == []: # Lxml will give a warning if I used something like `not result`
return []
elif self._is_text_node(element):
# etree._ElementUnicodeResult basically inherit from `str` so it's fine
return self.__content_convertor(element)
else:
return self.__element_convertor(element)
if isinstance(result, Adaptors):
return result
def __handle_elements(self, result: List[Union[html.HtmlElement, etree._ElementUnicodeResult]]) -> Union['Adaptors', 'TextHandlers', List]:
"""Used internally in all functions to convert results to type (Adaptors|TextHandlers) in bulk when possible"""
if not len(result): # Lxml will give a warning if I used something like `not result`
return Adaptors([])
if type(result) is list:
results = [self.__get_correct_result(n) for n in result]
if all(isinstance(res, self.__class__) for res in results):
return Adaptors(results)
elif all(isinstance(res, TextHandler) for res in results):
return TextHandlers(results)
return results
# From within the code, this method will always get a list of the same type
# so we will continue without checks for slight performance boost
if self._is_text_node(result[0]):
return TextHandlers(list(map(self.__content_convertor, result)))
return self.__get_correct_result(result)
return Adaptors(list(map(self.__element_convertor, result)))
def __getstate__(self) -> Any:
# lxml don't like it :)
@@ -223,29 +223,16 @@ class Adaptor(SelectorsGeneration):
:return: A TextHandler
"""
_all_strings = []
def _traverse(node: html.HtmlElement) -> None:
"""Traverse element children and get text content of each
:param node: Current node in the tree structure
:return:
"""
for node in self._root.xpath('.//*'):
if node.tag not in ignore_tags:
text = node.text
if text and type(text) is str:
if valid_values:
if text.strip():
_all_strings.append(text if not strip else text.strip())
if valid_values and text.strip():
_all_strings.append(text if not strip else text.strip())
else:
_all_strings.append(text if not strip else text.strip())
for branch in node.iterchildren():
_traverse(branch)
# We will start using Lxml directly for the speed boost
_traverse(self._root)
return TextHandler(separator.join([s for s in _all_strings]))
return TextHandler(separator.join(_all_strings))
def urljoin(self, relative_url: str) -> str:
"""Join this Adaptor's url with a relative url to form an absolute full URL."""
@@ -259,18 +246,18 @@ class Adaptor(SelectorsGeneration):
return self.__attributes
@property
def html_content(self) -> str:
def html_content(self) -> TextHandler:
"""Return the inner html code of the element"""
return etree.tostring(self._root, encoding='unicode', method='html', with_tail=False)
return TextHandler(etree.tostring(self._root, encoding='unicode', method='html', with_tail=False))
@property
def body(self) -> str:
def body(self) -> TextHandler:
"""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
return TextHandler(self.__raw_body) or self.html_content
def prettify(self) -> str:
def prettify(self) -> TextHandler:
"""Return a prettified version of the element's inner html-code"""
return etree.tostring(self._root, encoding='unicode', pretty_print=True, method='html', with_tail=False)
return TextHandler(etree.tostring(self._root, encoding='unicode', pretty_print=True, method='html', with_tail=False))
def has_class(self, class_name: str) -> bool:
"""Check if element has a specific class
@@ -282,26 +269,32 @@ class Adaptor(SelectorsGeneration):
@property
def parent(self) -> Union['Adaptor', None]:
"""Return the direct parent of the element or ``None`` otherwise"""
return self.__convert_results(self._root.getparent())
return self.__handle_element(self._root.getparent())
@property
def children(self) -> Union['Adaptors[Adaptor]', List]:
def below_elements(self) -> 'Adaptors[Adaptor]':
"""Return all elements under the current element in the DOM tree"""
below = self._root.xpath('.//*')
return self.__handle_elements(below)
@property
def children(self) -> 'Adaptors[Adaptor]':
"""Return the children elements of the current element or empty list otherwise"""
return self.__convert_results(list(
child for child in self._root.iterchildren() if type(child) not in html_forbidden
))
return Adaptors([
self.__element_convertor(child) for child in self._root.iterchildren() if type(child) not in html_forbidden
])
@property
def siblings(self) -> Union['Adaptors[Adaptor]', List]:
def siblings(self) -> 'Adaptors[Adaptor]':
"""Return other children of the current element's parent or empty list otherwise"""
if self.parent:
return Adaptors([child for child in self.parent.children if child._root != self._root])
return []
return Adaptors([])
def iterancestors(self) -> Generator['Adaptor', None, None]:
"""Return a generator that loops over all ancestors of the element, starting with element's parent."""
for ancestor in self._root.iterancestors():
yield self.__convert_results(ancestor)
yield self.__element_convertor(ancestor)
def find_ancestor(self, func: Callable[['Adaptor'], bool]) -> Union['Adaptor', None]:
"""Loop over all ancestors of the element till one match the passed function
@@ -328,7 +321,7 @@ class Adaptor(SelectorsGeneration):
# Ignore html comments and unwanted types
next_element = next_element.getnext()
return self.__convert_results(next_element)
return self.__handle_element(next_element)
@property
def previous(self) -> Union['Adaptor', None]:
@@ -339,7 +332,7 @@ class Adaptor(SelectorsGeneration):
# Ignore html comments and unwanted types
prev_element = prev_element.getprevious()
return self.__convert_results(prev_element)
return self.__handle_element(prev_element)
# For easy copy-paste from Scrapy/parsel code when needed :)
def get(self, default=None):
@@ -392,34 +385,26 @@ class Adaptor(SelectorsGeneration):
if issubclass(type(element), html.HtmlElement):
element = _StorageTools.element_to_dict(element)
# TODO: Optimize the traverse logic a bit, maybe later
def _traverse(node: html.HtmlElement, ele: Dict) -> None:
"""Get the matching score of the given element against the node then traverse the children
:param node: Current node in the tree structure
:param ele: The element we are searching for as dictionary
:return:
"""
for node in self._root.xpath('.//*'):
# Collect all elements in the page then for each element get the matching score of it against the node.
# Hence: the code doesn't stop even if the score was 100%
# because there might be another element(s) left in page with the same score
score = self.__calculate_similarity_score(ele, node)
score = self.__calculate_similarity_score(element, node)
score_table.setdefault(score, []).append(node)
for branch in node.iterchildren():
_traverse(branch, ele)
# This will block until we traverse all children/branches
_traverse(self._root, element)
if score_table:
highest_probability = max(score_table.keys())
if score_table[highest_probability] and highest_probability >= percentage:
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]:
log.debug(f'{percent} -> {self.__convert_results(score_table[percent])}')
if log.getEffectiveLevel() < 20:
# No need to execute this part if logging level is not debugging
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]:
log.debug(f'{percent} -> {self.__handle_elements(score_table[percent])}')
if not adaptor_type:
return score_table[highest_probability]
return self.__convert_results(score_table[highest_probability])
return self.__handle_elements(score_table[highest_probability])
return []
def css_first(self, selector: str, identifier: str = '',
@@ -439,8 +424,6 @@ class Adaptor(SelectorsGeneration):
:param percentage: The minimum percentage to accept while auto-matching and not going lower than that.
Be aware that the percentage calculation depends solely on the page structure so don't play with this
number unless you must know what you are doing!
:return: List as :class:`Adaptors`
"""
for element in self.css(selector, identifier, auto_match, auto_save, percentage):
return element
@@ -465,8 +448,6 @@ class Adaptor(SelectorsGeneration):
:param percentage: The minimum percentage to accept while auto-matching and not going lower than that.
Be aware that the percentage calculation depends solely on the page structure so don't play with this
number unless you must know what you are doing!
:return: List as :class:`Adaptors`
"""
for element in self.xpath(selector, identifier, auto_match, auto_save, percentage, **kwargs):
return element
@@ -493,7 +474,7 @@ class Adaptor(SelectorsGeneration):
:return: List as :class:`Adaptors`
"""
try:
if not self.__auto_match_enabled:
if not self.__auto_match_enabled or ',' not in selector:
# No need to split selectors in this case, let's save some CPU cycles :)
xpath_selector = HTMLTranslator().css_to_xpath(selector)
return self.xpath(xpath_selector, identifier or selector, auto_match, auto_save, percentage)
@@ -507,11 +488,8 @@ class Adaptor(SelectorsGeneration):
results += self.xpath(
xpath_selector, identifier or single_selector.canonical(), auto_match, auto_save, percentage
)
else:
xpath_selector = HTMLTranslator().css_to_xpath(selector)
return self.xpath(xpath_selector, identifier or selector, auto_match, auto_save, percentage)
return self.__convert_results(results)
return results
except (SelectorError, SelectorSyntaxError,):
raise SelectorSyntaxError(f"Invalid CSS selector: {selector}")
@@ -538,37 +516,37 @@ class Adaptor(SelectorsGeneration):
:return: List as :class:`Adaptors`
"""
try:
selected_elements = self._root.xpath(selector, **kwargs)
elements = self._root.xpath(selector, **kwargs)
if selected_elements:
if not self.__auto_match_enabled and auto_save:
log.warning("Argument `auto_save` will be ignored because `auto_match` wasn't enabled on initialization. Check docs for more info.")
if elements:
if auto_save:
if not self.__auto_match_enabled:
log.warning("Argument `auto_save` will be ignored because `auto_match` wasn't enabled on initialization. Check docs for more info.")
else:
self.save(elements[0], identifier or selector)
elif self.__auto_match_enabled and auto_save:
self.save(selected_elements[0], identifier or selector)
return self.__convert_results(selected_elements)
else:
if self.__auto_match_enabled and auto_match:
return self.__handle_elements(elements)
elif self.__auto_match_enabled:
if auto_match:
element_data = self.retrieve(identifier or selector)
if element_data:
relocated = self.relocate(element_data, percentage)
if relocated is not None and auto_save:
self.save(relocated[0], identifier or selector)
elements = self.relocate(element_data, percentage)
if elements is not None and auto_save:
self.save(elements[0], identifier or selector)
return self.__convert_results(relocated)
else:
return self.__convert_results(selected_elements)
elif not self.__auto_match_enabled and auto_match:
return self.__handle_elements(elements)
else:
if auto_match:
log.warning("Argument `auto_match` will be ignored because `auto_match` wasn't enabled on initialization. Check docs for more info.")
elif auto_save:
log.warning("Argument `auto_save` will be ignored because `auto_match` wasn't enabled on initialization. Check docs for more info.")
return self.__convert_results(selected_elements)
return self.__handle_elements(elements)
except (SelectorError, SelectorSyntaxError, etree.XPathError, etree.XPathEvalError):
raise SelectorSyntaxError(f"Invalid XPath selector: {selector}")
def find_all(self, *args: Union[str, Iterable[str], Pattern, Callable, Dict[str, str]], **kwargs: str) -> Union['Adaptors[Adaptor]', List]:
def find_all(self, *args: Union[str, Iterable[str], Pattern, Callable, Dict[str, str]], **kwargs: str) -> 'Adaptors':
"""Find elements by filters of your creations for ease..
:param args: Tag name(s), an iterable of tag names, regex patterns, function, or a dictionary of elements' attributes. Leave empty for selecting all.
@@ -588,15 +566,7 @@ class Adaptor(SelectorsGeneration):
attributes = dict()
tags, patterns = set(), set()
results, functions, selectors = [], [], []
def _search_tree(element: Adaptor, filter_function: Callable) -> None:
"""Collect element if it fulfills passed function otherwise, traverse the children tree and iterate"""
if filter_function(element):
results.append(element)
for branch in element.children:
_search_tree(branch, filter_function)
results, functions, selectors = Adaptors([]), [], []
# Brace yourself for a wonderful journey!
for arg in args:
@@ -608,12 +578,12 @@ class Adaptor(SelectorsGeneration):
raise TypeError('Nested Iterables are not accepted, only iterables of tag names are accepted')
tags.update(set(arg))
elif type(arg) is dict:
elif isinstance(arg, dict):
if not all([(type(k) is str and type(v) is str) for k, v in arg.items()]):
raise TypeError('Nested dictionaries are not accepted, only string keys and string values are accepted')
attributes.update(arg)
elif type(arg) is re.Pattern:
elif isinstance(arg, re.Pattern):
patterns.add(arg)
elif callable(arg):
@@ -634,14 +604,14 @@ class Adaptor(SelectorsGeneration):
attributes[attribute_name] = value
# It's easier and faster to build a selector than traversing the tree
tags = tags or ['']
tags = tags or ['*']
for tag in tags:
selector = tag
for key, value in attributes.items():
value = value.replace('"', r'\"') # Escape double quotes in user input
# Not escaping anything with the key so the user can pass patterns like {'href*': '/p/'} or get errors :)
selector += '[{}="{}"]'.format(key, value)
if selector:
if selector != '*':
selectors.append(selector)
if selectors:
@@ -655,14 +625,15 @@ class Adaptor(SelectorsGeneration):
for function in functions:
results = results.filter(function)
else:
results = results or self.below_elements
for pattern in patterns:
results.extend(self.find_by_regex(pattern, first_match=False))
results = results.filter(lambda e: e.text.re(pattern, check_match=True))
for result in (results or [self]):
for function in functions:
_search_tree(result, function)
# Collect element if it fulfills passed function otherwise
for function in functions:
results = results.filter(function)
return self.__convert_results(results)
return results
def find(self, *args: Union[str, Iterable[str], Pattern, Callable, Dict[str, str]], **kwargs: str) -> Union['Adaptor', None]:
"""Find elements by filters of your creations for ease then return the first result. Otherwise return `None`.
@@ -792,7 +763,7 @@ class Adaptor(SelectorsGeneration):
return self.get_all_text(strip=True).json()
def re(self, regex: Union[str, Pattern[str]], replace_entities: bool = True,
clean_match: bool = False, case_sensitive: bool = False) -> 'List[str]':
clean_match: bool = False, case_sensitive: bool = False) -> TextHandlers:
"""Apply the given regex to the current text and return a list of strings with the matches.
:param regex: Can be either a compiled regular expression or a string.
@@ -803,7 +774,7 @@ class Adaptor(SelectorsGeneration):
return self.text.re(regex, replace_entities, clean_match, case_sensitive)
def re_first(self, regex: Union[str, Pattern[str]], default=None, replace_entities: bool = True,
clean_match: bool = False, case_sensitive: bool = False) -> Union[str, None]:
clean_match: bool = False, case_sensitive: bool = False) -> TextHandler:
"""Apply the given regex to text and return the first match if found, otherwise return the default value.
:param regex: Can be either a compiled regular expression or a string.
@@ -894,12 +865,12 @@ class Adaptor(SelectorsGeneration):
if potential_match != root and are_alike(root, target_attrs, potential_match):
similar_elements.append(potential_match)
return self.__convert_results(similar_elements)
return self.__handle_elements(similar_elements)
def find_by_text(
self, text: str, first_match: bool = True, partial: bool = False,
case_sensitive: bool = False, clean_match: bool = True
) -> Union['Adaptors[Adaptor]', 'Adaptor', List]:
) -> Union['Adaptors[Adaptor]', 'Adaptor']:
"""Find elements that its text content fully/partially matches input.
:param text: Text query to match
:param first_match: Return first element that matches conditions, enabled by default
@@ -908,74 +879,60 @@ class Adaptor(SelectorsGeneration):
:param clean_match: if enabled, this will ignore all whitespaces and consecutive spaces while matching
"""
results = []
results = Adaptors([])
if not case_sensitive:
text = text.lower()
def _traverse(node: Adaptor) -> None:
# This selector gets all elements with text content
for node in self.__handle_elements(self._root.xpath('.//*[normalize-space(text())]')):
"""Check if element matches given text otherwise, traverse the children tree and iterate"""
node_text = node.text
# if there's already no text in this node, dodge it to save CPU cycles and time
if node_text:
if clean_match:
node_text = node_text.clean()
if clean_match:
node_text = node_text.clean()
if not case_sensitive:
node_text = node_text.lower()
if not case_sensitive:
node_text = node_text.lower()
if partial:
if text in node_text:
results.append(node)
elif text == node_text:
if partial:
if text in node_text:
results.append(node)
elif text == node_text:
results.append(node)
if results and first_match:
if first_match and results:
# we got an element so we should stop
return
for branch in node.children:
_traverse(branch)
# This will block until we traverse all children/branches
_traverse(self)
break
if first_match:
if results:
return results[0]
return self.__convert_results(results)
return results
def find_by_regex(
self, query: Union[str, Pattern[str]], first_match: bool = True, case_sensitive: bool = False, clean_match: bool = True
) -> Union['Adaptors[Adaptor]', 'Adaptor', List]:
) -> Union['Adaptors[Adaptor]', 'Adaptor']:
"""Find elements that its text content matches the input regex pattern.
:param query: Regex query/pattern to match
:param first_match: Return first element that matches conditions, enabled by default
:param case_sensitive: if enabled, letters case will be taken into consideration in the regex
:param clean_match: if enabled, this will ignore all whitespaces and consecutive spaces while matching
"""
results = []
results = Adaptors([])
def _traverse(node: Adaptor) -> None:
# This selector gets all elements with text content
for node in self.__handle_elements(self._root.xpath('.//*[normalize-space(text())]')):
"""Check if element matches given regex otherwise, traverse the children tree and iterate"""
node_text = node.text
# if there's already no text in this node, dodge it to save CPU cycles and time
if node_text:
if node_text.re(query, check_match=True, clean_match=clean_match, case_sensitive=case_sensitive):
results.append(node)
if node_text.re(query, check_match=True, clean_match=clean_match, case_sensitive=case_sensitive):
results.append(node)
if results and first_match:
if first_match and results:
# we got an element so we should stop
return
for branch in node.children:
_traverse(branch)
# This will block until we traverse all children/branches
_traverse(self)
break
if results and first_match:
return results[0]
return self.__convert_results(results)
return results
class Adaptors(List[Adaptor]):
@@ -984,7 +941,15 @@ class Adaptors(List[Adaptor]):
"""
__slots__ = ()
def __getitem__(self, pos: Union[SupportsIndex, slice]) -> Union[Adaptor, "Adaptors[Adaptor]"]:
@typing.overload
def __getitem__(self, pos: SupportsIndex) -> Adaptor:
pass
@typing.overload
def __getitem__(self, pos: slice) -> "Adaptors":
pass
def __getitem__(self, pos: Union[SupportsIndex, slice]) -> Union[Adaptor, "Adaptors"]:
lst = super().__getitem__(pos)
if isinstance(pos, slice):
return self.__class__(lst)
@@ -993,7 +958,7 @@ class Adaptors(List[Adaptor]):
def xpath(
self, selector: str, identifier: str = '', auto_save: bool = False, percentage: int = 0, **kwargs: Any
) -> Union["Adaptors[Adaptor]", List]:
) -> "Adaptors[Adaptor]":
"""
Call the ``.xpath()`` method for each element in this list and return
their results as another :class:`Adaptors`.
@@ -1019,7 +984,7 @@ class Adaptors(List[Adaptor]):
]
return self.__class__(flatten(results))
def css(self, selector: str, identifier: str = '', auto_save: bool = False, percentage: int = 0) -> Union["Adaptors[Adaptor]", List]:
def css(self, selector: str, identifier: str = '', auto_save: bool = False, percentage: int = 0) -> "Adaptors[Adaptor]":
"""
Call the ``.css()`` method for each element in this list and return
their results flattened as another :class:`Adaptors`.
@@ -1044,7 +1009,7 @@ class Adaptors(List[Adaptor]):
return self.__class__(flatten(results))
def re(self, regex: Union[str, Pattern[str]], replace_entities: bool = True,
clean_match: bool = False, case_sensitive: bool = False) -> 'List[str]':
clean_match: bool = False, case_sensitive: bool = False) -> TextHandlers[TextHandler]:
"""Call the ``.re()`` method for each element in this list and return
their results flattened as List of TextHandler.
@@ -1056,10 +1021,10 @@ class Adaptors(List[Adaptor]):
results = [
n.text.re(regex, replace_entities, clean_match, case_sensitive) for n in self
]
return flatten(results)
return TextHandlers(flatten(results))
def re_first(self, regex: Union[str, Pattern[str]], default=None, replace_entities: bool = True,
clean_match: bool = False, case_sensitive: bool = False) -> Union[str, None]:
clean_match: bool = False, case_sensitive: bool = False) -> TextHandler:
"""Call the ``.re_first()`` method for each element in this list and return
the first result or the default value otherwise.
@@ -1084,15 +1049,14 @@ class Adaptors(List[Adaptor]):
return element
return None
def filter(self, func: Callable[['Adaptor'], bool]) -> Union['Adaptors', List]:
def filter(self, func: Callable[['Adaptor'], bool]) -> 'Adaptors[Adaptor]':
"""Filter current elements based on the passed function
:param func: A function that takes each element as an argument and returns True/False
:return: The new `Adaptors` object or empty list otherwise.
"""
results = [
return self.__class__([
element for element in self if func(element)
]
return self.__class__(results) if results else results
])
# For easy copy-paste from Scrapy/parsel code when needed :)
def get(self, default=None):
+1 -1
View File
@@ -1,6 +1,6 @@
[metadata]
name = scrapling
version = 0.2.92
version = 0.2.93
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.
+3 -4
View File
@@ -6,7 +6,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
setup(
name="scrapling",
version="0.2.92",
version="0.2.93",
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.""",
@@ -52,8 +52,7 @@ setup(
],
# Instead of using requirements file to dodge possible errors from tox?
install_requires=[
"requests>=2.3",
"lxml>=4.5",
"lxml>=5.0",
"cssselect>=1.2",
'click',
"w3lib",
@@ -62,7 +61,7 @@ setup(
'httpx[brotli,zstd, socks]',
'playwright>=1.49.1',
'rebrowser-playwright>=1.49.1',
'camoufox[geoip]>=0.4.9'
'camoufox[geoip]>=0.4.10'
],
python_requires=">=3.9",
url="https://github.com/D4Vinci/Scrapling",