Adds `_fetch_page_content()`, which downloads a URL with a browser-like User-Agent and uses BeautifulSoup with the lxml parser to strip scripts, navigation, and other non-content markup, returning up to 3000 characters of readable text. The response validator now also compares generated replies against this scraped text, flagging named entities and claims that do not appear in the retrieved page content. Dependencies: adds beautifulsoup4==4.12.3 and pins lxml==5.3.0 for parser compatibility.
1705 lines
62 KiB
Python
1705 lines
62 KiB
Python
"""CSS matcher."""
|
|
from __future__ import annotations
|
|
from datetime import datetime
|
|
from collections.abc import Hashable
|
|
from . import util
|
|
import re
|
|
from . import css_types as ct
|
|
import unicodedata
|
|
import bs4
|
|
from typing import Iterator, Iterable, Any, Callable, Sequence, Any, overload, Literal, cast # noqa: F401, F811
|
|
|
|
# Empty tag pattern (whitespace okay)
|
|
RE_NOT_EMPTY = re.compile('[^ \t\r\n\f]')
|
|
|
|
RE_NOT_WS = re.compile('[^ \t\r\n\f]+')
|
|
|
|
# Relationships
|
|
REL_PARENT = ' '
|
|
REL_CLOSE_PARENT = '>'
|
|
REL_SIBLING = '~'
|
|
REL_CLOSE_SIBLING = '+'
|
|
|
|
# Relationships for :has() (forward looking)
|
|
REL_HAS_PARENT = ': '
|
|
REL_HAS_CLOSE_PARENT = ':>'
|
|
REL_HAS_SIBLING = ':~'
|
|
REL_HAS_CLOSE_SIBLING = ':+'
|
|
|
|
NS_XHTML = 'http://www.w3.org/1999/xhtml'
|
|
NS_XML = 'http://www.w3.org/XML/1998/namespace'
|
|
|
|
DIR_FLAGS = ct.SEL_DIR_LTR | ct.SEL_DIR_RTL
|
|
RANGES = ct.SEL_IN_RANGE | ct.SEL_OUT_OF_RANGE
|
|
|
|
DIR_MAP = {
|
|
'ltr': ct.SEL_DIR_LTR,
|
|
'rtl': ct.SEL_DIR_RTL,
|
|
'auto': 0
|
|
}
|
|
|
|
RE_NUM = re.compile(r"^(?P<value>-?(?:[0-9]{1,}(\.[0-9]+)?|\.[0-9]+))$")
|
|
RE_TIME = re.compile(r'^(?P<hour>[0-9]{2}):(?P<minutes>[0-9]{2})$')
|
|
RE_MONTH = re.compile(r'^(?P<year>[0-9]{4,})-(?P<month>[0-9]{2})$')
|
|
RE_WEEK = re.compile(r'^(?P<year>[0-9]{4,})-W(?P<week>[0-9]{2})$')
|
|
RE_DATE = re.compile(r'^(?P<year>[0-9]{4,})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})$')
|
|
RE_DATETIME = re.compile(
|
|
r'^(?P<year>[0-9]{4,})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})T(?P<hour>[0-9]{2}):(?P<minutes>[0-9]{2})$'
|
|
)
|
|
RE_WILD_STRIP = re.compile(r'(?:(?:-\*-)(?:\*(?:-|$))*|-\*$)')
|
|
|
|
MONTHS_30 = (4, 6, 9, 11) # April, June, September, and November
|
|
FEB = 2
|
|
SHORT_MONTH = 30
|
|
LONG_MONTH = 31
|
|
FEB_MONTH = 28
|
|
FEB_LEAP_MONTH = 29
|
|
DAYS_IN_WEEK = 7
|
|
|
|
|
|
def within(target: bs4.Tag, parent: bs4.Tag | bs4.BeautifulSoup, start: int, end: int | None = None) -> bool:
|
|
"""Check if target is within data."""
|
|
|
|
contents = parent.contents
|
|
return any(contents[i] is target for i in range(start, end if end is not None else len(contents)))
|
|
|
|
|
|
class _DocumentNav:
|
|
"""Navigate a Beautiful Soup document."""
|
|
|
|
@classmethod
|
|
def assert_valid_input(cls, tag: Any) -> None:
|
|
"""Check if valid input tag or document."""
|
|
|
|
# Fail on unexpected types.
|
|
if not cls.is_tag(tag):
|
|
raise TypeError(f"Expected a BeautifulSoup 'Tag', but instead received type {type(tag)}")
|
|
|
|
@staticmethod
|
|
def is_doc(obj: bs4.element.PageElement | None) -> bool:
|
|
"""Is `BeautifulSoup` object."""
|
|
return isinstance(obj, bs4.BeautifulSoup)
|
|
|
|
@staticmethod
|
|
def is_tag(obj: bs4.element.PageElement | None) -> bool:
|
|
"""Is tag."""
|
|
return isinstance(obj, bs4.Tag)
|
|
|
|
@staticmethod
|
|
def is_declaration(obj: bs4.element.PageElement | None) -> bool: # pragma: no cover
|
|
"""Is declaration."""
|
|
return isinstance(obj, bs4.Declaration)
|
|
|
|
@staticmethod
|
|
def is_cdata(obj: bs4.element.PageElement | None) -> bool:
|
|
"""Is CDATA."""
|
|
return isinstance(obj, bs4.CData)
|
|
|
|
@staticmethod
|
|
def is_processing_instruction(obj: bs4.element.PageElement | None) -> bool: # pragma: no cover
|
|
"""Is processing instruction."""
|
|
return isinstance(obj, bs4.ProcessingInstruction)
|
|
|
|
@staticmethod
|
|
def is_navigable_string(obj: bs4.element.PageElement | None) -> bool:
|
|
"""Is navigable string."""
|
|
return isinstance(obj, bs4.element.NavigableString)
|
|
|
|
@staticmethod
|
|
def is_special_string(obj: bs4.element.PageElement | None) -> bool:
|
|
"""Is special string."""
|
|
return isinstance(obj, (bs4.Comment, bs4.Declaration, bs4.CData, bs4.ProcessingInstruction, bs4.Doctype))
|
|
|
|
@classmethod
|
|
def is_content_string(cls, obj: bs4.element.PageElement | None) -> bool:
|
|
"""Check if node is content string."""
|
|
|
|
return cls.is_navigable_string(obj) and not cls.is_special_string(obj)
|
|
|
|
@staticmethod
|
|
def is_xml_tree(el: bs4.Tag | None) -> bool:
|
|
"""Check if element (or document) is from a XML tree."""
|
|
|
|
return el is not None and bool(el._is_xml)
|
|
|
|
def is_iframe(self, el: bs4.Tag | None) -> bool:
|
|
"""Check if element is an `iframe`."""
|
|
|
|
if el is None: # pragma: no cover
|
|
return False
|
|
|
|
return bool(
|
|
((el.name if self.is_xml_tree(el) else util.lower(el.name)) == 'iframe') and
|
|
self.is_html_tag(el) # type: ignore[attr-defined]
|
|
)
|
|
|
|
def is_root(self, el: bs4.Tag) -> bool:
|
|
"""
|
|
Return whether element is a root element.
|
|
|
|
We check that the element is the root of the tree (which we have already pre-calculated),
|
|
and we check if it is the root element under an `iframe`.
|
|
"""
|
|
|
|
root = self.root and self.root is el # type: ignore[attr-defined]
|
|
if not root:
|
|
parent = self.get_parent(el)
|
|
root = parent is not None and self.is_html and self.is_iframe(parent) # type: ignore[attr-defined]
|
|
return root
|
|
|
|
def get_contents(self, el: bs4.Tag | None, no_iframe: bool = False) -> Iterator[bs4.element.PageElement]:
|
|
"""Get contents or contents in reverse."""
|
|
|
|
if el is not None:
|
|
if not no_iframe or not self.is_iframe(el):
|
|
yield from el.contents
|
|
|
|
def get_tag_children(
|
|
self,
|
|
el: bs4.Tag | None,
|
|
start: int | None = None,
|
|
reverse: bool = False,
|
|
no_iframe: bool = False
|
|
) -> Iterator[bs4.Tag]:
|
|
"""Get tag children."""
|
|
|
|
return self.get_children(el, start, reverse, True, no_iframe)
|
|
|
|
@overload
|
|
def get_children(
|
|
self,
|
|
el: bs4.Tag | None,
|
|
start: int | None = None,
|
|
reverse: bool = False,
|
|
tags: Literal[True] = ...,
|
|
no_iframe: bool = False
|
|
) -> Iterator[bs4.Tag]:
|
|
...
|
|
|
|
@overload
|
|
def get_children(
|
|
self,
|
|
el: bs4.Tag | None,
|
|
start: int | None = None,
|
|
reverse: bool = False,
|
|
tags: Literal[False] = ...,
|
|
no_iframe: bool = False
|
|
) -> Iterator[bs4.element.PageElement]:
|
|
...
|
|
|
|
def get_children(
|
|
self,
|
|
el: bs4.Tag | None,
|
|
start: int | None = None,
|
|
reverse: bool = False,
|
|
tags: Literal[True] | Literal[False] = False,
|
|
no_iframe: bool = False
|
|
) -> Iterator[bs4.element.PageElement]:
|
|
"""Get children."""
|
|
|
|
if el is not None and (not no_iframe or not self.is_iframe(el)):
|
|
last = len(el.contents) - 1
|
|
if start is None:
|
|
index = last if reverse else 0
|
|
else:
|
|
index = start
|
|
end = -1 if reverse else last + 1
|
|
incr = -1 if reverse else 1
|
|
|
|
if 0 <= index <= last:
|
|
for i in range(index, end, incr):
|
|
node = el.contents[i]
|
|
if not tags or self.is_tag(node):
|
|
yield node
|
|
|
|
def get_tag_descendants(
|
|
self,
|
|
el: bs4.Tag | None,
|
|
no_iframe: bool = False
|
|
) -> Iterator[bs4.Tag]:
|
|
"""Specifically get tag descendants."""
|
|
|
|
yield from self.get_descendants(el, tags=True, no_iframe=no_iframe) # type: ignore[misc]
|
|
|
|
def get_descendants(
|
|
self,
|
|
el: bs4.Tag | None,
|
|
tags: bool = False,
|
|
no_iframe: bool = False
|
|
) -> Iterator[bs4.element.PageElement]:
|
|
"""Get descendants."""
|
|
|
|
if el is not None and (not no_iframe or not self.is_iframe(el)):
|
|
next_good = None
|
|
for child in el.descendants:
|
|
|
|
if next_good is not None:
|
|
if child is not next_good:
|
|
continue
|
|
next_good = None
|
|
|
|
if isinstance(child, bs4.Tag):
|
|
if no_iframe and self.is_iframe(child):
|
|
if child.next_sibling is not None:
|
|
next_good = child.next_sibling
|
|
else:
|
|
last_child = child # type: bs4.element.PageElement
|
|
while isinstance(last_child, bs4.Tag) and last_child.contents:
|
|
last_child = last_child.contents[-1]
|
|
next_good = last_child.next_element
|
|
yield child
|
|
if next_good is None:
|
|
break
|
|
# Coverage isn't seeing this even though it's executed
|
|
continue # pragma: no cover
|
|
yield child
|
|
|
|
elif not tags:
|
|
yield child
|
|
|
|
def get_parent(self, el: bs4.Tag | None, no_iframe: bool = False) -> bs4.Tag | None:
|
|
"""Get parent."""
|
|
|
|
parent = el.parent if el is not None else None
|
|
if no_iframe and parent is not None and self.is_iframe(parent): # pragma: no cover
|
|
parent = None
|
|
return parent
|
|
|
|
@staticmethod
|
|
def get_tag_name(el: bs4.Tag | None) -> str | None:
|
|
"""Get tag."""
|
|
|
|
return el.name if el is not None else None
|
|
|
|
@staticmethod
|
|
def get_prefix_name(el: bs4.Tag) -> str | None:
|
|
"""Get prefix."""
|
|
|
|
return el.prefix
|
|
|
|
@staticmethod
|
|
def get_uri(el: bs4.Tag | None) -> str | None:
|
|
"""Get namespace `URI`."""
|
|
|
|
return el.namespace if el is not None else None
|
|
|
|
@classmethod
|
|
def get_next_tag(cls, el: bs4.Tag) -> bs4.Tag | None:
|
|
"""Get next sibling tag."""
|
|
|
|
return cls.get_next(el, tags=True) # type: ignore[return-value]
|
|
|
|
@classmethod
|
|
def get_next(cls, el: bs4.Tag, tags: bool = False) -> bs4.element.PageElement | None:
|
|
"""Get next sibling tag."""
|
|
|
|
sibling = el.next_sibling
|
|
while tags and not isinstance(sibling, bs4.Tag) and sibling is not None:
|
|
sibling = sibling.next_sibling
|
|
|
|
if tags and not isinstance(sibling, bs4.Tag):
|
|
sibling = None
|
|
|
|
return sibling
|
|
|
|
@classmethod
|
|
def get_previous_tag(cls, el: bs4.Tag, tags: bool = True) -> bs4.Tag | None:
|
|
"""Get previous sibling tag."""
|
|
|
|
return cls.get_previous(el, True) # type: ignore[return-value]
|
|
|
|
@classmethod
|
|
def get_previous(cls, el: bs4.Tag, tags: bool = False) -> bs4.element.PageElement | None:
|
|
"""Get previous sibling tag."""
|
|
|
|
sibling = el.previous_sibling
|
|
while tags and not isinstance(sibling, bs4.Tag) and sibling is not None:
|
|
sibling = sibling.previous_sibling
|
|
|
|
if tags and not isinstance(sibling, bs4.Tag):
|
|
sibling = None
|
|
|
|
return sibling
|
|
|
|
@staticmethod
|
|
def has_html_ns(el: bs4.Tag | None) -> bool:
|
|
"""
|
|
Check if element has an HTML namespace.
|
|
|
|
This is a bit different than whether a element is treated as having an HTML namespace,
|
|
like we do in the case of `is_html_tag`.
|
|
"""
|
|
|
|
ns = getattr(el, 'namespace') if el is not None else None # noqa: B009
|
|
return bool(ns and ns == NS_XHTML)
|
|
|
|
@staticmethod
|
|
def split_namespace(el: bs4.Tag | None, attr_name: str) -> tuple[str | None, str | None]:
|
|
"""Return namespace and attribute name without the prefix."""
|
|
|
|
if el is None: # pragma: no cover
|
|
return None, None
|
|
|
|
return getattr(attr_name, 'namespace', None), getattr(attr_name, 'name', None)
|
|
|
|
@classmethod
|
|
def get_attribute_by_name(
|
|
cls,
|
|
el: bs4.Tag,
|
|
name: str,
|
|
default: str | Sequence[str] | None = None
|
|
) -> str | Sequence[str] | None:
|
|
"""Get attribute by name."""
|
|
|
|
value = default
|
|
if el._is_xml:
|
|
if name in el.attrs:
|
|
v = el.attrs[name]
|
|
value = '' if v is None else v
|
|
else:
|
|
for k, v in el.attrs.items():
|
|
if util.lower(k) == name:
|
|
value = '' if v is None else v
|
|
break
|
|
return value
|
|
|
|
@classmethod
|
|
def iter_attributes(cls, el: bs4.Tag | None) -> Iterator[tuple[str, str | Sequence[str] | None]]:
|
|
"""Iterate attributes."""
|
|
|
|
if el is not None:
|
|
for k, v in el.attrs.items():
|
|
yield k, '' if v is None else v
|
|
|
|
@classmethod
|
|
def get_classes(cls, el: bs4.Tag) -> Sequence[str]:
|
|
"""Get classes."""
|
|
|
|
classes = cls.get_attribute_by_name(el, 'class', [])
|
|
if isinstance(classes, str):
|
|
classes = RE_NOT_WS.findall(classes)
|
|
return cast(Sequence[str], classes)
|
|
|
|
def get_text(self, el: bs4.Tag, no_iframe: bool = False) -> str:
|
|
"""Get text."""
|
|
|
|
return ''.join(
|
|
[
|
|
node for node in self.get_descendants(el, no_iframe=no_iframe) # type: ignore[misc]
|
|
if self.is_content_string(node)
|
|
]
|
|
)
|
|
|
|
def get_own_text(self, el: bs4.Tag, no_iframe: bool = False) -> list[str]:
|
|
"""Get Own Text."""
|
|
|
|
return [
|
|
node for node in self.get_contents(el, no_iframe=no_iframe) if self.is_content_string(node) # type: ignore[misc]
|
|
]
|
|
|
|
|
|
class Inputs:
|
|
"""Class for parsing and validating input items."""
|
|
|
|
@staticmethod
|
|
def validate_day(year: int, month: int, day: int) -> bool:
|
|
"""Validate day."""
|
|
|
|
max_days = LONG_MONTH
|
|
if month == FEB:
|
|
max_days = FEB_LEAP_MONTH if ((year % 4 == 0) and (year % 100 != 0)) or (year % 400 == 0) else FEB_MONTH
|
|
elif month in MONTHS_30:
|
|
max_days = SHORT_MONTH
|
|
return 1 <= day <= max_days
|
|
|
|
@staticmethod
|
|
def validate_week(year: int, week: int) -> bool:
|
|
"""Validate week."""
|
|
|
|
# Validate an ISO week number for `year`.
|
|
#
|
|
# Per ISO 8601 rules, the last ISO week of a year is the week
|
|
# containing Dec 28. Using Dec 28 guarantees we obtain the
|
|
# correct ISO week-number for the final week of `year`, even in
|
|
# years where Dec 31 falls in ISO week 01 of the following year.
|
|
#
|
|
# Example: if Dec 31 is a Thursday the year's last ISO week will
|
|
# be week 53; if Dec 31 is a Monday and that week is counted as
|
|
# week 1 of the next year, Dec 28 still belongs to the final
|
|
# week of the current ISO year and yields the correct max week.
|
|
max_week = datetime(year, 12, 28).isocalendar()[1]
|
|
return 1 <= week <= max_week
|
|
|
|
@staticmethod
|
|
def validate_month(month: int) -> bool:
|
|
"""Validate month."""
|
|
|
|
return 1 <= month <= 12
|
|
|
|
@staticmethod
|
|
def validate_year(year: int) -> bool:
|
|
"""Validate year."""
|
|
|
|
return 1 <= year
|
|
|
|
@staticmethod
|
|
def validate_hour(hour: int) -> bool:
|
|
"""Validate hour."""
|
|
|
|
return 0 <= hour <= 23
|
|
|
|
@staticmethod
|
|
def validate_minutes(minutes: int) -> bool:
|
|
"""Validate minutes."""
|
|
|
|
return 0 <= minutes <= 59
|
|
|
|
@classmethod
|
|
def parse_value(cls, itype: str, value: str | None) -> tuple[float, ...] | None:
|
|
"""Parse the input value."""
|
|
|
|
parsed = None # type: tuple[float, ...] | None
|
|
if value is None:
|
|
return value
|
|
if itype == "date":
|
|
m = RE_DATE.match(value)
|
|
if m:
|
|
year = int(m.group('year'), 10)
|
|
month = int(m.group('month'), 10)
|
|
day = int(m.group('day'), 10)
|
|
if cls.validate_year(year) and cls.validate_month(month) and cls.validate_day(year, month, day):
|
|
parsed = (year, month, day)
|
|
elif itype == "month":
|
|
m = RE_MONTH.match(value)
|
|
if m:
|
|
year = int(m.group('year'), 10)
|
|
month = int(m.group('month'), 10)
|
|
if cls.validate_year(year) and cls.validate_month(month):
|
|
parsed = (year, month)
|
|
elif itype == "week":
|
|
m = RE_WEEK.match(value)
|
|
if m:
|
|
year = int(m.group('year'), 10)
|
|
week = int(m.group('week'), 10)
|
|
if cls.validate_year(year) and cls.validate_week(year, week):
|
|
parsed = (year, week)
|
|
elif itype == "time":
|
|
m = RE_TIME.match(value)
|
|
if m:
|
|
hour = int(m.group('hour'), 10)
|
|
minutes = int(m.group('minutes'), 10)
|
|
if cls.validate_hour(hour) and cls.validate_minutes(minutes):
|
|
parsed = (hour, minutes)
|
|
elif itype == "datetime-local":
|
|
m = RE_DATETIME.match(value)
|
|
if m:
|
|
year = int(m.group('year'), 10)
|
|
month = int(m.group('month'), 10)
|
|
day = int(m.group('day'), 10)
|
|
hour = int(m.group('hour'), 10)
|
|
minutes = int(m.group('minutes'), 10)
|
|
if (
|
|
cls.validate_year(year) and cls.validate_month(month) and cls.validate_day(year, month, day) and
|
|
cls.validate_hour(hour) and cls.validate_minutes(minutes)
|
|
):
|
|
parsed = (year, month, day, hour, minutes)
|
|
elif itype in ("number", "range"):
|
|
m = RE_NUM.match(value)
|
|
if m:
|
|
parsed = (float(m.group('value')),)
|
|
return parsed
|
|
|
|
|
|
class CSSMatch(_DocumentNav):
|
|
"""Perform CSS matching."""
|
|
|
|
def __init__(
|
|
self,
|
|
selectors: ct.SelectorList,
|
|
scope: bs4.Tag | None,
|
|
namespaces: ct.Namespaces | None,
|
|
flags: int
|
|
) -> None:
|
|
"""Initialize."""
|
|
|
|
self.assert_valid_input(scope)
|
|
self.tag = scope
|
|
self.cached_meta_lang = [] # type: list[tuple[str, str]]
|
|
self.cached_default_forms = [] # type: list[tuple[bs4.Tag, bs4.Tag]]
|
|
self.cached_indeterminate_forms = [] # type: list[tuple[bs4.Tag, str, bool]]
|
|
self.selectors = selectors
|
|
self.namespaces = {} if namespaces is None else namespaces # type: ct.Namespaces | dict[str, str]
|
|
self.flags = flags
|
|
self.enable_cache = not bool(self.flags & util.NOCACHE)
|
|
self.iframe_restrict = False
|
|
self.nth_cache: dict[Hashable, dict[Hashable, list[int]]] = {}
|
|
self.sib_cache: dict[Hashable, dict[Hashable, int]] = {}
|
|
|
|
# Find the root element for the whole tree
|
|
doc = scope
|
|
parent = self.get_parent(doc)
|
|
while parent:
|
|
doc = parent
|
|
parent = self.get_parent(doc)
|
|
root = None # type: bs4.Tag | None
|
|
if not self.is_doc(doc):
|
|
root = doc
|
|
else:
|
|
for child in self.get_tag_children(doc):
|
|
root = child
|
|
break
|
|
|
|
self.root = root
|
|
self.scope = scope if scope is not doc else root
|
|
self.has_html_namespace = self.has_html_ns(root)
|
|
|
|
# A document can be both XML and HTML (XHTML)
|
|
self.is_xml = self.is_xml_tree(doc)
|
|
self.is_html = not self.is_xml or self.has_html_namespace
|
|
|
|
def reset(self) -> None: # pragma: no cover
|
|
"""Reset."""
|
|
|
|
self.nth_cache.clear()
|
|
self.sib_cache.clear()
|
|
|
|
def supports_namespaces(self) -> bool:
|
|
"""Check if namespaces are supported in the HTML type."""
|
|
|
|
return self.is_xml or self.has_html_namespace
|
|
|
|
def get_tag_ns(self, el: bs4.Tag | None) -> str:
|
|
"""Get tag namespace."""
|
|
|
|
namespace = ''
|
|
if el is None: # pragma: no cover
|
|
return namespace
|
|
|
|
if self.supports_namespaces():
|
|
ns = self.get_uri(el)
|
|
if ns:
|
|
namespace = ns
|
|
else:
|
|
namespace = NS_XHTML
|
|
return namespace
|
|
|
|
def is_html_tag(self, el: bs4.Tag | None) -> bool:
|
|
"""Check if tag is in HTML namespace."""
|
|
|
|
return self.get_tag_ns(el) == NS_XHTML
|
|
|
|
def get_tag(self, el: bs4.Tag | None) -> str | None:
|
|
"""Get tag."""
|
|
|
|
name = self.get_tag_name(el)
|
|
return util.lower(name) if name is not None and not self.is_xml else name
|
|
|
|
def get_prefix(self, el: bs4.Tag) -> str | None:
|
|
"""Get prefix."""
|
|
|
|
prefix = self.get_prefix_name(el)
|
|
return util.lower(prefix) if prefix is not None and not self.is_xml else prefix
|
|
|
|
def find_bidi(self, el: bs4.Tag) -> int | None:
|
|
"""Get directionality from element text."""
|
|
|
|
for node in self.get_children(el):
|
|
|
|
# Analyze child text nodes
|
|
if self.is_tag(node):
|
|
|
|
# Avoid analyzing certain elements specified in the specification.
|
|
direction = DIR_MAP.get(util.lower(self.get_attribute_by_name(node, 'dir', '')), None)
|
|
name = self.get_tag(node)
|
|
if (
|
|
(name and name in ('bdi', 'script', 'style', 'textarea', 'iframe')) or
|
|
not self.is_html_tag(node) or
|
|
direction is not None
|
|
):
|
|
continue # pragma: no cover
|
|
|
|
# Check directionality of this node's text
|
|
value = self.find_bidi(node)
|
|
if value is not None:
|
|
return value
|
|
|
|
# Direction could not be determined
|
|
continue # pragma: no cover
|
|
|
|
# Skip `doctype` comments, etc.
|
|
if self.is_special_string(node):
|
|
continue
|
|
|
|
# Analyze text nodes for directionality.
|
|
for c in cast('bs4.element.NavigableString', node):
|
|
bidi = unicodedata.bidirectional(c)
|
|
if bidi in ('AL', 'R', 'L'):
|
|
return ct.SEL_DIR_LTR if bidi == 'L' else ct.SEL_DIR_RTL
|
|
return None
|
|
|
|
def extended_language_filter(self, lang_range: str, lang_tag: str) -> bool:
|
|
"""Filter the language tags."""
|
|
|
|
match = True
|
|
lang_range = RE_WILD_STRIP.sub('-', lang_range).lower()
|
|
ranges = lang_range.split('-')
|
|
subtags = lang_tag.lower().split('-')
|
|
length = len(ranges)
|
|
slength = len(subtags)
|
|
rindex = 0
|
|
sindex = 0
|
|
r = ranges[rindex]
|
|
s = subtags[sindex]
|
|
|
|
# Empty specified language should match unspecified language attributes
|
|
if length == 1 and slength == 1 and not r and r == s:
|
|
return True
|
|
|
|
# Primary tag needs to match
|
|
if (r != '*' and r != s) or (r == '*' and slength == 1 and not s):
|
|
match = False
|
|
|
|
rindex += 1
|
|
sindex += 1
|
|
|
|
# Match until we run out of ranges
|
|
while match and rindex < length:
|
|
r = ranges[rindex]
|
|
try:
|
|
s = subtags[sindex]
|
|
except IndexError:
|
|
# Ran out of subtags,
|
|
# but we still have ranges
|
|
match = False
|
|
continue
|
|
|
|
# Empty range
|
|
if not r:
|
|
match = False
|
|
continue
|
|
|
|
# Matched range
|
|
elif s == r:
|
|
rindex += 1
|
|
|
|
# Implicit wildcard cannot match
|
|
# singletons
|
|
elif len(s) == 1:
|
|
match = False
|
|
continue
|
|
|
|
# Implicitly matched, so grab next subtag
|
|
sindex += 1
|
|
|
|
return match
|
|
|
|
def match_attribute_name(
|
|
self,
|
|
el: bs4.Tag,
|
|
attr: str,
|
|
prefix: str | None
|
|
) -> str | Sequence[str] | None:
|
|
"""Match attribute name and return value if it exists."""
|
|
|
|
value = None
|
|
if self.supports_namespaces():
|
|
value = None
|
|
# If we have not defined namespaces, we can't very well find them, so don't bother trying.
|
|
if prefix:
|
|
ns = self.namespaces.get(prefix)
|
|
if ns is None and prefix != '*':
|
|
return None
|
|
else:
|
|
ns = None
|
|
|
|
for k, v in self.iter_attributes(el):
|
|
|
|
# Get attribute parts
|
|
namespace, name = self.split_namespace(el, k)
|
|
|
|
# Can't match a prefix attribute as we haven't specified one to match
|
|
# Try to match it normally as a whole `p:a` as selector may be trying `p\:a`.
|
|
if ns is None:
|
|
if (self.is_xml and attr == k) or (not self.is_xml and util.lower(attr) == util.lower(k)):
|
|
value = v
|
|
break
|
|
# Coverage is not finding this even though it is executed.
|
|
# Adding a print statement before this (and erasing coverage) causes coverage to find the line.
|
|
# Ignore the false positive message.
|
|
continue # pragma: no cover
|
|
|
|
# We can't match our desired prefix attribute as the attribute doesn't have a prefix
|
|
if namespace is None or (ns != namespace and prefix != '*'):
|
|
continue
|
|
|
|
# The attribute doesn't match.
|
|
if (util.lower(attr) != util.lower(name)) if not self.is_xml else (attr != name):
|
|
continue
|
|
|
|
value = v
|
|
break
|
|
else:
|
|
for k, v in self.iter_attributes(el):
|
|
if util.lower(attr) != util.lower(k):
|
|
continue
|
|
value = v
|
|
break
|
|
return value
|
|
|
|
def match_namespace(self, el: bs4.Tag, tag: ct.SelectorTag) -> bool:
|
|
"""Match the namespace of the element."""
|
|
|
|
match = True
|
|
namespace = self.get_tag_ns(el)
|
|
default_namespace = self.namespaces.get('')
|
|
tag_ns = '' if tag.prefix is None else self.namespaces.get(tag.prefix)
|
|
# We must match the default namespace if one is not provided
|
|
if tag.prefix is None and (default_namespace is not None and namespace != default_namespace):
|
|
match = False
|
|
# If we specified `|tag`, we must not have a namespace.
|
|
elif (tag.prefix is not None and tag.prefix == '' and namespace):
|
|
match = False
|
|
# Verify prefix matches
|
|
elif (
|
|
tag.prefix and
|
|
tag.prefix != '*' and (tag_ns is None or namespace != tag_ns)
|
|
):
|
|
match = False
|
|
return match
|
|
|
|
def match_attributes(self, el: bs4.Tag, attributes: tuple[ct.SelectorAttribute, ...]) -> bool:
|
|
"""Match attributes."""
|
|
|
|
match = True
|
|
if attributes:
|
|
for a in attributes:
|
|
temp = self.match_attribute_name(el, a.attribute, a.prefix)
|
|
pattern = a.xml_type_pattern if self.is_xml and a.xml_type_pattern else a.pattern
|
|
if temp is None:
|
|
match = False
|
|
break
|
|
value = temp if isinstance(temp, str) else ' '.join(temp)
|
|
if pattern is None:
|
|
continue
|
|
elif pattern.match(value) is None:
|
|
match = False
|
|
break
|
|
return match
|
|
|
|
def match_tagname(self, el: bs4.Tag, tag: ct.SelectorTag) -> bool:
|
|
"""Match tag name."""
|
|
|
|
name = (util.lower(tag.name) if not self.is_xml and tag.name is not None else tag.name)
|
|
return not (
|
|
name is not None and
|
|
name not in (self.get_tag(el), '*')
|
|
)
|
|
|
|
def match_tag(self, el: bs4.Tag, tag: ct.SelectorTag | None) -> bool:
|
|
"""Match the tag."""
|
|
|
|
match = True
|
|
if tag is not None:
|
|
# Verify namespace
|
|
if not self.match_tagname(el, tag):
|
|
match = False
|
|
if match and not self.match_namespace(el, tag):
|
|
match = False
|
|
return match
|
|
|
|
def match_general_sibling(self, el: bs4.Tag, relation: ct.SelectorList) -> bool:
|
|
"""Match general sibling combinator."""
|
|
|
|
found = False
|
|
|
|
if relation[0] is ct.Null: # pragma: no cover
|
|
return found
|
|
|
|
pkey: tuple[str | None, int] | None = None
|
|
key: tuple[ct.SelectorList, int] | None = None
|
|
|
|
# Setup the cache by the parent if present
|
|
parent = self.get_parent(el)
|
|
if parent is None: # pragma: no cover
|
|
return found
|
|
|
|
if parent:
|
|
pkey = (parent.name, id(parent))
|
|
|
|
# Initialize the cache if necessary
|
|
if pkey not in self.sib_cache:
|
|
self.sib_cache[pkey] = {}
|
|
|
|
# Check the cache to see if we already know where the first sibling is,
|
|
# and if we do, check if we are on the correct side of it.
|
|
# If we've previously searched and found no sibling, there is no sibling.
|
|
# Lastly, if this is our first time, setup the cache.
|
|
reverse = relation[0].rel_type == REL_HAS_SIBLING
|
|
start = len(parent) - 1 if reverse else 0
|
|
if pkey:
|
|
key = (relation, id(relation))
|
|
if key in self.sib_cache[pkey]:
|
|
index = self.sib_cache[pkey][key]
|
|
if index >= 0:
|
|
a, b = (index, start) if reverse else (start, index)
|
|
return not within(el, parent, a, b)
|
|
else:
|
|
return False
|
|
self.sib_cache[pkey][key] = start
|
|
|
|
# Start at the furthest endpoint and walk back towards the element looking for siblings.
|
|
# The current element counts as a sibling, but will not cause a match.
|
|
passed = False
|
|
incr = -1 if reverse else 1
|
|
for child in self.get_children(parent, start=start, reverse=reverse):
|
|
start += incr
|
|
if not isinstance(child, bs4.Tag):
|
|
continue
|
|
|
|
# Flag that we are passing the element.
|
|
# Any siblings we find aren't valid for this element.
|
|
if child is el:
|
|
passed = True
|
|
|
|
# We found the furthest sibling.
|
|
if self.match_selectors(child, relation):
|
|
found = True
|
|
break
|
|
|
|
# Cache the index of the sibling or mark as there being no siblings.
|
|
if pkey and key:
|
|
self.sib_cache[pkey][key] = start if found else -1
|
|
|
|
# If we passed the current element and then found a sibling, it doesn't count as a match.
|
|
if passed:
|
|
found = False
|
|
|
|
return found
|
|
|
|
def match_past_relations(self, el: bs4.Tag, relation: ct.SelectorList) -> bool:
|
|
"""Match past relationship."""
|
|
|
|
found = False
|
|
# I don't think this can ever happen, but it makes `mypy` happy
|
|
if relation[0] is ct.Null: # pragma: no cover
|
|
return found
|
|
|
|
if relation[0].rel_type == REL_PARENT:
|
|
parent: bs4.Tag | None = el
|
|
while not found and parent and (parent := self.get_parent(parent, no_iframe=self.iframe_restrict)):
|
|
found = parent is not None and self.match_selectors(parent, relation)
|
|
elif relation[0].rel_type == REL_CLOSE_PARENT:
|
|
parent = self.get_parent(el, no_iframe=self.iframe_restrict)
|
|
found = parent is not None and self.match_selectors(parent, relation)
|
|
elif relation[0].rel_type == REL_SIBLING:
|
|
if self.enable_cache:
|
|
found = self.match_general_sibling(el, relation)
|
|
else:
|
|
sibling: bs4.Tag | None = el
|
|
while not found and sibling and (sibling := self.get_previous_tag(sibling)):
|
|
found = sibling is not None and self.match_selectors(sibling, relation)
|
|
elif relation[0].rel_type == REL_CLOSE_SIBLING:
|
|
sibling = self.get_previous_tag(el)
|
|
found = sibling is not None and self.match_selectors(sibling, relation)
|
|
return found
|
|
|
|
def match_future_child(self, parent: bs4.Tag, relation: ct.SelectorList, recursive: bool = False) -> bool:
|
|
"""Match future child."""
|
|
|
|
match = False
|
|
if recursive:
|
|
children = self.get_tag_descendants # type: Callable[..., Iterator[bs4.Tag]]
|
|
else:
|
|
children = self.get_tag_children
|
|
for child in children(parent, no_iframe=self.iframe_restrict):
|
|
if self.match_selectors(child, relation):
|
|
match = True
|
|
break
|
|
return match
|
|
|
|
def match_future_relations(self, el: bs4.Tag, relation: ct.SelectorList) -> bool:
|
|
"""Match future relationship."""
|
|
|
|
found = False
|
|
# I don't think this can ever happen, but it makes `mypy` happy
|
|
if relation[0] is ct.Null: # pragma: no cover
|
|
return found
|
|
|
|
if relation[0].rel_type == REL_HAS_PARENT:
|
|
found = self.match_future_child(el, relation, True)
|
|
elif relation[0].rel_type == REL_HAS_CLOSE_PARENT:
|
|
found = self.match_future_child(el, relation)
|
|
elif relation[0].rel_type == REL_HAS_SIBLING:
|
|
if self.enable_cache:
|
|
found = self.match_general_sibling(el, relation)
|
|
else:
|
|
sibling: bs4.Tag | None = el
|
|
while not found and sibling and (sibling := self.get_next_tag(sibling)):
|
|
found = self.match_selectors(sibling, relation)
|
|
elif relation[0].rel_type == REL_HAS_CLOSE_SIBLING:
|
|
sibling = self.get_next_tag(el)
|
|
found = sibling is not None and self.match_selectors(sibling, relation)
|
|
return found
|
|
|
|
def match_relations(self, el: bs4.Tag, relation: ct.SelectorList) -> bool:
|
|
"""Match relationship to other elements."""
|
|
|
|
found = False
|
|
|
|
if relation[0] is ct.Null or relation[0].rel_type is None:
|
|
return found
|
|
|
|
if relation[0].rel_type.startswith(':'):
|
|
found = self.match_future_relations(el, relation)
|
|
else:
|
|
found = self.match_past_relations(el, relation)
|
|
|
|
return found
|
|
|
|
def match_id(self, el: bs4.Tag, ids: tuple[str, ...]) -> bool:
|
|
"""Match element's ID."""
|
|
|
|
found = True
|
|
for i in ids:
|
|
if i != self.get_attribute_by_name(el, 'id', ''):
|
|
found = False
|
|
break
|
|
return found
|
|
|
|
def match_classes(self, el: bs4.Tag, classes: tuple[str, ...]) -> bool:
|
|
"""Match element's classes."""
|
|
|
|
current_classes = self.get_classes(el)
|
|
found = True
|
|
for c in classes:
|
|
if c not in current_classes:
|
|
found = False
|
|
break
|
|
return found
|
|
|
|
def match_root(self, el: bs4.Tag) -> bool:
|
|
"""Match element as root."""
|
|
|
|
is_root = self.is_root(el)
|
|
if is_root:
|
|
sibling = self.get_previous(el) # type: Any
|
|
while is_root and sibling is not None:
|
|
if (
|
|
self.is_tag(sibling) or (self.is_content_string(sibling) and sibling.strip()) or
|
|
self.is_cdata(sibling)
|
|
):
|
|
is_root = False
|
|
else:
|
|
sibling = self.get_previous(sibling)
|
|
if is_root:
|
|
sibling = self.get_next(el)
|
|
while is_root and sibling is not None:
|
|
if (
|
|
self.is_tag(sibling) or (self.is_content_string(sibling) and sibling.strip()) or
|
|
self.is_cdata(sibling)
|
|
):
|
|
is_root = False
|
|
else:
|
|
sibling = self.get_next(sibling)
|
|
return is_root
|
|
|
|
def match_scope(self, el: bs4.Tag) -> bool:
|
|
"""Match element as scope."""
|
|
|
|
return self.scope is el
|
|
|
|
def match_nth_tag_type(self, el: bs4.Tag, child: bs4.Tag) -> bool:
|
|
"""Match tag type for `nth` matches."""
|
|
|
|
return (
|
|
(self.get_tag(child) == self.get_tag(el)) and
|
|
(self.get_tag_ns(child) == self.get_tag_ns(el))
|
|
)
|
|
|
|
def match_nth(self, el: bs4.Tag, nth: tuple[ct.SelectorNth, ...]) -> bool:
|
|
"""Match `nth` elements."""
|
|
|
|
# `nth` selectors are evaluated against siblings under the same parent.
|
|
parent = self.get_parent(el) # type: bs4.Tag | None
|
|
pkey: tuple[str | None, int] | None = None
|
|
key: tuple[ct.SelectorNth, int, str | None, str | None] | None = None
|
|
start = rindex = 0
|
|
incr = rincr = 0
|
|
|
|
# Setup the cache by the parent, if parent a parent is present
|
|
if self.enable_cache and parent:
|
|
pkey = (parent.name, id(parent))
|
|
|
|
# Initialize the cache if necessary
|
|
if pkey not in self.nth_cache:
|
|
self.nth_cache[pkey] = {}
|
|
|
|
# Test element against the `nth` selectors.
|
|
matched = True
|
|
for n in nth:
|
|
matched = False
|
|
last = n.last
|
|
key = None
|
|
|
|
# Prepare the child iterator and get the starting, real index and the relative index
|
|
if pkey and parent:
|
|
# Get last info from the cache
|
|
key = (n, id(n), self.get_tag(el), self.get_tag_ns(el)) if n.of_type else (n, id(n), None, None)
|
|
valid = False
|
|
if key in self.nth_cache[pkey]:
|
|
start, rindex = self.nth_cache[pkey][key]
|
|
if within(el, parent, start):
|
|
last = False
|
|
rincr = -1 if n.last else 1
|
|
valid = True
|
|
|
|
# Start/overwrite the cache if the cache was empty or invalid
|
|
if not valid:
|
|
start, rindex = len(parent) - 1 if last else 0, 0
|
|
self.nth_cache[pkey][key] = [start, rindex]
|
|
rincr = 1
|
|
|
|
incr = 1 if not last else -1
|
|
children = self.get_children(parent, start=start, reverse=last)
|
|
|
|
# Non-cached handling of parented element
|
|
elif parent:
|
|
rindex = 0
|
|
start = len(parent) - 1 if last else 0
|
|
rincr = incr = 1
|
|
children = self.get_children(parent, start=start, reverse=last)
|
|
|
|
# No parent, just evaluate the element against the selectors
|
|
else:
|
|
start = rindex = 0
|
|
rincr = incr = 1
|
|
children = iter([el])
|
|
|
|
# Find index of element compared to its siblings and check the index conditions
|
|
child: bs4.Tag
|
|
for child in children:
|
|
start += incr
|
|
|
|
# We only care about tags
|
|
if not self.is_tag(child):
|
|
continue
|
|
|
|
# Handle `of S` in `nth-child` and handle `of-type`
|
|
if (
|
|
(n.selectors and not self.match_selectors(child, n.selectors)) or
|
|
(n.of_type and not self.match_nth_tag_type(el, child))
|
|
):
|
|
if child is el:
|
|
break
|
|
continue
|
|
|
|
# Test the relative index against the `nth` requirement.
|
|
rindex += rincr
|
|
if child is el:
|
|
if n.a != 0:
|
|
v = (rindex - n.b) / n.a
|
|
matched = v.is_integer() and v >= 0
|
|
else:
|
|
matched = rindex == n.b and n.b >= 1
|
|
break
|
|
|
|
# "Last index" selectors evaluate first from the bottom and then evaluate
|
|
# from the first found element top-down. Start will be incremented in the
|
|
# wrong direction, so increment it and step over the current index.
|
|
if last:
|
|
start += 2
|
|
|
|
# Update the cache
|
|
if pkey and key:
|
|
self.nth_cache[pkey][key] = [start, rindex]
|
|
|
|
# If we failed to match any `nth` selectors, quit.
|
|
if not matched:
|
|
break
|
|
|
|
return matched
|
|
|
|
def match_empty(self, el: bs4.Tag) -> bool:
|
|
"""Check if element is empty (if requested)."""
|
|
|
|
is_empty = True
|
|
for child in self.get_children(el):
|
|
if self.is_tag(child):
|
|
is_empty = False
|
|
break
|
|
elif self.is_content_string(child) and RE_NOT_EMPTY.search(child): # type: ignore[call-overload]
|
|
is_empty = False
|
|
break
|
|
return is_empty
|
|
|
|
def match_subselectors(self, el: bs4.Tag, selectors: tuple[ct.SelectorList, ...]) -> bool:
|
|
"""Match selectors."""
|
|
|
|
match = True
|
|
for sel in selectors:
|
|
if not self.match_selectors(el, sel):
|
|
match = False
|
|
return match
|
|
|
|
def match_contains(self, el: bs4.Tag, contains: tuple[ct.SelectorContains, ...]) -> bool:
|
|
"""Match element if it contains text."""
|
|
|
|
match = True
|
|
content = None # type: str | Sequence[str] | None
|
|
for contain_list in contains:
|
|
if content is None:
|
|
if contain_list.own:
|
|
content = self.get_own_text(el, no_iframe=self.is_html)
|
|
else:
|
|
content = self.get_text(el, no_iframe=self.is_html)
|
|
found = False
|
|
for text in contain_list.text:
|
|
if contain_list.own:
|
|
for c in content:
|
|
if text in c:
|
|
found = True
|
|
break
|
|
if found:
|
|
break
|
|
else:
|
|
if text in content:
|
|
found = True
|
|
break
|
|
if not found:
|
|
match = False
|
|
return match
|
|
|
|
def match_default(self, el: bs4.Tag) -> bool:
|
|
"""Match default."""
|
|
|
|
match = False
|
|
|
|
# Find this input's form
|
|
form = None # type: bs4.Tag | None
|
|
parent = self.get_parent(el, no_iframe=True)
|
|
while parent and form is None:
|
|
if self.get_tag(parent) == 'form' and self.is_html_tag(parent):
|
|
form = parent
|
|
else:
|
|
parent = self.get_parent(parent, no_iframe=True)
|
|
|
|
if form is not None:
|
|
# Look in form cache to see if we've already located its default button
|
|
found_form = False
|
|
for f, t in self.cached_default_forms:
|
|
if f is form:
|
|
found_form = True
|
|
if t is el:
|
|
match = True
|
|
break
|
|
|
|
# We didn't have the form cached, so look for its default button
|
|
if not found_form:
|
|
for child in self.get_tag_descendants(form, no_iframe=True):
|
|
name = self.get_tag(child)
|
|
# Can't do nested forms (haven't figured out why we never hit this)
|
|
if name == 'form': # pragma: no cover
|
|
break
|
|
if name in ('input', 'button'):
|
|
v = self.get_attribute_by_name(child, 'type', '')
|
|
if v and util.lower(v) == 'submit':
|
|
self.cached_default_forms.append((form, child))
|
|
if el is child:
|
|
match = True
|
|
break
|
|
return match
|
|
|
|
def match_indeterminate(self, el: bs4.Tag) -> bool:
|
|
"""Match default."""
|
|
|
|
match = False
|
|
name = cast(str, self.get_attribute_by_name(el, 'name'))
|
|
|
|
def get_parent_form(el: bs4.Tag) -> bs4.Tag | None:
|
|
"""Find this input's form."""
|
|
form = None
|
|
parent = self.get_parent(el, no_iframe=True)
|
|
while form is None:
|
|
if self.get_tag(parent) == 'form' and self.is_html_tag(parent):
|
|
form = parent
|
|
break
|
|
last_parent = parent
|
|
parent = self.get_parent(parent, no_iframe=True)
|
|
if parent is None:
|
|
form = last_parent
|
|
break
|
|
return form
|
|
|
|
form = get_parent_form(el)
|
|
|
|
# Look in form cache to see if we've already evaluated that its fellow radio buttons are indeterminate
|
|
if form is not None:
|
|
found_form = False
|
|
for f, n, i in self.cached_indeterminate_forms:
|
|
if f is form and n == name:
|
|
found_form = True
|
|
if i is True:
|
|
match = True
|
|
break
|
|
|
|
# We didn't have the form cached, so validate that the radio button is indeterminate
|
|
if not found_form:
|
|
checked = False
|
|
for child in self.get_tag_descendants(form, no_iframe=True):
|
|
if child is el:
|
|
continue
|
|
tag_name = self.get_tag(child)
|
|
if tag_name == 'input':
|
|
is_radio = False
|
|
check = False
|
|
has_name = False
|
|
for k, v in self.iter_attributes(child):
|
|
if util.lower(k) == 'type' and util.lower(v) == 'radio':
|
|
is_radio = True
|
|
elif util.lower(k) == 'name' and v == name:
|
|
has_name = True
|
|
elif util.lower(k) == 'checked':
|
|
check = True
|
|
if is_radio and check and has_name and get_parent_form(child) is form:
|
|
checked = True
|
|
break
|
|
if checked:
|
|
break
|
|
if not checked:
|
|
match = True
|
|
self.cached_indeterminate_forms.append((form, name, match))
|
|
|
|
return match
|
|
|
|
def match_lang(self, el: bs4.Tag, langs: tuple[ct.SelectorLang, ...]) -> bool:
|
|
"""Match languages."""
|
|
|
|
match = False
|
|
has_ns = self.supports_namespaces()
|
|
root = self.root
|
|
has_html_namespace = self.has_html_namespace
|
|
|
|
# Walk parents looking for `lang` (HTML) or `xml:lang` XML property.
|
|
parent = el # type: bs4.Tag | None
|
|
found_lang = None
|
|
last = None
|
|
while not found_lang:
|
|
has_html_ns = self.has_html_ns(parent)
|
|
for k, v in self.iter_attributes(parent):
|
|
attr_ns, attr = self.split_namespace(parent, k)
|
|
if (
|
|
((not has_ns or has_html_ns) and (util.lower(k) if not self.is_xml else k) == 'lang') or
|
|
(
|
|
has_ns and not has_html_ns and attr_ns == NS_XML and
|
|
(util.lower(attr) if not self.is_xml and attr is not None else attr) == 'lang'
|
|
)
|
|
):
|
|
found_lang = v
|
|
break
|
|
last = parent
|
|
parent = self.get_parent(parent, no_iframe=self.is_html)
|
|
|
|
if parent is None:
|
|
root = last
|
|
has_html_namespace = self.has_html_ns(root)
|
|
parent = last
|
|
break
|
|
|
|
# Use cached meta language.
|
|
if found_lang is None and self.cached_meta_lang:
|
|
for cache in self.cached_meta_lang:
|
|
if root is not None and cast(str, root) is cache[0]:
|
|
found_lang = cache[1]
|
|
|
|
# If we couldn't find a language, and the document is HTML, look to meta to determine language.
|
|
if found_lang is None and (not self.is_xml or (has_html_namespace and root and root.name == 'html')):
|
|
# Find head
|
|
found = False
|
|
for tag in ('html', 'head'):
|
|
found = False
|
|
for child in self.get_tag_children(parent, no_iframe=self.is_html):
|
|
if self.get_tag(child) == tag and self.is_html_tag(child):
|
|
found = True
|
|
parent = child
|
|
break
|
|
if not found: # pragma: no cover
|
|
break
|
|
|
|
# Search meta tags
|
|
if found and parent is not None:
|
|
for child2 in parent:
|
|
if isinstance(child2, bs4.Tag) and self.get_tag(child2) == 'meta' and self.is_html_tag(parent):
|
|
c_lang = False
|
|
content = None
|
|
for k, v in self.iter_attributes(child2):
|
|
if util.lower(k) == 'http-equiv' and util.lower(v) == 'content-language':
|
|
c_lang = True
|
|
if util.lower(k) == 'content':
|
|
content = v
|
|
if c_lang and content:
|
|
found_lang = content
|
|
self.cached_meta_lang.append((cast(str, root), cast(str, found_lang)))
|
|
break
|
|
if found_lang is not None:
|
|
break
|
|
if found_lang is None:
|
|
self.cached_meta_lang.append((cast(str, root), ''))
|
|
|
|
# If we determined a language, compare.
|
|
if found_lang is not None:
|
|
for patterns in langs:
|
|
match = False
|
|
for pattern in patterns:
|
|
if self.extended_language_filter(pattern, cast(str, found_lang)):
|
|
match = True
|
|
if not match:
|
|
break
|
|
|
|
return match
|
|
|
|
def match_dir(self, el: bs4.Tag | None, directionality: int) -> bool:
|
|
"""Check directionality."""
|
|
|
|
# If we have to match both left and right, we can't match either.
|
|
if directionality & ct.SEL_DIR_LTR and directionality & ct.SEL_DIR_RTL:
|
|
return False
|
|
|
|
if el is None or not self.is_html_tag(el):
|
|
return False
|
|
|
|
# Element has defined direction of left to right or right to left
|
|
direction = DIR_MAP.get(util.lower(self.get_attribute_by_name(el, 'dir', '')), None)
|
|
if direction not in (None, 0):
|
|
return direction == directionality
|
|
|
|
# Element is the document element (the root) and no direction assigned, assume left to right.
|
|
is_root = self.is_root(el)
|
|
if is_root and direction is None:
|
|
return ct.SEL_DIR_LTR == directionality
|
|
|
|
# If `input[type=telephone]` and no direction is assigned, assume left to right.
|
|
name = self.get_tag(el)
|
|
is_input = name == 'input'
|
|
is_textarea = name == 'textarea'
|
|
is_bdi = name == 'bdi'
|
|
itype = util.lower(self.get_attribute_by_name(el, 'type', '')) if is_input else ''
|
|
if is_input and itype == 'tel' and direction is None:
|
|
return ct.SEL_DIR_LTR == directionality
|
|
|
|
# Auto handling for text inputs
|
|
if ((is_input and itype in ('text', 'search', 'tel', 'url', 'email')) or is_textarea) and direction == 0:
|
|
if is_textarea:
|
|
value = ''.join(node for node in self.get_contents(el, no_iframe=True) if self.is_content_string(node)) # type: ignore[misc]
|
|
else:
|
|
value = cast(str, self.get_attribute_by_name(el, 'value', ''))
|
|
if value:
|
|
for c in value:
|
|
bidi = unicodedata.bidirectional(c)
|
|
if bidi in ('AL', 'R', 'L'):
|
|
direction = ct.SEL_DIR_LTR if bidi == 'L' else ct.SEL_DIR_RTL
|
|
return direction == directionality
|
|
# Assume left to right
|
|
return ct.SEL_DIR_LTR == directionality
|
|
elif is_root:
|
|
return ct.SEL_DIR_LTR == directionality
|
|
return self.match_dir(self.get_parent(el, no_iframe=True), directionality)
|
|
|
|
# Auto handling for `bdi` and other non text inputs.
|
|
if (is_bdi and direction is None) or direction == 0:
|
|
direction = self.find_bidi(el)
|
|
if direction is not None:
|
|
return direction == directionality
|
|
elif is_root:
|
|
return ct.SEL_DIR_LTR == directionality
|
|
return self.match_dir(self.get_parent(el, no_iframe=True), directionality)
|
|
|
|
# Match parents direction
|
|
return self.match_dir(self.get_parent(el, no_iframe=True), directionality)
|
|
|
|
def match_range(self, el: bs4.Tag, condition: int) -> bool:
|
|
"""
|
|
Match range.
|
|
|
|
Behavior is modeled after what we see in browsers. Browsers seem to evaluate
|
|
if the value is out of range, and if not, it is in range. So a missing value
|
|
will not evaluate out of range; therefore, value is in range. Personally, I
|
|
feel like this should evaluate as neither in or out of range.
|
|
"""
|
|
|
|
out_of_range = False
|
|
|
|
itype = util.lower(self.get_attribute_by_name(el, 'type'))
|
|
mn = Inputs.parse_value(itype, cast(str, self.get_attribute_by_name(el, 'min', None)))
|
|
mx = Inputs.parse_value(itype, cast(str, self.get_attribute_by_name(el, 'max', None)))
|
|
|
|
# There is no valid min or max, so we cannot evaluate a range
|
|
if mn is None and mx is None:
|
|
return False
|
|
|
|
value = Inputs.parse_value(itype, cast(str, self.get_attribute_by_name(el, 'value', None)))
|
|
if value is not None:
|
|
if itype in ("date", "datetime-local", "month", "week", "number", "range"):
|
|
if mn is not None and value < mn:
|
|
out_of_range = True
|
|
if not out_of_range and mx is not None and value > mx:
|
|
out_of_range = True
|
|
elif itype == "time":
|
|
if mn is not None and mx is not None and mn > mx:
|
|
# Time is periodic, so this is a reversed/discontinuous range
|
|
if value < mn and value > mx:
|
|
out_of_range = True
|
|
else:
|
|
if mn is not None and value < mn:
|
|
out_of_range = True
|
|
if not out_of_range and mx is not None and value > mx:
|
|
out_of_range = True
|
|
|
|
return not out_of_range if condition & ct.SEL_IN_RANGE else out_of_range
|
|
|
|
def match_defined(self, el: bs4.Tag) -> bool:
|
|
"""
|
|
Match defined.
|
|
|
|
`:defined` is related to custom elements in a browser.
|
|
|
|
- If the document is XML (not XHTML), all tags will match.
|
|
- Tags that are not custom (don't have a hyphen) are marked defined.
|
|
- If the tag has a prefix (without or without a namespace), it will not match.
|
|
|
|
This is of course requires the parser to provide us with the proper prefix and namespace info,
|
|
if it doesn't, there is nothing we can do.
|
|
"""
|
|
|
|
name = self.get_tag(el)
|
|
return (
|
|
name is not None and (
|
|
name.find('-') == -1 or
|
|
name.find(':') != -1 or
|
|
self.get_prefix(el) is not None
|
|
)
|
|
)
|
|
|
|
def match_placeholder_shown(self, el: bs4.Tag) -> bool:
|
|
"""
|
|
Match placeholder shown according to HTML spec.
|
|
|
|
- text area should be checked if they have content. A single newline does not count as content.
|
|
|
|
"""
|
|
|
|
match = False
|
|
content = self.get_text(el)
|
|
if content in ('', '\n'):
|
|
match = True
|
|
|
|
return match
|
|
|
|
def match_selectors(self, el: bs4.Tag, selectors: ct.SelectorList) -> bool:
|
|
"""Check if element matches one of the selectors."""
|
|
|
|
match = False
|
|
is_not = selectors.is_not
|
|
is_html = selectors.is_html
|
|
|
|
# Internal selector lists that use the HTML flag, will automatically get the `html` namespace.
|
|
if is_html:
|
|
namespaces = self.namespaces
|
|
iframe_restrict = self.iframe_restrict
|
|
self.namespaces = {'html': NS_XHTML}
|
|
self.iframe_restrict = True
|
|
|
|
if not is_html or self.is_html:
|
|
for selector in selectors:
|
|
match = is_not
|
|
# We have a un-matchable situation (like `:focus` as you can focus an element in this environment)
|
|
if selector is ct.Null:
|
|
continue
|
|
# Verify tag matches
|
|
if not self.match_tag(el, selector.tag):
|
|
continue
|
|
# Verify tag is defined
|
|
if selector.flags & ct.SEL_DEFINED and not self.match_defined(el):
|
|
continue
|
|
# Verify element is root
|
|
if selector.flags & ct.SEL_ROOT and not self.match_root(el):
|
|
continue
|
|
# Verify element is scope
|
|
if selector.flags & ct.SEL_SCOPE and not self.match_scope(el):
|
|
continue
|
|
# Verify element has placeholder shown
|
|
if selector.flags & ct.SEL_PLACEHOLDER_SHOWN and not self.match_placeholder_shown(el):
|
|
continue
|
|
# Verify `nth` matches
|
|
if selector.nth and not self.match_nth(el, selector.nth):
|
|
continue
|
|
if selector.flags & ct.SEL_EMPTY and not self.match_empty(el):
|
|
continue
|
|
# Verify id matches
|
|
if selector.ids and not self.match_id(el, selector.ids):
|
|
continue
|
|
# Verify classes match
|
|
if selector.classes and not self.match_classes(el, selector.classes):
|
|
continue
|
|
# Verify attribute(s) match
|
|
if not self.match_attributes(el, selector.attributes):
|
|
continue
|
|
# Verify ranges
|
|
if selector.flags & RANGES and not self.match_range(el, selector.flags & RANGES):
|
|
continue
|
|
# Verify language patterns
|
|
if selector.lang and not self.match_lang(el, selector.lang):
|
|
continue
|
|
# Verify pseudo selector patterns
|
|
if selector.selectors and not self.match_subselectors(el, selector.selectors):
|
|
continue
|
|
# Verify relationship selectors
|
|
if selector.relation and not self.match_relations(el, selector.relation):
|
|
continue
|
|
# Validate that the current default selector match corresponds to the first submit button in the form
|
|
if selector.flags & ct.SEL_DEFAULT and not self.match_default(el):
|
|
continue
|
|
# Validate that the unset radio button is among radio buttons with the same name in a form that are
|
|
# also not set.
|
|
if selector.flags & ct.SEL_INDETERMINATE and not self.match_indeterminate(el):
|
|
continue
|
|
# Validate element directionality
|
|
if selector.flags & DIR_FLAGS and not self.match_dir(el, selector.flags & DIR_FLAGS):
|
|
continue
|
|
# Validate that the tag contains the specified text.
|
|
if selector.contains and not self.match_contains(el, selector.contains):
|
|
continue
|
|
match = not is_not
|
|
break
|
|
|
|
# Restore actual namespaces being used for external selector lists
|
|
if is_html:
|
|
self.namespaces = namespaces
|
|
self.iframe_restrict = iframe_restrict
|
|
|
|
return match
|
|
|
|
def select(self, limit: int = 0) -> Iterator[bs4.Tag]:
|
|
"""Match all tags under the targeted tag."""
|
|
|
|
lim = None if limit < 1 else limit
|
|
|
|
for child in self.get_tag_descendants(self.tag):
|
|
if self.match(child):
|
|
yield child
|
|
if lim is not None:
|
|
lim -= 1
|
|
if lim < 1:
|
|
break
|
|
|
|
def closest(self) -> bs4.Tag | None:
|
|
"""Match closest ancestor."""
|
|
|
|
current = self.tag # type: bs4.Tag | None
|
|
closest = None
|
|
while closest is None and current is not None:
|
|
if self.match(current):
|
|
closest = current
|
|
else:
|
|
current = self.get_parent(current)
|
|
return closest
|
|
|
|
def filter(self) -> list[bs4.Tag]: # noqa A001
|
|
"""Filter tag's children."""
|
|
|
|
return [
|
|
tag for tag in self.get_contents(self.tag)
|
|
if isinstance(tag, bs4.Tag) and self.match(tag)
|
|
]
|
|
|
|
def match(self, el: bs4.Tag) -> bool:
|
|
"""Match."""
|
|
|
|
return not self.is_doc(el) and self.is_tag(el) and self.match_selectors(el, self.selectors)
|
|
|
|
|
|
class SoupSieve(ct.Immutable):
|
|
"""Compiled Soup Sieve selector matching object."""
|
|
|
|
pattern: str
|
|
selectors: ct.SelectorList
|
|
namespaces: ct.Namespaces | None
|
|
custom: dict[str, str]
|
|
flags: int
|
|
|
|
__slots__ = ("pattern", "selectors", "namespaces", "custom", "flags", "_hash")
|
|
|
|
def __init__(
|
|
self,
|
|
pattern: str,
|
|
selectors: ct.SelectorList,
|
|
namespaces: ct.Namespaces | None,
|
|
custom: ct.CustomSelectors | None,
|
|
flags: int
|
|
):
|
|
"""Initialize."""
|
|
|
|
super().__init__(
|
|
pattern=pattern,
|
|
selectors=selectors,
|
|
namespaces=namespaces,
|
|
custom=custom,
|
|
flags=flags
|
|
)
|
|
|
|
def match(self, tag: bs4.Tag) -> bool:
|
|
"""Match."""
|
|
|
|
return CSSMatch(self.selectors, tag, self.namespaces, self.flags).match(tag)
|
|
|
|
def closest(self, tag: bs4.Tag) -> bs4.Tag | None:
|
|
"""Match closest ancestor."""
|
|
|
|
return CSSMatch(self.selectors, tag, self.namespaces, self.flags).closest()
|
|
|
|
def filter(self, iterable: Iterable[bs4.Tag]) -> list[bs4.Tag]: # noqa A001
|
|
"""
|
|
Filter.
|
|
|
|
`CSSMatch` can cache certain searches for tags of the same document,
|
|
so if we are given a tag, all tags are from the same document,
|
|
and we can take advantage of the optimization.
|
|
|
|
Any other kind of iterable could have tags from different documents or detached tags,
|
|
so for those, we use a new `CSSMatch` for each item in the iterable.
|
|
"""
|
|
|
|
if isinstance(iterable, bs4.Tag):
|
|
return CSSMatch(self.selectors, iterable, self.namespaces, self.flags).filter()
|
|
else:
|
|
# There is no guarantee that elements are from the same document, evaluate them separately.
|
|
return [node for node in iterable if not CSSMatch.is_navigable_string(node) and self.match(node)]
|
|
|
|
def select_one(self, tag: bs4.Tag) -> bs4.Tag | None:
|
|
"""Select a single tag."""
|
|
|
|
tags = self.select(tag, limit=1)
|
|
return tags[0] if tags else None
|
|
|
|
def select(self, tag: bs4.Tag, limit: int = 0) -> list[bs4.Tag]:
|
|
"""Select the specified tags."""
|
|
|
|
return list(self.iselect(tag, limit))
|
|
|
|
def iselect(self, tag: bs4.Tag, limit: int = 0) -> Iterator[bs4.Tag]:
|
|
"""Iterate the specified tags."""
|
|
|
|
yield from CSSMatch(self.selectors, tag, self.namespaces, self.flags).select(limit)
|
|
|
|
def __repr__(self) -> str: # pragma: no cover
|
|
"""Representation."""
|
|
|
|
return (
|
|
f"SoupSieve(pattern={self.pattern!r}, namespaces={self.namespaces!r}, "
|
|
f"custom={self.custom!r}, flags={self.flags!r})"
|
|
)
|
|
|
|
__str__ = __repr__
|
|
|
|
|
|
ct.pickle_register(SoupSieve)
|