@@ -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.**
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user