python-corekit 0.4.1__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -330,15 +330,19 @@ class SQLConnection(Connectable):
330
330
  """
331
331
  return list(self._exec(query.build()))
332
332
 
333
- def delete_many_by_query(self, query: Query) -> None:
333
+ def delete_many_by_query(self, query: Query) -> int:
334
334
  """
335
335
  Delete every row matching a Query.
336
+
337
+ Returns the number of rows deleted.
336
338
  """
337
339
  self.require_writable()
338
340
  self._start_session()
339
- for item in self.fetch_many_by_query(query):
341
+ items = self.fetch_many_by_query(query)
342
+ for item in items:
340
343
  self.session.delete(item)
341
344
  self.session.commit()
345
+ return len(items)
342
346
 
343
347
  def execute(self, operation: Operation) -> Any:
344
348
  """
@@ -0,0 +1,22 @@
1
+ """
2
+ HTML parsing and crawling helpers.
3
+ """
4
+
5
+ from corekit.html.client import WebscrapeClient
6
+ from corekit.html.models import LinkItem, Page, PageMetadata
7
+ from corekit.html.parser import HtmlParser
8
+ from corekit.html.scraper import Scraper
9
+ from corekit.html.settings import CleanSettings, LinkFilterSettings
10
+ from corekit.html.state import ScraperState
11
+
12
+ __all__ = [
13
+ "CleanSettings",
14
+ "HtmlParser",
15
+ "LinkFilterSettings",
16
+ "LinkItem",
17
+ "Page",
18
+ "PageMetadata",
19
+ "Scraper",
20
+ "ScraperState",
21
+ "WebscrapeClient",
22
+ ]
corekit/html/client.py ADDED
@@ -0,0 +1,22 @@
1
+ from typing import Any
2
+
3
+ from corekit.http import BaseHttpClient
4
+
5
+
6
+ class WebscrapeClient(BaseHttpClient):
7
+ """
8
+ HTTP client with browser-like default headers for scraping.
9
+
10
+ Inherits GET-only ``supported_methods`` from ``BaseHttpClient``.
11
+ """
12
+
13
+ def __init__(self, **kwargs: Any) -> None:
14
+ kwargs.setdefault("strict", False)
15
+ super().__init__(**kwargs)
16
+
17
+ @property
18
+ def headers(self) -> dict[str, str]:
19
+ return {
20
+ "Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8",
21
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:156.0) Gecko/20100101 Firefox/156.0",
22
+ }
corekit/html/models.py ADDED
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass(slots=True)
5
+ class PageMetadata:
6
+ title: str = ""
7
+ base_url: str | None = None
8
+ meta: dict[str, str] = field(default_factory=dict)
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class LinkItem:
13
+ href: str
14
+ text: str | None = None
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class Page:
19
+ metadata: PageMetadata
20
+ content: str
corekit/html/parser.py ADDED
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterator
4
+ from urllib.parse import urldefrag, urljoin, urlparse, urlunparse
5
+
6
+ from selectolax.lexbor import LexborHTMLParser
7
+
8
+ from corekit.exceptions import InternalCoreException
9
+ from corekit.exceptions.enum import Retryability
10
+ from corekit.html.models import LinkItem, PageMetadata
11
+ from corekit.html.settings import CleanSettings, LinkFilterSettings
12
+ from corekit.observability import Loggable
13
+ from corekit.utils import iterator, parse_string, safe_dict, safe_string
14
+
15
+ _SKIP_SCHEMES: frozenset[str] = frozenset({"", "javascript", "mailto", "tel", "data", "blob", "about"})
16
+
17
+
18
+ class HtmlParser(Loggable):
19
+ def __init__(
20
+ self,
21
+ html: str | bytes,
22
+ url: str,
23
+ *,
24
+ clean_settings: CleanSettings | None = None,
25
+ link_settings: LinkFilterSettings | None = None,
26
+ ) -> None:
27
+ super().__init__()
28
+ self._html = html
29
+ self._url = url
30
+ self._clean_settings = clean_settings or CleanSettings.defaults()
31
+ self._link_settings = link_settings
32
+ self._original = LexborHTMLParser(html)
33
+ self._current = LexborHTMLParser(html)
34
+
35
+ def reset(self) -> None:
36
+ """
37
+ Resets the current working tree back to the pristine original state
38
+ """
39
+ self._current = LexborHTMLParser(self._html)
40
+
41
+ def clone(self) -> HtmlParser:
42
+ """
43
+ Returns a deep-copy independent instance of the parser
44
+ """
45
+ return HtmlParser(
46
+ self._html,
47
+ self._url,
48
+ clean_settings=self._clean_settings,
49
+ link_settings=self._link_settings,
50
+ )
51
+
52
+ @property
53
+ def current(self) -> LexborHTMLParser:
54
+ """
55
+ Direct access to the underlying active LexborHTMLParser instance
56
+ """
57
+ return self._current
58
+
59
+ def _html_text(self) -> str | None:
60
+ parsed_html = parse_string(self._current.html)
61
+ return parsed_html.value or None
62
+
63
+ def _has_content(self) -> bool:
64
+ html = self._html_text()
65
+ if not html:
66
+ return False
67
+ body = self._current.body
68
+ if body is None:
69
+ return bool(html)
70
+ text = safe_string(body.text(separator=" ", strip=True))
71
+ return bool(text)
72
+
73
+ def get_clean_content(self) -> str:
74
+ """
75
+ Clean the page with tiered fallbacks and return non-empty HTML.
76
+
77
+ Tries user/effective settings first (ideal → no-scope → emergency).
78
+ If those yield no content and settings were customized, falls back to
79
+ built-in defaults and logs an error. Raises if every attempt fails.
80
+ """
81
+ content = self._clean_with_settings(self._clean_settings)
82
+ if content is not None:
83
+ return content
84
+
85
+ if self._clean_settings.is_customized():
86
+ fallback = self._clean_with_settings(CleanSettings.defaults())
87
+ if fallback is not None:
88
+ self.error(
89
+ f"User clean settings for {self._url!r} yielded no content; "
90
+ f"fell back to defaults ({len(fallback)} chars remaining)"
91
+ )
92
+ return fallback
93
+
94
+ raise InternalCoreException(
95
+ f"Failed to extract clean content from {self._url!r}",
96
+ retryable=Retryability.NON_RETRYABLE,
97
+ error="all cleaning tiers produced empty content",
98
+ )
99
+
100
+ def _clean_with_settings(self, settings: CleanSettings) -> str | None:
101
+ for tier in ("ideal", "fallback", "emergency"):
102
+ self.reset()
103
+ self._apply_tier(settings, tier)
104
+ if self._has_content():
105
+ html = self._html_text()
106
+ if html is not None:
107
+ return html
108
+ self.warning(f"[HtmlParser] Clean tier {tier!r} produced no content for {self._url!r}")
109
+ return None
110
+
111
+ def _apply_tier(self, settings: CleanSettings, tier: str) -> None:
112
+ if tier == "ideal":
113
+ self.prune_noise(settings, emergency=False)
114
+ self.apply_remove_selectors(settings)
115
+ self.scope_to_content_root(settings)
116
+ self.sanitize_attributes(settings)
117
+ elif tier == "fallback":
118
+ self.prune_noise(settings, emergency=False)
119
+ self.apply_remove_selectors(settings)
120
+ self.sanitize_attributes(settings)
121
+ else:
122
+ self.prune_noise(settings, emergency=True)
123
+ self.apply_remove_selectors(settings)
124
+ self.sanitize_attributes(settings)
125
+
126
+ def get_metadata(self) -> PageMetadata:
127
+ """
128
+ Extracts and validates page metadata into a structured model
129
+ """
130
+ title_node = self._current.css_first("title")
131
+ page_title = title_node.text(strip=True) if title_node else ""
132
+
133
+ base_node = self._current.css_first("base")
134
+ base_url = base_node.attributes.get("href") if base_node and base_node.attributes else None
135
+
136
+ meta_tags: dict[str, str] = {}
137
+ for m in iterator(self._current.css("meta"), where=lambda node: bool(node.attributes)):
138
+ attrs = safe_dict(m.attributes)
139
+ key = attrs.get("name") or attrs.get("property")
140
+ content = attrs.get("content")
141
+ if key and content:
142
+ meta_tags[key] = content
143
+
144
+ return PageMetadata(
145
+ title=page_title,
146
+ base_url=base_url,
147
+ meta=meta_tags,
148
+ )
149
+
150
+ def _base_for_links(self) -> str:
151
+ metadata = self.get_metadata()
152
+ if metadata.base_url:
153
+ return urljoin(self._url, metadata.base_url)
154
+ return self._url
155
+
156
+ def resolve_href(self, href: str, *, base: str | None = None) -> str | None:
157
+ """
158
+ Resolve a raw href to an absolute http(s) URL, or None if it should be skipped.
159
+
160
+ Fragments are always stripped. Query strings are kept unless
161
+ ``link_settings.should_strip_query`` says otherwise for the URL.
162
+ """
163
+ raw = href.strip()
164
+ if not raw or raw.startswith("#"):
165
+ return None
166
+
167
+ absolute = urljoin(base or self._base_for_links(), raw)
168
+ absolute, _frag = urldefrag(absolute)
169
+ parsed = urlparse(absolute)
170
+ scheme = parsed.scheme.lower()
171
+ if scheme in _SKIP_SCHEMES or scheme not in {"http", "https"}:
172
+ return None
173
+ if self._link_settings and self._link_settings.should_strip_query(absolute) and parsed.query:
174
+ absolute = urlunparse(parsed._replace(query=""))
175
+ return absolute
176
+
177
+ def get_links(self) -> Iterator[LinkItem]:
178
+ """
179
+ Extracts links as absolute URLs with anchor text.
180
+ """
181
+ base = self._base_for_links()
182
+
183
+ def has_href(node: Any) -> bool:
184
+ return bool(safe_dict(node.attributes).get("href"))
185
+
186
+ for a in iterator(self._current.css("a[href]"), where=has_href):
187
+ resolved = self.resolve_href(a.attributes["href"], base=base)
188
+ if not resolved:
189
+ continue
190
+ yield LinkItem(href=resolved, text=parse_string(a.text(strip=True)).value)
191
+
192
+ def find_content_root(self, settings: CleanSettings | None = None) -> Any:
193
+ """
194
+ Navigates to and returns the optimal starting content container node
195
+ """
196
+ effective = settings or self._clean_settings
197
+ selector = effective.effective_content_selector(self._url)
198
+ if selector:
199
+ if node := self._current.css_first(selector):
200
+ return node
201
+ return self._current.css_first("body")
202
+
203
+ def prune_noise(self, settings: CleanSettings | None = None, *, emergency: bool = False) -> None:
204
+ """
205
+ Removes unhelpful structural elements while optionally preserving search UI
206
+ """
207
+ effective = settings or self._clean_settings
208
+ for tag in effective.effective_noise_tags(emergency=emergency):
209
+ for node in self._current.css(tag):
210
+ node.remove()
211
+
212
+ if emergency or not effective.preserve_header_nav_with_search:
213
+ return
214
+
215
+ for container in iterator(
216
+ self._current.css("header, nav"),
217
+ where=lambda _node: not _node.css_first(effective.search_selectors),
218
+ ):
219
+ container.remove()
220
+
221
+ def apply_remove_selectors(self, settings: CleanSettings | None = None) -> None:
222
+ """
223
+ Remove nodes matching user-provided CSS selectors.
224
+ """
225
+ effective = settings or self._clean_settings
226
+ for selector in effective.remove_selectors:
227
+ for node in self._current.css(selector):
228
+ node.remove()
229
+
230
+ def sanitize_attributes(self, settings: CleanSettings | None = None) -> None:
231
+ """
232
+ Strips non-allowed attributes across the tree
233
+ """
234
+ effective = settings or self._clean_settings
235
+ allowed = effective.effective_allowed_attributes()
236
+ for node in iterator(self._current.css("*"), where=lambda n: bool(n.attributes)):
237
+ for key in iterator(
238
+ list(node.attrs.keys()),
239
+ where=lambda attr: attr.lower() not in allowed,
240
+ ):
241
+ del node.attrs[key]
242
+
243
+ def scope_to_content_root(self, settings: CleanSettings | None = None) -> None:
244
+ """
245
+ Replace the working tree with the best content container, when found.
246
+ """
247
+ effective = settings or self._clean_settings
248
+ root = self.find_content_root(effective)
249
+ if root is None:
250
+ self.warning(f"[HtmlParser] No content root found for {self._url!r}")
251
+ return
252
+
253
+ html = safe_string(root.html)
254
+ if not html:
255
+ self.warning(f"[HtmlParser] Content root for {self._url!r} had empty HTML")
256
+ return
257
+
258
+ text = safe_string(root.text(separator=" ", strip=True))
259
+ if not text:
260
+ self.warning(f"[HtmlParser] Content root for {self._url!r} had no text content")
261
+ return
262
+
263
+ self._current = LexborHTMLParser(html)
264
+
265
+ def clean(self, settings: CleanSettings | None = None) -> None:
266
+ """
267
+ Ideal-path clean: prune, remove selectors, scope, sanitize.
268
+
269
+ Prefer ``get_clean_content`` when you need tiered fallbacks.
270
+ """
271
+ effective = settings or self._clean_settings
272
+ self._apply_tier(effective, "ideal")
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from http import HTTPMethod
5
+ from typing import Any, AsyncIterator, cast
6
+
7
+ import httpx
8
+
9
+ from corekit.html.client import WebscrapeClient
10
+ from corekit.html.models import Page
11
+ from corekit.html.parser import HtmlParser
12
+ from corekit.html.settings import CleanSettings, LinkFilterSettings
13
+ from corekit.html.state import ScraperState
14
+ from corekit.http import BaseHttpResponse
15
+ from corekit.observability import Benchmarkable
16
+ from corekit.utils import iterator
17
+
18
+
19
+ class Scraper(Benchmarkable):
20
+ def __init__(
21
+ self,
22
+ start_urls: list[str],
23
+ settings: LinkFilterSettings,
24
+ *,
25
+ clean_settings: CleanSettings | None = None,
26
+ concurrency: int = 10,
27
+ request_timeout: float = 10.0,
28
+ retries: int = 2,
29
+ ) -> None:
30
+ super().__init__()
31
+ self.concurrency = concurrency
32
+ self.semaphore = asyncio.Semaphore(concurrency)
33
+ self.timeout = request_timeout
34
+ self.retries = retries
35
+ self.settings = settings
36
+ self.clean_settings = clean_settings or CleanSettings.defaults()
37
+
38
+ self._limits = httpx.Limits(
39
+ max_keepalive_connections=concurrency,
40
+ max_connections=concurrency * 2,
41
+ )
42
+ self._http: httpx.AsyncClient | None = None
43
+ self._client: WebscrapeClient | None = None
44
+ self._state = ScraperState(start_urls)
45
+
46
+ def initialize(self) -> None:
47
+ self._http = httpx.AsyncClient(
48
+ limits=self._limits,
49
+ timeout=self.timeout,
50
+ follow_redirects=True,
51
+ )
52
+ self._client = WebscrapeClient(
53
+ http_client=self._http,
54
+ timeout=self.timeout,
55
+ retries=self.retries,
56
+ base_delay=0.5,
57
+ )
58
+
59
+ async def close(self) -> None:
60
+ if self._http is not None:
61
+ await self._http.aclose()
62
+ self._http = None
63
+ self._client = None
64
+
65
+ async def __aenter__(self) -> Scraper:
66
+ self.initialize()
67
+ return self
68
+
69
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
70
+ await self.close()
71
+
72
+ async def _fetch(self, url: str) -> BaseHttpResponse:
73
+ if self._client is None:
74
+ raise RuntimeError("Scraper must be initialized before running.")
75
+ response = await self._client.request(HTTPMethod.GET, url)
76
+ response.raise_for_status()
77
+ return response
78
+
79
+ async def _worker(self, worker_id: int) -> None:
80
+ while True:
81
+ url = await self._state.get_next_url()
82
+ try:
83
+ async with self.semaphore:
84
+ self.info(f"[Worker {worker_id}] Fetching: {url}")
85
+ response = await self._fetch(url)
86
+
87
+ page_url = response.url or url
88
+ tree = HtmlParser(
89
+ response.text,
90
+ page_url,
91
+ clean_settings=self.clean_settings,
92
+ link_settings=self.settings,
93
+ )
94
+ for link in iterator(tree.get_links(), where=lambda item: self.settings.should_visit(item.href)):
95
+ await self._state.enqueue(link.href)
96
+
97
+ if self.settings.should_scrape(url) or self.settings.should_scrape(page_url):
98
+ page = Page(
99
+ metadata=tree.get_metadata(),
100
+ content=tree.get_clean_content(),
101
+ )
102
+ await self._state.record_output(page)
103
+
104
+ except Exception as e:
105
+ self.error(f"[Worker {worker_id}] Error on {url}: {e}")
106
+
107
+ finally:
108
+ self._state.task_done()
109
+
110
+ async def run(self) -> AsyncIterator[Page]:
111
+ if not self._client:
112
+ raise RuntimeError("Scraper must be initialized before running.")
113
+
114
+ workers = [asyncio.create_task(self._worker(i)) for i in range(self.concurrency)]
115
+ monitor_task = asyncio.create_task(self._state.join())
116
+
117
+ try:
118
+ while True:
119
+ result = await self._state.get_output()
120
+ if result is ScraperState.FINISHED_TOKEN:
121
+ break
122
+ yield cast(Page, result)
123
+ self._state.output_task_done()
124
+ finally:
125
+ monitor_task.cancel()
126
+ for w in workers:
127
+ w.cancel()
@@ -0,0 +1,197 @@
1
+ """
2
+ Filter and cleaning policy for crawling and HTML extraction.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import itertools
8
+ import re
9
+ from dataclasses import dataclass, field
10
+
11
+ OptionalPattern = re.Pattern[str] | str | None
12
+
13
+ DEFAULT_ALLOWED_ATTRIBUTES: frozenset[str] = frozenset(
14
+ {
15
+ "id",
16
+ "class",
17
+ "name",
18
+ "type",
19
+ "href",
20
+ "src",
21
+ "role",
22
+ "placeholder",
23
+ "value",
24
+ "data-id",
25
+ "data-testid",
26
+ }
27
+ )
28
+ DEFAULT_NOISE_TAGS: frozenset[str] = frozenset({"script", "style", "noscript", "iframe", "footer", "aside"})
29
+ EMERGENCY_NOISE_TAGS: frozenset[str] = frozenset({"script", "style", "noscript"})
30
+ DEFAULT_CONTENT_SELECTORS: tuple[str, ...] = (
31
+ "main",
32
+ "article",
33
+ "[role='main']",
34
+ "#content",
35
+ ".post-content",
36
+ ".entry-content",
37
+ ".article-body",
38
+ )
39
+ DEFAULT_SEARCH_SELECTORS: str = "form[role='search'], input[type='search'], [id*='search' i], [class*='search' i]"
40
+
41
+
42
+ def compile_pattern(pattern: OptionalPattern) -> re.Pattern[str] | None:
43
+ if not pattern:
44
+ return None
45
+ if isinstance(pattern, str):
46
+ return re.compile(pattern)
47
+ return pattern
48
+
49
+
50
+ def compile_patterns(patterns: list[OptionalPattern] | None) -> list[re.Pattern[str]]:
51
+ if not patterns:
52
+ return []
53
+ return [compiled for p in patterns if (compiled := compile_pattern(p)) is not None]
54
+
55
+
56
+ class LinkFilterSettings:
57
+ scrape: list[re.Pattern[str]]
58
+ visit_only: list[re.Pattern[str]]
59
+ blocked: list[re.Pattern[str]]
60
+ strip_query: bool | list[re.Pattern[str]]
61
+
62
+ def __init__(
63
+ self,
64
+ scrape: list[OptionalPattern] | None = None,
65
+ visit_only: list[OptionalPattern] | None = None,
66
+ blocked: list[OptionalPattern] | None = None,
67
+ strip_query: bool | list[OptionalPattern] = False,
68
+ ) -> None:
69
+ self.scrape = compile_patterns(scrape)
70
+ self.visit_only = compile_patterns(visit_only)
71
+ self.blocked = compile_patterns(blocked)
72
+ if isinstance(strip_query, bool):
73
+ self.strip_query = strip_query
74
+ else:
75
+ self.strip_query = compile_patterns(strip_query)
76
+
77
+ def is_blocked(self, url: str) -> bool:
78
+ """
79
+ Returns True if the URL matches any of the patterns in self.blocked
80
+ """
81
+ return any(p.search(url) for p in self.blocked)
82
+
83
+ def should_scrape(self, url: str) -> bool:
84
+ """
85
+ Returns True if the URL matches any scrape pattern and is not blocked.
86
+
87
+ Empty scrape patterns mean nothing is scraped (allowlist-first).
88
+ """
89
+ return not self.is_blocked(url) and any(p.search(url) for p in self.scrape)
90
+
91
+ def should_visit(self, url: str) -> bool:
92
+ """
93
+ Returns True if the URL matches scrape or visit_only and is not blocked.
94
+
95
+ Empty patterns mean discovered links are not enqueued (start URLs only).
96
+ """
97
+ return not self.is_blocked(url) and any(p.search(url) for p in itertools.chain(self.scrape, self.visit_only))
98
+
99
+ def should_strip_query(self, url: str) -> bool:
100
+ """
101
+ Whether query params should be dropped for this URL.
102
+
103
+ ``False`` keeps them (default). ``True`` strips always. A pattern list
104
+ strips only when the URL matches.
105
+ """
106
+ if self.strip_query is False:
107
+ return False
108
+ if self.strip_query is True:
109
+ return True
110
+ return any(p.search(url) for p in self.strip_query)
111
+
112
+
113
+ @dataclass(slots=True)
114
+ class CleanSettings:
115
+ """
116
+ Page cleaning policy.
117
+
118
+ When fields are left unset, built-in defaults apply. Explicit user values
119
+ win on conflict (e.g. a custom allowlist replaces the default attribute
120
+ set; ``keep_tags`` prevents default noise removal).
121
+ """
122
+
123
+ content_selectors: list[str] | None = None
124
+ url_content_selectors: list[tuple[OptionalPattern, str]] = field(default_factory=list)
125
+ remove_selectors: list[str] = field(default_factory=list)
126
+ noise_tags: list[str] | None = None
127
+ extra_noise_tags: list[str] = field(default_factory=list)
128
+ keep_tags: list[str] = field(default_factory=list)
129
+ allowed_attributes: set[str] | None = None
130
+ extra_allowed_attributes: set[str] = field(default_factory=set)
131
+ disallowed_attributes: set[str] = field(default_factory=set)
132
+ preserve_header_nav_with_search: bool = True
133
+ search_selectors: str = DEFAULT_SEARCH_SELECTORS
134
+
135
+ @classmethod
136
+ def defaults(cls) -> CleanSettings:
137
+ """
138
+ Built-in fallback policy used when user settings yield no content.
139
+ """
140
+ return cls()
141
+
142
+ def is_customized(self) -> bool:
143
+ """
144
+ True when any field differs from an empty/default construction.
145
+ """
146
+ baseline = CleanSettings.defaults()
147
+ return (
148
+ self.content_selectors is not None
149
+ or bool(self.url_content_selectors)
150
+ or bool(self.remove_selectors)
151
+ or self.noise_tags is not None
152
+ or bool(self.extra_noise_tags)
153
+ or bool(self.keep_tags)
154
+ or self.allowed_attributes is not None
155
+ or bool(self.extra_allowed_attributes)
156
+ or bool(self.disallowed_attributes)
157
+ or self.preserve_header_nav_with_search != baseline.preserve_header_nav_with_search
158
+ or self.search_selectors != baseline.search_selectors
159
+ )
160
+
161
+ def effective_noise_tags(self, *, emergency: bool = False) -> set[str]:
162
+ if emergency:
163
+ tags = set(EMERGENCY_NOISE_TAGS)
164
+ elif self.noise_tags is not None:
165
+ tags = {t.lower() for t in self.noise_tags}
166
+ else:
167
+ tags = {t.lower() for t in DEFAULT_NOISE_TAGS}
168
+ tags.update(t.lower() for t in self.extra_noise_tags)
169
+ tags.difference_update(t.lower() for t in self.keep_tags)
170
+ return tags
171
+
172
+ def effective_allowed_attributes(self) -> set[str]:
173
+ if self.allowed_attributes is not None:
174
+ allowed = {a.lower() for a in self.allowed_attributes}
175
+ else:
176
+ allowed = {a.lower() for a in DEFAULT_ALLOWED_ATTRIBUTES}
177
+ allowed.update(a.lower() for a in self.extra_allowed_attributes)
178
+ allowed.difference_update(a.lower() for a in self.disallowed_attributes)
179
+ return allowed
180
+
181
+ def effective_content_selector(self, url: str) -> str:
182
+ """
183
+ Comma-joined CSS selector list for this URL.
184
+
185
+ URL-specific selectors are prepended so they win over global/default ones.
186
+ """
187
+ matched: list[str] = []
188
+ for pattern, selector in self.url_content_selectors:
189
+ compiled = compile_pattern(pattern)
190
+ if compiled and compiled.search(url):
191
+ matched.append(selector)
192
+ if self.content_selectors is not None:
193
+ base = list(self.content_selectors)
194
+ else:
195
+ base = list(DEFAULT_CONTENT_SELECTORS)
196
+ combined = matched + base
197
+ return ", ".join(s for s in combined if s)