Files
marcocosta10000 723ca1506b feat(agent): scrape linked pages to ground answers in real content
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.
2026-10-26 16:48:29 -03:00

1379 lines
48 KiB
Python

"""CSS selector parser."""
from __future__ import annotations
import re
from functools import lru_cache
from . import util
from . import css_match as cm
from . import css_types as ct
from .util import SelectorSyntaxError
import warnings
from typing import Match, Any, Iterator, Iterable, cast
from dataclasses import dataclass
from collections import UserDict
import threading
RE_LOCK = threading.Lock()
SEL_LOCK = threading.RLock()
UNICODE_REPLACEMENT_CHAR = 0xFFFD
SELECTOR_LIMIT = 8192
# Simple pseudo classes that take no parameters
PSEUDO_SIMPLE = {
":any-link",
":empty",
":first-child",
":first-of-type",
":in-range",
":open",
":out-of-range",
":last-child",
":last-of-type",
":link",
":only-child",
":only-of-type",
":root",
':checked',
':default',
':disabled',
':enabled',
':indeterminate',
':optional',
':placeholder-shown',
':read-only',
':read-write',
':required',
':scope',
':defined',
':muted'
}
# Supported, simple pseudo classes that match nothing in the Soup Sieve environment
PSEUDO_SIMPLE_NO_MATCH = {
':active',
':autofill',
':buffering',
':current',
':focus',
':focus-visible',
':focus-within',
':fullscreen',
':future',
':host',
':hover',
':local-link',
':past',
':paused',
':picture-in-picture',
':playing',
':popover-open',
':seeking',
':stalled',
':target',
':target-within',
':user-invalid',
':volume-locked',
':visited'
}
# Complex pseudo classes that take selector lists
PSEUDO_COMPLEX = {
':contains',
':-soup-contains',
':-soup-contains-own',
':has',
':is',
':matches',
':not',
':where'
}
PSEUDO_COMPLEX_NO_MATCH = {
':current',
':host',
':host-context'
}
# Complex pseudo classes that take very specific parameters and are handled special
PSEUDO_SPECIAL = {
':dir',
':lang',
':nth-child',
':nth-last-child',
':nth-last-of-type',
':nth-of-type'
}
PSEUDO_SUPPORTED = PSEUDO_SIMPLE | PSEUDO_SIMPLE_NO_MATCH | PSEUDO_COMPLEX | PSEUDO_COMPLEX_NO_MATCH | PSEUDO_SPECIAL
# Sub-patterns parts
# Whitespace
NEWLINE = r'(?:\r\n|(?!\r\n)[\n\f\r])'
WS = fr'(?:[ \t]|{NEWLINE})'
# Comments
COMMENTS = r'(?:/\*(?:[^*]|\*(?!/))*\*/)'
# Whitespace with comments included
WSC = fr'(?:{WS}|{COMMENTS})'
# CSS escapes
CSS_ESCAPES = fr'(?:\\(?:[a-f0-9]{{1,6}}{WS}?|[^\r\n\f]|$))'
CSS_STRING_ESCAPES = fr'(?:\\(?:[a-f0-9]{{1,6}}{WS}?|{NEWLINE}|[^\r\n\f]|$))'
# CSS Identifier
IDENTIFIER = fr'''
(?:(?:--|-?(?:[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f]|{CSS_ESCAPES}))
(?:[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f]|{CSS_ESCAPES})*)
'''
# `nth` content
NTH = fr'[-+]?(?:[0-9]+n?|n)(?:(?<=n){WSC}*[-+]{WSC}*[0-9]+)?'
# Value: quoted string or identifier
VALUE = fr'''
(?:"(?:\\(?:{NEWLINE}|[^\r\n\f])|[^\\"\r\n\f])*"|
'(?:\\(?:{NEWLINE}|[^\r\n\f])|[^\\'\r\n\f])*'|
{IDENTIFIER})
'''
# Attribute value comparison. `!=` is handled special as it is non-standard.
ATTR = fr'(?:{WSC}*(?P<cmp>[!~^|*$]?=){WSC}*(?P<value>{VALUE})(?:{WSC}*(?P<case>[is]))?)?{WSC}*'
# Selector patterns
# IDs (`#id`)
PAT_ID = fr'\#{IDENTIFIER}'
# Classes (`.class`)
PAT_CLASS = fr'\.{IDENTIFIER}'
# Prefix:Tag (`prefix|tag`)
PAT_TAG = fr'(?P<tag_ns>(?:{IDENTIFIER}|\*)?\|)?(?P<tag_name>{IDENTIFIER}|\*)'
# Attributes (`[attr]`, `[attr=value]`, etc.)
PAT_ATTR = fr'\[{WSC}*(?P<attr_ns>(?:{IDENTIFIER}|\*)?\|(?!=))?(?P<attr_name>{IDENTIFIER}){ATTR}{WSC}*\]'
# Pseudo class (`:pseudo-class`, `:pseudo-class(`)
PAT_PSEUDO_CLASS = fr'(?P<name>:{IDENTIFIER})(?P<open>\({WSC}*)?'
# Pseudo class special patterns. Matches `:pseudo-class(` for special case pseudo classes.
PAT_PSEUDO_CLASS_SPECIAL = fr'(?P<name>:{IDENTIFIER})(?P<open>\({WSC}*)'
# Custom pseudo class (`:--custom-pseudo`)
PAT_PSEUDO_CLASS_CUSTOM = fr'(?P<name>:(?=--){IDENTIFIER})'
# Nesting ampersand selector. Matches `&`
PAT_AMP = r'&'
# Closing pseudo group (`)`)
PAT_PSEUDO_CLOSE = fr'{WSC}*\)'
# Pseudo element (`::pseudo-element`)
PAT_PSEUDO_ELEMENT = fr':{PAT_PSEUDO_CLASS}'
# At rule (`@page`, etc.) (not supported)
PAT_AT_RULE = fr'@P{IDENTIFIER}'
# Pseudo class `nth-child` (`:nth-child(an+b [of S]?)`, `:first-child`, etc.)
PAT_PSEUDO_NTH_CHILD = fr'''
(?P<pseudo_nth_child>{PAT_PSEUDO_CLASS_SPECIAL}
(?P<nth_child>{NTH}|even|odd))(?:{WSC}*\)|(?P<of>{COMMENTS}*{WS}{WSC}*of{COMMENTS}*{WS}{WSC}*))
'''
# Pseudo class `nth-of-type` (`:nth-of-type(an+b)`, `:first-of-type`, etc.)
PAT_PSEUDO_NTH_TYPE = fr'''
(?P<pseudo_nth_type>{PAT_PSEUDO_CLASS_SPECIAL}
(?P<nth_type>{NTH}|even|odd)){WSC}*\)
'''
# Pseudo class language (`:lang("*-de", en)`)
PAT_PSEUDO_LANG = fr'{PAT_PSEUDO_CLASS_SPECIAL}(?P<values>{VALUE}(?:{WSC}*,{WSC}*{VALUE})*){WSC}*\)'
# Pseudo class direction (`:dir(ltr)`)
PAT_PSEUDO_DIR = fr'{PAT_PSEUDO_CLASS_SPECIAL}(?P<dir>ltr|rtl){WSC}*\)'
# Combining characters (`>`, `~`, ` `, `+`, `,`)
PAT_COMBINE = fr'{COMMENTS}*(?={WS}|[,+>~]){WSC}*(?P<relation>[,+>~])?{WSC}*'
# Extra: Contains (`:contains(text)`)
PAT_PSEUDO_CONTAINS = fr'{PAT_PSEUDO_CLASS_SPECIAL}(?P<values>{VALUE}(?:{WSC}*,{WSC}*{VALUE})*){WSC}*\)'
# Regular expressions
# CSS escape pattern
RE_CSS_ESC = re.compile(fr'(?:(\\[a-f0-9]{{1,6}}{WSC}?)|(\\[^\r\n\f])|(\\$))', re.I)
RE_CSS_STR_ESC = re.compile(fr'(?:(\\[a-f0-9]{{1,6}}{WS}?)|(\\[^\r\n\f])|(\\$)|(\\{NEWLINE}))', re.I)
# Pattern to break up `nth` specifiers
RE_NTH = re.compile(fr'(?P<s1>[-+])?(?P<a>[0-9]+n?|n)(?:(?<=n){WSC}*(?P<s2>[-+]){WSC}*(?P<b>[0-9]+))?', re.I)
# Pattern to iterate multiple values.
RE_VALUES = re.compile(fr'(?:(?P<value>{VALUE})|(?P<split>{WSC}*,{WSC}*))', re.X)
# Whitespace checks
RE_WS = re.compile(WS)
RE_WS_BEGIN = re.compile(fr'^{WSC}*')
RE_WS_END = re.compile(fr'^(?:[ \t]|(?:\n\r|(?!\n\r)[\n\f\r])|{COMMENTS})*')
RE_CUSTOM = re.compile(fr'^{PAT_PSEUDO_CLASS_CUSTOM}$', re.X)
RE_PSEUDO_CLASS_SPECIAL = re.compile(PAT_PSEUDO_CLASS_SPECIAL, re.I | re.X | re.U)
RE_PSEUDO_IGNORE = re.compile(PAT_PSEUDO_CLASS, re.X | re.I | re.U)
QUOTED = ("'", '"')
# Constants
# List split token
COMMA_COMBINATOR = ','
# Relation token for descendant
WS_COMBINATOR = " "
# Parse flags
FLG_PSEUDO = 0x01
FLG_NOT = 0x02
FLG_RELATIVE = 0x04
FLG_DEFAULT = 0x08
FLG_HTML = 0x10
FLG_INDETERMINATE = 0x20
FLG_OPEN = 0x40
FLG_IN_RANGE = 0x80
FLG_OUT_OF_RANGE = 0x100
FLG_PLACEHOLDER_SHOWN = 0x200
FLG_FORGIVE = 0x400
# Maximum cached patterns to store
_MAXCACHE = 500
@lru_cache(maxsize=_MAXCACHE)
def _cached_css_compile(
pattern: str,
namespaces: ct.Namespaces | None,
custom: ct.CustomSelectors | None,
ignore: tuple[str] | None = None,
flags: int = 0
) -> cm.SoupSieve:
"""Cached CSS compile."""
custom_selectors = process_custom(custom)
return cm.SoupSieve(
pattern,
CSSParser(
pattern,
custom=custom_selectors,
ignore=ignore,
flags=flags
).process_selectors(),
namespaces,
custom,
flags
)
def _purge_cache() -> None:
"""Purge the cache."""
_cached_css_compile.cache_clear()
def process_custom(custom: ct.CustomSelectors | None) -> dict[str, str | ct.SelectorList]:
"""Process custom."""
custom_selectors = {}
if custom is not None:
for key, value in custom.items():
name = util.lower(key)
if RE_CUSTOM.match(name) is None:
raise SelectorSyntaxError(f"The name '{name}' is not a valid custom pseudo-class name")
if name in custom_selectors:
raise KeyError(f"The custom selector '{name}' has already been registered")
custom_selectors[css_unescape(name)] = value
return custom_selectors
def css_unescape(content: str) -> str:
"""
Unescape CSS value.
Strings allow for spanning the value on multiple strings by escaping a new line.
"""
def replace(m: Match[str]) -> str:
"""Replace with the appropriate substitute."""
if m.group(1):
codepoint = int(m.group(1)[1:], 16)
if codepoint == 0:
codepoint = UNICODE_REPLACEMENT_CHAR
value = chr(codepoint)
elif m.group(2):
value = m.group(2)[1:]
elif m.group(3):
value = '\ufffd'
else:
value = ''
return value
quoted = content.startswith(QUOTED)
return (RE_CSS_STR_ESC if quoted else RE_CSS_ESC).sub(
replace,
content[1:-1] if quoted else content
)
def escape(ident: str) -> str:
"""Escape identifier."""
string = []
length = len(ident)
start_dash = length > 0 and ident[0] == '-'
if length == 1 and start_dash:
# Need to escape identifier that is a single `-` with no other characters
string.append(f'\\{ident}')
else:
for index, c in enumerate(ident):
codepoint = ord(c)
if codepoint == 0x00:
string.append('\ufffd')
elif (0x01 <= codepoint <= 0x1F) or codepoint == 0x7F:
string.append(f'\\{codepoint:x} ')
elif (index == 0 or (start_dash and index == 1)) and (0x30 <= codepoint <= 0x39):
string.append(f'\\{codepoint:x} ')
elif (
codepoint in (0x2D, 0x5F) or codepoint >= 0x80 or (0x30 <= codepoint <= 0x39) or
(0x30 <= codepoint <= 0x39) or (0x41 <= codepoint <= 0x5A) or (0x61 <= codepoint <= 0x7A)
):
string.append(c)
else:
string.append(f'\\{c}')
return ''.join(string)
class SelectorPattern:
"""Selector pattern."""
def __init__(self, name: str, pattern: str) -> None:
"""Initialize."""
self.name = name
self.pattern = pattern
self._re_pattern: re.Pattern[str] | None = None
@property
def re_pattern(self) -> re.Pattern[str]:
"""Retrieve the compiled regular expression pattern."""
with RE_LOCK:
if self._re_pattern is None:
self._re_pattern = re.compile(self.pattern, re.I | re.X | re.U)
return self._re_pattern
def get_name(self) -> str:
"""Get name."""
return self.name
def match(self, selector: str, index: int, flags: int) -> Match[str] | None:
"""Match the selector."""
return self.re_pattern.match(selector, index)
class SpecialPseudoPattern(SelectorPattern):
"""Selector pattern."""
def __init__(self, patterns: tuple[tuple[str, tuple[str, ...], str, type[SelectorPattern]], ...]) -> None:
"""Initialize."""
self.patterns = {}
for p in patterns:
name = p[0]
pattern = p[3](name, p[2])
for pseudo in p[1]:
self.patterns[pseudo] = pattern
self.matched_name = None # type: SelectorPattern | None
def get_name(self) -> str:
"""Get name."""
return '' if self.matched_name is None else self.matched_name.get_name()
def match(self, selector: str, index: int, flags: int) -> Match[str] | None:
"""Match the selector."""
pseudo = None
m = RE_PSEUDO_CLASS_SPECIAL.match(selector, index)
if m:
name = util.lower(css_unescape(m.group('name')))
pattern = self.patterns.get(name)
if pattern:
pseudo = pattern.match(selector, index, flags)
if pseudo:
self.matched_name = pattern
return pseudo
class _Selector:
"""
Intermediate selector class.
This stores selector data for a compound selector as we are acquiring them.
Once we are done collecting the data for a compound selector, we freeze
the data in an object that can be pickled and hashed.
"""
def __init__(self, **kwargs: Any) -> None:
"""Initialize."""
self.tag = kwargs.get('tag', None) # type: ct.SelectorTag | None
self.ids = kwargs.get('ids', []) # type: list[str]
self.classes = kwargs.get('classes', []) # type: list[str]
self.attributes = kwargs.get('attributes', []) # type: list[ct.SelectorAttribute]
self.nth = kwargs.get('nth', []) # type: list[ct.SelectorNth]
self.selectors = kwargs.get('selectors', []) # type: list[ct.SelectorList]
self.relations = kwargs.get('relations', []) # type: list[_Selector]
self.rel_type = kwargs.get('rel_type', None) # type: str | None
self.contains = kwargs.get('contains', []) # type: list[ct.SelectorContains]
self.lang = kwargs.get('lang', []) # type: list[ct.SelectorLang]
self.flags = kwargs.get('flags', 0) # type: int
self.no_match = kwargs.get('no_match', False) # type: bool
def _freeze_relations(self, relations: list[_Selector]) -> ct.SelectorList:
"""Freeze relation."""
if relations:
sel = relations[0]
sel.relations.extend(relations[1:])
return ct.SelectorList([sel.freeze()])
else:
return ct.SelectorList()
def freeze(self) -> ct.Selector | ct.SelectorNull:
"""Freeze self."""
if self.no_match:
return ct.Null
else:
return ct.Selector(
self.tag,
tuple(self.ids),
tuple(self.classes),
tuple(self.attributes),
tuple(self.nth),
tuple(self.selectors),
self._freeze_relations(self.relations),
self.rel_type,
tuple(self.contains),
tuple(self.lang),
self.flags
)
def __str__(self) -> str: # pragma: no cover
"""String representation."""
return (
f'_Selector(tag={self.tag!r}, ids={self.ids!r}, classes={self.classes!r}, attributes={self.attributes!r}, '
f'nth={self.nth!r}, selectors={self.selectors!r}, relations={self.relations!r}, '
f'rel_type={self.rel_type!r}, contains={self.contains!r}, lang={self.lang!r}, flags={self.flags!r}, '
f'no_match={self.no_match!r})'
)
__repr__ = __str__
@dataclass
class CSSPattern:
"""A CSS pattern that hasn't been processed by `CSSParser` yet."""
selector: str
flags: int
class PseudoSelectorMap(UserDict[str, CSSPattern | ct.SelectorList]):
"""Pseudo selector map."""
def __setitem__(self, key: str, value: CSSPattern | ct.SelectorList) -> None:
"""Set item."""
self.data[key] = value
def __getitem__(self, key: str) -> ct.SelectorList:
"""Get item."""
with SEL_LOCK:
value = self.data[key]
if isinstance(value, CSSPattern):
value = CSSParser(value.selector).process_selectors(flags=value.flags)
self.data[key] = value
return value
# CSS pattern for `:link` and `:any-link`
CSS_LINK = CSSPattern('html|*:is(a, area)[href]', FLG_PSEUDO | FLG_HTML)
# CSS pattern for `:checked`
CSS_CHECKED = CSSPattern(
'''
html|*:is(input[type=checkbox], input[type=radio])[checked], html|option[selected]
''',
FLG_PSEUDO | FLG_HTML
)
# CSS pattern for `:default` (must compile CSS_CHECKED first)
CSS_DEFAULT = CSSPattern(
'''
:checked,
/*
This pattern must be at the end.
Special logic is applied to the last selector.
*/
html|form html|*:is(button, input)[type="submit"]
''',
FLG_PSEUDO | FLG_HTML | FLG_DEFAULT
)
# CSS pattern for `:indeterminate`
CSS_INDETERMINATE = CSSPattern(
'''
html|input[type="checkbox"][indeterminate],
html|input[type="radio"]:is(:not([name]), [name=""]):not([checked]),
html|progress:not([value]),
/*
This pattern must be at the end.
Special logic is applied to the last selector.
*/
html|input[type="radio"][name]:not([name='']):not([checked])
''',
FLG_PSEUDO | FLG_HTML | FLG_INDETERMINATE
)
# CSS pattern for `:disabled`
CSS_DISABLED = CSSPattern(
'''
html|*:is(input:not([type=hidden]), button, select, textarea, fieldset, optgroup, option, fieldset)[disabled],
html|optgroup[disabled] > html|option,
html|fieldset[disabled] > html|*:is(input:not([type=hidden]), button, select, textarea, fieldset),
html|fieldset[disabled] >
html|*:not(legend:nth-of-type(1)) html|*:is(input:not([type=hidden]), button, select, textarea, fieldset)
''',
FLG_PSEUDO | FLG_HTML
)
# CSS pattern for `:enabled`
CSS_ENABLED = CSSPattern(
'''
html|*:is(input:not([type=hidden]), button, select, textarea, fieldset, optgroup, option, fieldset):not(:disabled)
''',
FLG_PSEUDO | FLG_HTML
)
# CSS pattern for `:required`
CSS_REQUIRED = CSSPattern('html|*:is(input, textarea, select)[required]', FLG_PSEUDO | FLG_HTML)
# CSS pattern for `:optional`
CSS_OPTIONAL = CSSPattern('html|*:is(input, textarea, select):not([required])', FLG_PSEUDO | FLG_HTML)
# CSS pattern for `:placeholder-shown`
CSS_PLACEHOLDER_SHOWN = CSSPattern(
'''
html|input:is(
:not([type]),
[type=""],
[type=text],
[type=search],
[type=url],
[type=tel],
[type=email],
[type=password],
[type=number]
)[placeholder]:not([placeholder='']):is(:not([value]), [value=""]),
html|textarea[placeholder]:not([placeholder=''])
''',
FLG_PSEUDO | FLG_HTML | FLG_PLACEHOLDER_SHOWN
)
# CSS pattern for `:read-write` (CSS_DISABLED must be compiled first)
CSS_READ_WRITE = CSSPattern(
'''
html|*:is(
textarea,
input:is(
:not([type]),
[type=""],
[type=text],
[type=search],
[type=url],
[type=tel],
[type=email],
[type=number],
[type=password],
[type=date],
[type=datetime-local],
[type=month],
[type=time],
[type=week]
)
):not([readonly], :disabled),
html|*:is([contenteditable=""], [contenteditable="true" i])
''',
FLG_PSEUDO | FLG_HTML
)
# CSS pattern for `:read-only`
CSS_READ_ONLY = CSSPattern('html|*:not(:read-write)', FLG_PSEUDO | FLG_HTML)
# CSS pattern for `:in-range`
CSS_IN_RANGE = CSSPattern(
'''
html|input:is(
[type="date"],
[type="month"],
[type="week"],
[type="time"],
[type="datetime-local"],
[type="number"],
[type="range"]
):is(
[min],
[max]
)
''',
FLG_PSEUDO | FLG_HTML | FLG_IN_RANGE
)
# CSS pattern for `:out-of-range`
CSS_OUT_OF_RANGE = CSSPattern(
'''
html|input:is(
[type="date"],
[type="month"],
[type="week"],
[type="time"],
[type="datetime-local"],
[type="number"],
[type="range"]
):is(
[min],
[max]
)
''',
FLG_PSEUDO | FLG_HTML | FLG_OUT_OF_RANGE
)
# CSS pattern for :open
CSS_OPEN = CSSPattern('html|*:is(details, dialog)[open]', FLG_PSEUDO | FLG_HTML)
# CSS pattern for :muted
CSS_MUTED = CSSPattern('html|*:is(video, audio)[muted]', FLG_PSEUDO | FLG_HTML)
# CSS pattern default for `:nth-child` "of S" feature
CSS_NTH_OF_S_DEFAULT = CSSPattern("*|*", FLG_PSEUDO)
class CSSParser:
"""Parse CSS selectors."""
CSS_TOKENS = (
SelectorPattern("pseudo_close", PAT_PSEUDO_CLOSE),
SpecialPseudoPattern(
(
(
"pseudo_contains",
(':contains', ':-soup-contains', ':-soup-contains-own'),
PAT_PSEUDO_CONTAINS,
SelectorPattern
),
("pseudo_nth_child", (':nth-child', ':nth-last-child'), PAT_PSEUDO_NTH_CHILD, SelectorPattern),
("pseudo_nth_type", (':nth-of-type', ':nth-last-of-type'), PAT_PSEUDO_NTH_TYPE, SelectorPattern),
("pseudo_lang", (':lang',), PAT_PSEUDO_LANG, SelectorPattern),
("pseudo_dir", (':dir',), PAT_PSEUDO_DIR, SelectorPattern)
)
),
SelectorPattern("pseudo_class_custom", PAT_PSEUDO_CLASS_CUSTOM),
SelectorPattern("pseudo_class", PAT_PSEUDO_CLASS),
SelectorPattern("pseudo_element", PAT_PSEUDO_ELEMENT),
SelectorPattern("amp", PAT_AMP),
SelectorPattern("at_rule", PAT_AT_RULE),
SelectorPattern("id", PAT_ID),
SelectorPattern("class", PAT_CLASS),
SelectorPattern("tag", PAT_TAG),
SelectorPattern("attribute", PAT_ATTR),
SelectorPattern("combine", PAT_COMBINE)
)
# Pseudos that expand to selectors
PSEUDO_SELECTORS = PseudoSelectorMap(
{
':link': CSS_LINK,
':any-link': CSS_LINK,
':checked': CSS_CHECKED,
':default': CSS_DEFAULT,
':indeterminate': CSS_INDETERMINATE,
':disabled': CSS_DISABLED,
':enabled': CSS_ENABLED,
':required': CSS_REQUIRED,
':muted': CSS_MUTED,
':open': CSS_OPEN,
':optional': CSS_OPTIONAL,
':read-only': CSS_READ_ONLY,
':read-write': CSS_READ_WRITE,
':in-range': CSS_IN_RANGE,
':out-of-range': CSS_OUT_OF_RANGE,
':placeholder-shown': CSS_PLACEHOLDER_SHOWN,
'<nth-of-s>': CSS_NTH_OF_S_DEFAULT
}
)
def __init__(
self,
selector: str,
*,
custom: dict[str, str | ct.SelectorList] | None = None,
ignore: Iterable[str] | None = None,
flags: int = 0
) -> None:
"""Initialize."""
self.pattern = selector.replace('\x00', '\ufffd')
self.flags = flags
self.debug = self.flags & util.DEBUG
self.custom = {} if custom is None else custom
self.ignore = frozenset([] if ignore is None else ignore)
self.count = 0
def increment_count(self, increment: int = 1) -> None:
"""Check the current selector count."""
self.count += increment
if self.count > SELECTOR_LIMIT:
raise ValueError(f'Selector exceeds pseudo-class nesting limit of {SELECTOR_LIMIT}')
def parse_attribute_selector(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
"""Create attribute selector from the returned regex match."""
inverse = False
op = m.group('cmp')
case = util.lower(m.group('case')) if m.group('case') else None
ns = css_unescape(m.group('attr_ns')[:-1]) if m.group('attr_ns') else ''
attr = css_unescape(m.group('attr_name'))
is_type = False
pattern2 = None
value = ''
if case:
flags = (re.I if case == 'i' else 0) | re.DOTALL
elif util.lower(attr) == 'type':
flags = re.I | re.DOTALL
is_type = True
else:
flags = re.DOTALL
if op:
value = css_unescape(m.group('value'))
if not op:
# Attribute name
pattern = None
elif op.startswith('^'):
# Value start with
# `^=` should match nothing if the value is empty, so use `(?!)` which cannot be matched.
value = r'(?!)' if not value else re.escape(value)
pattern = re.compile(r'^%s.*' % value, flags)
elif op.startswith('$'):
# Value ends with
# `$=` should match nothing if the value is empty, so use `(?!)` which cannot be matched.
value = r'(?!)' if not value else re.escape(value)
pattern = re.compile(r'.*?%s$' % value, flags)
elif op.startswith('*'):
# Value contains
# `*=` should match nothing if the value is empty, so use `(?!)` which cannot be matched.
value = r'(?!)' if not value else re.escape(value)
pattern = re.compile(r'.*?%s.*' % value, flags)
elif op.startswith('~'):
# Value contains word within space separated list
# `*~` should match nothing if the value is empty, so use `(?!)` which cannot be matched.
value = r'(?!)' if not value or RE_WS.search(value) else re.escape(value)
pattern = re.compile(r'.*?(?:(?<=^)|(?<=[ \t\r\n\f]))%s(?=(?:[ \t\r\n\f]|$)).*' % value, flags)
elif op.startswith('|'):
# Value starts with word in dash separated list
pattern = re.compile(r'^%s(?:-.*)?$' % re.escape(value), flags)
else:
# Value matches
pattern = re.compile(r'^%s$' % re.escape(value), flags)
if op.startswith('!'):
# Equivalent to `:not([attr=value])`
inverse = True
if is_type and pattern:
pattern2 = re.compile(pattern.pattern)
# Append the attribute selector
sel_attr = ct.SelectorAttribute(attr, ns, pattern, pattern2)
if inverse:
# If we are using `!=`, we need to nest the pattern under a `:not()`.
sub_sel = _Selector()
sub_sel.attributes.append(sel_attr)
not_list = ct.SelectorList([sub_sel.freeze()], True, False)
sel.selectors.append(not_list)
else:
sel.attributes.append(sel_attr)
has_selector = True
return has_selector
def parse_tag_pattern(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
"""Parse tag pattern from regex match."""
prefix = css_unescape(m.group('tag_ns')[:-1]) if m.group('tag_ns') else None
tag = css_unescape(m.group('tag_name'))
sel.tag = ct.SelectorTag(tag, prefix)
has_selector = True
return has_selector
def parse_pseudo_class_custom(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
"""
Parse custom pseudo class alias.
Compile custom selectors as we need them. When compiling a custom selector,
set it to `None` in the dictionary so we can avoid an infinite loop.
"""
pseudo = util.lower(css_unescape(m.group('name')))
selector = self.custom.get(pseudo)
if selector is None:
raise SelectorSyntaxError(
f"Undefined custom selector '{pseudo}' found at position {m.end(0)}",
self.pattern,
m.end(0)
)
if not isinstance(selector, ct.SelectorList):
del self.custom[pseudo]
selector = CSSParser(
selector, custom=self.custom, flags=self.flags, ignore=self.ignore
).process_selectors(flags=FLG_PSEUDO)
self.custom[pseudo] = selector
self.increment_count(selector.count)
sel.selectors.append(selector)
has_selector = True
return has_selector
def parse_pseudo_class(
self,
sel: _Selector,
m: Match[str],
has_selector: bool,
iselector: Iterator[tuple[str, Match[str]]],
is_html: bool
) -> tuple[bool, bool]:
"""Parse pseudo class."""
complex_pseudo = False
pseudo = util.lower(css_unescape(m.group('name')))
if m.group('open'):
complex_pseudo = True
if complex_pseudo and pseudo in PSEUDO_COMPLEX:
has_selector = self.parse_pseudo_open(sel, pseudo, has_selector, iselector, m.end(0))
elif not complex_pseudo and pseudo in PSEUDO_SIMPLE:
if pseudo == ':root':
sel.flags |= ct.SEL_ROOT
elif pseudo == ':defined':
sel.flags |= ct.SEL_DEFINED
is_html = True
elif pseudo == ':scope':
sel.flags |= ct.SEL_SCOPE
elif pseudo == ':empty':
sel.flags |= ct.SEL_EMPTY
elif pseudo in self.PSEUDO_SELECTORS:
pseudo_selector = self.PSEUDO_SELECTORS[pseudo]
self.increment_count(pseudo_selector.count)
sel.selectors.append(pseudo_selector)
elif pseudo == ':first-child':
sel.nth.append(ct.SelectorNth(0, False, 1, False, False, ct.SelectorList()))
elif pseudo == ':last-child':
sel.nth.append(ct.SelectorNth(0, False, 1, False, True, ct.SelectorList()))
elif pseudo == ':first-of-type':
sel.nth.append(ct.SelectorNth(0, False, 1, True, False, ct.SelectorList()))
elif pseudo == ':last-of-type':
sel.nth.append(ct.SelectorNth(0, False, 1, True, True, ct.SelectorList()))
elif pseudo == ':only-child':
sel.nth.extend(
[
ct.SelectorNth(0, False, 1, False, False, ct.SelectorList()),
ct.SelectorNth(0, False, 1, False, True, ct.SelectorList())
]
)
elif pseudo == ':only-of-type':
sel.nth.extend(
[
ct.SelectorNth(0, False, 1, True, False, ct.SelectorList()),
ct.SelectorNth(0, False, 1, True, True, ct.SelectorList())
]
)
has_selector = True
elif complex_pseudo and pseudo in PSEUDO_COMPLEX_NO_MATCH:
self.parse_selectors(iselector, m.end(0), FLG_PSEUDO | FLG_OPEN)
sel.no_match = True
has_selector = True
elif not complex_pseudo and pseudo in PSEUDO_SIMPLE_NO_MATCH:
sel.no_match = True
has_selector = True
elif pseudo in PSEUDO_SUPPORTED:
raise SelectorSyntaxError(
f"Invalid syntax for pseudo class '{pseudo}'",
self.pattern,
m.start(0)
)
else:
raise SelectorSyntaxError(
f"'{pseudo}' was detected as a pseudo-class and is either unsupported or invalid. "
"If the syntax was not intended to be recognized as a pseudo-class, please escape the colon.",
self.pattern,
m.start(0)
)
return has_selector, is_html
def parse_pseudo_nth(
self,
sel: _Selector,
m: Match[str],
has_selector: bool,
iselector: Iterator[tuple[str, Match[str]]]
) -> bool:
"""Parse `nth` pseudo."""
mdict = m.groupdict()
if mdict.get('pseudo_nth_child'):
postfix = '_child'
else:
postfix = '_type'
mdict['name'] = util.lower(css_unescape(mdict['name']))
content = util.lower(mdict.get('nth' + postfix))
if content == 'even':
# 2n
s1 = 2
s2 = 0
var = True
elif content == 'odd':
# 2n+1
s1 = 2
s2 = 1
var = True
else:
nth_parts = cast(Match[str], RE_NTH.match(content))
_s1 = '-' if nth_parts.group('s1') and nth_parts.group('s1') == '-' else ''
_s2 = ''
a = nth_parts.group('a')
var = a.endswith('n')
if a.startswith('n'):
_s1 += '1'
elif var:
_s1 += a[:-1]
else:
_s2 = _s1 + a
_s1 = ''
if nth_parts.group('s2') and nth_parts.group('s2') == '-':
_s2 = '-'
if nth_parts.group('b'):
_s2 += nth_parts.group('b')
s1 = int(_s1, 10) if _s1 else 0
s2 = int(_s2, 10) if _s2 else 0
pseudo_sel = mdict['name']
if postfix == '_child':
if m.group('of'):
# Parse the rest of `of S`.
nth_sel = self.parse_selectors(iselector, m.end(0), FLG_PSEUDO | FLG_OPEN)
else:
# Use default `*|*` for `of S`.
nth_sel = self.PSEUDO_SELECTORS['<nth-of-s>']
self.increment_count(nth_sel.count)
if pseudo_sel == ':nth-child':
sel.nth.append(ct.SelectorNth(s1, var, s2, False, False, nth_sel))
elif pseudo_sel == ':nth-last-child':
sel.nth.append(ct.SelectorNth(s1, var, s2, False, True, nth_sel))
else:
if pseudo_sel == ':nth-of-type':
sel.nth.append(ct.SelectorNth(s1, var, s2, True, False, ct.SelectorList()))
elif pseudo_sel == ':nth-last-of-type':
sel.nth.append(ct.SelectorNth(s1, var, s2, True, True, ct.SelectorList()))
has_selector = True
return has_selector
def parse_pseudo_open(
self,
sel: _Selector,
name: str,
has_selector: bool,
iselector: Iterator[tuple[str, Match[str]]],
index: int
) -> bool:
"""Parse pseudo with opening bracket."""
flags = FLG_PSEUDO | FLG_OPEN
if name == ':not':
flags |= FLG_NOT
elif name == ':has':
flags |= FLG_RELATIVE
elif name in (':where', ':is'):
flags |= FLG_FORGIVE
sel.selectors.append(self.parse_selectors(iselector, index, flags))
has_selector = True
return has_selector
def parse_has_combinator(
self,
sel: _Selector,
m: Match[str],
has_selector: bool,
selectors: list[_Selector],
rel_type: str,
index: int
) -> tuple[bool, _Selector, str]:
"""Parse combinator tokens."""
combinator = m.group('relation')
if not combinator:
combinator = WS_COMBINATOR
if combinator == COMMA_COMBINATOR:
if not has_selector:
raise SelectorSyntaxError(
f"The combinator '{combinator}' at position {index}, must have a selector before it",
self.pattern,
index
)
sel.rel_type = rel_type
selectors[-1].relations.append(sel)
rel_type = ":" + WS_COMBINATOR
selectors.append(_Selector())
else:
if has_selector:
# End the current selector and associate the leading combinator with this selector.
sel.rel_type = rel_type
selectors[-1].relations.append(sel)
elif rel_type[1:] != WS_COMBINATOR:
# It's impossible to have two whitespace combinators after each other as the patterns
# will gobble up trailing whitespace. It is also impossible to have a whitespace
# combinator after any other kind for the same reason. But we could have
# multiple non-whitespace combinators. So if the current combinator is not a whitespace,
# then we've hit the multiple combinator case, so we should fail.
raise SelectorSyntaxError(
f'The multiple combinators at position {index}',
self.pattern,
index
)
# Set the leading combinator for the next selector.
rel_type = ':' + combinator
sel = _Selector()
has_selector = False
return has_selector, sel, rel_type
def parse_combinator(
self,
sel: _Selector,
m: Match[str],
has_selector: bool,
selectors: list[_Selector],
relations: list[_Selector],
is_pseudo: bool,
is_forgive: bool,
index: int
) -> tuple[bool, _Selector]:
"""Parse combinator tokens."""
combinator = m.group('relation')
if not combinator:
combinator = WS_COMBINATOR
if not has_selector:
if not is_forgive or combinator != COMMA_COMBINATOR:
raise SelectorSyntaxError(
f"The combinator '{combinator}' at position {index}, must have a selector before it",
self.pattern,
index
)
# If we are in a forgiving pseudo class, just make the selector a "no match"
if combinator == COMMA_COMBINATOR:
self.increment_count()
del relations[:]
else:
if combinator == COMMA_COMBINATOR:
if not sel.tag and not is_pseudo:
# Implied `*`
sel.tag = ct.SelectorTag('*', None)
sel.relations.extend(relations)
selectors.append(sel)
del relations[:]
else:
sel.relations.extend(relations)
sel.rel_type = combinator
del relations[:]
relations.append(sel)
sel = _Selector()
has_selector = False
return has_selector, sel
def parse_class_id(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
"""Parse HTML classes and ids."""
selector = m.group(0)
if selector.startswith('.'):
sel.classes.append(css_unescape(selector[1:]))
else:
sel.ids.append(css_unescape(selector[1:]))
has_selector = True
return has_selector
def parse_pseudo_contains(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
"""Parse contains."""
pseudo = util.lower(css_unescape(m.group('name')))
if pseudo == ":contains":
warnings.warn( # noqa: B028
"The pseudo class ':contains' is deprecated, ':-soup-contains' should be used moving forward.",
FutureWarning
)
contains_own = pseudo == ":-soup-contains-own"
patterns = [
css_unescape(token.group('value'))
for token in RE_VALUES.finditer(m.group('values'))
if not token.group('split')
]
sel.contains.append(ct.SelectorContains(patterns, contains_own))
has_selector = True
return has_selector
def parse_pseudo_lang(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
"""Parse pseudo language."""
patterns = [
css_unescape(token.group('value'))
for token in RE_VALUES.finditer(m.group('values'))
if not token.group('split')
]
sel.lang.append(ct.SelectorLang(patterns))
has_selector = True
return has_selector
def parse_pseudo_dir(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
"""Parse pseudo direction."""
value = ct.SEL_DIR_LTR if util.lower(m.group('dir')) == 'ltr' else ct.SEL_DIR_RTL
sel.flags |= value
has_selector = True
return has_selector
def parse_selectors(
self,
iselector: Iterator[tuple[str, Match[str]]],
index: int = 0,
flags: int = 0
) -> ct.SelectorList:
"""Parse selectors."""
# Initialize important variables
sel = _Selector()
selectors = []
has_selector = False
closed = False
relations = [] # type: list[_Selector]
rel_type = ":" + WS_COMBINATOR
count = self.count
# Setup various flags
is_open = bool(flags & FLG_OPEN)
is_pseudo = bool(flags & FLG_PSEUDO)
is_relative = bool(flags & FLG_RELATIVE)
is_not = bool(flags & FLG_NOT)
is_html = bool(flags & FLG_HTML)
is_default = bool(flags & FLG_DEFAULT)
is_indeterminate = bool(flags & FLG_INDETERMINATE)
is_in_range = bool(flags & FLG_IN_RANGE)
is_out_of_range = bool(flags & FLG_OUT_OF_RANGE)
is_placeholder_shown = bool(flags & FLG_PLACEHOLDER_SHOWN)
is_forgive = bool(flags & FLG_FORGIVE)
# Print out useful debug stuff
if self.debug: # pragma: no cover
if is_pseudo:
print(' is_pseudo: True')
if is_open:
print(' is_open: True')
if is_relative:
print(' is_relative: True')
if is_not:
print(' is_not: True')
if is_html:
print(' is_html: True')
if is_default:
print(' is_default: True')
if is_indeterminate:
print(' is_indeterminate: True')
if is_in_range:
print(' is_in_range: True')
if is_out_of_range:
print(' is_out_of_range: True')
if is_placeholder_shown:
print(' is_placeholder_shown: True')
if is_forgive:
print(' is_forgive: True')
# The algorithm for relative selectors require an initial selector in the selector list
if is_relative:
selectors.append(_Selector())
try:
while True:
key, m = next(iselector)
if key not in ('combine', 'pseudo_close'):
self.increment_count()
# Handle parts
if self.ignore:
mi = RE_PSEUDO_IGNORE.match(m.group(0))
if mi is not None and mi.group('name').lower() in self.ignore:
raise SelectorSyntaxError(
f"The selector '{mi.group('name')}' at position {m.start(0)}, has been disallowed",
self.pattern,
m.start(0)
)
if key == "at_rule":
raise NotImplementedError(f"At-rules found at position {m.start(0)}")
elif key == "amp":
sel.flags |= ct.SEL_SCOPE
has_selector = True
elif key == 'pseudo_class_custom':
has_selector = self.parse_pseudo_class_custom(sel, m, has_selector)
elif key == 'pseudo_class':
has_selector, is_html = self.parse_pseudo_class(sel, m, has_selector, iselector, is_html)
elif key == 'pseudo_element':
raise NotImplementedError(f"Pseudo-element found at position {m.start(0)}")
elif key == 'pseudo_contains':
has_selector = self.parse_pseudo_contains(sel, m, has_selector)
elif key in ('pseudo_nth_type', 'pseudo_nth_child'):
has_selector = self.parse_pseudo_nth(sel, m, has_selector, iselector)
elif key == 'pseudo_lang':
has_selector = self.parse_pseudo_lang(sel, m, has_selector)
elif key == 'pseudo_dir':
has_selector = self.parse_pseudo_dir(sel, m, has_selector)
# Currently only supports HTML
is_html = True
elif key == 'pseudo_close':
if not has_selector:
if not is_forgive:
raise SelectorSyntaxError(
f"Expected a selector at position {m.start(0)}",
self.pattern,
m.start(0)
)
sel.no_match = True
if is_open:
closed = True
break
else:
raise SelectorSyntaxError(
f"Unmatched pseudo-class close at position {m.start(0)}",
self.pattern,
m.start(0)
)
elif key == 'combine':
if is_relative:
has_selector, sel, rel_type = self.parse_has_combinator(
sel, m, has_selector, selectors, rel_type, index
)
else:
has_selector, sel = self.parse_combinator(
sel, m, has_selector, selectors, relations, is_pseudo, is_forgive, index
)
elif key == 'attribute':
has_selector = self.parse_attribute_selector(sel, m, has_selector)
elif key == 'tag':
if has_selector:
raise SelectorSyntaxError(
f"Tag name found at position {m.start(0)} instead of at the start",
self.pattern,
m.start(0)
)
has_selector = self.parse_tag_pattern(sel, m, has_selector)
elif key in ('class', 'id'):
has_selector = self.parse_class_id(sel, m, has_selector)
index = m.end(0)
except StopIteration:
pass
# Handle selectors that are not closed
if is_open and not closed:
raise SelectorSyntaxError(
f"Unclosed pseudo-class at position {index}",
self.pattern,
index
)
# Cleanup completed selector piece
if has_selector:
if not sel.tag and not is_pseudo:
# Implied `*`
sel.tag = ct.SelectorTag('*', None)
if is_relative:
sel.rel_type = rel_type
selectors[-1].relations.append(sel)
else:
sel.relations.extend(relations)
del relations[:]
selectors.append(sel)
# Forgive empty slots in pseudo-classes that have lists (and are forgiving)
elif is_forgive and (not selectors or not relations):
# Handle normal pseudo-classes with empty slots like `:is()` etc.
self.increment_count()
sel.no_match = True
del relations[:]
selectors.append(sel)
has_selector = True
if not has_selector:
# We will always need to finish a selector when `:has()` is used as it leads with combining.
# May apply to others as well.
raise SelectorSyntaxError(
f'Expected a selector at position {index}',
self.pattern,
index
)
# Some patterns require additional logic, such as default. We try to make these the
# last pattern, and append the appropriate flag to that selector which communicates
# to the matcher what additional logic is required.
if is_default:
selectors[-1].flags = ct.SEL_DEFAULT
if is_indeterminate:
selectors[-1].flags = ct.SEL_INDETERMINATE
if is_in_range:
selectors[-1].flags = ct.SEL_IN_RANGE
if is_out_of_range:
selectors[-1].flags = ct.SEL_OUT_OF_RANGE
if is_placeholder_shown:
selectors[-1].flags = ct.SEL_PLACEHOLDER_SHOWN
# Return selector list
return ct.SelectorList([s.freeze() for s in selectors], is_not, is_html, self.count - count)
def selector_iter(self, pattern: str) -> Iterator[tuple[str, Match[str]]]:
"""Iterate selector tokens."""
# Ignore whitespace and comments at start and end of pattern
m = RE_WS_BEGIN.search(pattern)
index = m.end(0) if m else 0
m = RE_WS_END.search(pattern[::-1])
offset = m.end(0) if m else 0
end = len(pattern) - (1 + offset)
if self.debug: # pragma: no cover
print(f'## PARSING: {pattern!r}')
while index <= end:
m = None
for v in self.CSS_TOKENS:
m = v.match(pattern, index, self.flags)
if m:
name = v.get_name()
if self.debug: # pragma: no cover
print(f"TOKEN: '{name}' --> {m.group(0)!r} at position {m.start(0)}")
index = m.end(0)
yield name, m
break
if m is None:
c = pattern[index]
# If the character represents the start of one of the known selector types,
# throw an exception mentioning that the known selector type is in error;
# otherwise, report the invalid character.
if c == '[':
msg = f"Malformed attribute selector at position {index}"
elif c == '.':
msg = f"Malformed class selector at position {index}"
elif c == '#':
msg = f"Malformed id selector at position {index}"
elif c == ':':
msg = f"Malformed pseudo-class selector at position {index}"
else:
msg = f"Invalid character {c!r} position {index}"
raise SelectorSyntaxError(msg, self.pattern, index)
if self.debug: # pragma: no cover
print('## END PARSING')
def process_selectors(self, index: int = 0, flags: int = 0) -> ct.SelectorList:
"""Process selectors."""
return self.parse_selectors(self.selector_iter(self.pattern), index, flags)