Making find_all/find methods on steroids

Adding the ability to find elements by regex patterns and functions.
This commit is contained in:
Karim shoair
2024-11-09 21:25:17 +02:00
parent b90c8adb02
commit 6b62579a7e
+51 -14
View File
@@ -1,13 +1,14 @@
import os import os
import re
import inspect
from difflib import SequenceMatcher from difflib import SequenceMatcher
from scrapling.core.translator import HTMLTranslator from scrapling.core.translator import HTMLTranslator
from scrapling.core.mixins import SelectorsGeneration from scrapling.core.mixins import SelectorsGeneration
from scrapling.core.custom_types import TextHandler, TextHandlers, AttributesHandler from scrapling.core.custom_types import TextHandler, TextHandlers, AttributesHandler
from scrapling.core.storage_adaptors import SQLiteStorageSystem, StorageSystemMixin, _StorageTools from scrapling.core.storage_adaptors import SQLiteStorageSystem, StorageSystemMixin, _StorageTools
from scrapling.core.utils import setup_basic_logging, logging, clean_spaces, flatten, _is_iterable, html_forbidden from scrapling.core.utils import setup_basic_logging, logging, clean_spaces, flatten, html_forbidden
from scrapling.core._types import Any, Dict, List, Tuple, Optional, Pattern, Union, Callable, Generator, SupportsIndex, Iterable from scrapling.core._types import Any, Dict, List, Tuple, Optional, Pattern, Union, Callable, Generator, SupportsIndex, Iterable
from lxml import etree, html from lxml import etree, html
from cssselect import SelectorError, SelectorSyntaxError, parse as split_selectors from cssselect import SelectorError, SelectorSyntaxError, parse as split_selectors
@@ -542,10 +543,10 @@ class Adaptor(SelectorsGeneration):
except (SelectorError, SelectorSyntaxError, etree.XPathError, etree.XPathEvalError): except (SelectorError, SelectorSyntaxError, etree.XPathError, etree.XPathEvalError):
raise SelectorSyntaxError(f"Invalid XPath selector: {selector}") raise SelectorSyntaxError(f"Invalid XPath selector: {selector}")
def find_all(self, *args, **kwargs) -> Union['Adaptors[Adaptor]', List]: def find_all(self, *args: Union[str, Iterable[str], Pattern, Callable, Dict[str, str]], **kwargs: str) -> Union['Adaptors[Adaptor]', List]:
"""Find elements by their tag name and filter them based on attributes for ease.. """Find elements by filters of your creations for ease..
:param args: Tag name(s), an iterable of tag names, or a dictionary of elements' attributes. Leave empty for selecting all. :param args: Tag name(s), an iterable of tag names, regex patterns, function, or a dictionary of elements' attributes. Leave empty for selecting all.
:param kwargs: The attributes you want to filter elements based on it. :param kwargs: The attributes you want to filter elements based on it.
:return: The `Adaptors` object of the elements or empty list :return: The `Adaptors` object of the elements or empty list
""" """
@@ -560,9 +561,18 @@ class Adaptor(SelectorsGeneration):
if not args and not kwargs: if not args and not kwargs:
raise TypeError('You have to pass something to search with, like tag name(s), tag attributes, or both.') raise TypeError('You have to pass something to search with, like tag name(s), tag attributes, or both.')
tags = set()
selectors = []
attributes = dict() 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)
# Brace yourself for a wonderful journey! # Brace yourself for a wonderful journey!
for arg in args: for arg in args:
if type(arg) is str: if type(arg) is str:
@@ -578,6 +588,15 @@ class Adaptor(SelectorsGeneration):
raise TypeError('Nested dictionaries are not accepted, only string keys and string values are accepted') raise TypeError('Nested dictionaries are not accepted, only string keys and string values are accepted')
attributes.update(arg) attributes.update(arg)
elif type(arg) is re.Pattern:
patterns.add(arg)
elif callable(arg):
if len(inspect.signature(arg).parameters) > 0:
functions.append(arg)
else:
raise TypeError("Callable filter function must have at least one argument to take `Adaptor` objects.")
else: else:
raise TypeError(f'Argument with type "{type(arg)}" is not accepted, please read the docs.') raise TypeError(f'Argument with type "{type(arg)}" is not accepted, please read the docs.')
@@ -597,14 +616,32 @@ class Adaptor(SelectorsGeneration):
value = value.replace('"', r'\"') # Escape double quotes in user input 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 :) # Not escaping anything with the key so the user can pass patterns like {'href*': '/p/'} or get errors :)
selector += '[{}="{}"]'.format(key, value) selector += '[{}="{}"]'.format(key, value)
selectors.append(selector) if selector:
selectors.append(selector)
return self.css(', '.join(selectors)) if selectors:
results = self.css(', '.join(selectors))
if results:
# From the results, get the ones that fulfill passed regex patterns
for pattern in patterns:
results = results.filter(lambda e: e.text.re(pattern, check_match=True))
def find(self, *args, **kwargs) -> Union['Adaptor', None]: # From the results, get the ones that fulfill passed functions
"""Find elements by their tag name and filter them based on attributes for ease then return the first result. Otherwise return `None`. for function in functions:
results = results.filter(function)
else:
for pattern in patterns:
results.extend(self.find_by_regex(pattern, first_match=False))
:param args: Tag name(s), an iterable of tag names, or a dictionary of elements' attributes. Leave empty for selecting all. for function in functions:
_search_tree(self, function)
return self.__convert_results(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`.
:param args: Tag name(s), an iterable of tag names, regex patterns, function, or a dictionary of elements' attributes. Leave empty for selecting all.
:param kwargs: The attributes you want to filter elements based on it. :param kwargs: The attributes you want to filter elements based on it.
:return: The `Adaptor` object of the element or `None` if the result didn't match :return: The `Adaptor` object of the element or `None` if the result didn't match
""" """
@@ -882,10 +919,10 @@ class Adaptor(SelectorsGeneration):
return self.__convert_results(results) return self.__convert_results(results)
def find_by_regex( def find_by_regex(
self, query: str, first_match: bool = True, case_sensitive: bool = False, clean_match: bool = True 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', List]:
"""Find elements that its text content matches the input regex pattern. """Find elements that its text content matches the input regex pattern.
:param query: Regex query to match :param query: Regex query/pattern to match
:param first_match: Return first element that matches conditions, enabled by default :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 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 :param clean_match: if enabled, this will ignore all whitespaces and consecutive spaces while matching