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.
- corekit/connections/sql/connection.py +6 -2
- corekit/html/__init__.py +22 -0
- corekit/html/client.py +22 -0
- corekit/html/models.py +20 -0
- corekit/html/parser.py +272 -0
- corekit/html/scraper.py +127 -0
- corekit/html/settings.py +197 -0
- corekit/html/state.py +56 -0
- corekit/http/__init__.py +2 -1
- corekit/http/api.py +71 -3
- corekit/http/client.py +24 -54
- corekit/http/response.py +38 -5
- corekit/llm/client.py +3 -2
- corekit/llm/tools/loop.py +5 -2
- corekit/llm/wire.py +11 -5
- corekit/schemas/__init__.py +8 -1
- corekit/schemas/models/arbitrary.py +49 -5
- corekit/utils/__init__.py +48 -7
- corekit/utils/coercion.py +45 -59
- corekit/utils/collections.py +136 -3
- corekit/utils/parsers.py +205 -0
- {python_corekit-0.4.1.dist-info → python_corekit-0.5.0.dist-info}/METADATA +8 -7
- {python_corekit-0.4.1.dist-info → python_corekit-0.5.0.dist-info}/RECORD +26 -18
- {python_corekit-0.4.1.dist-info → python_corekit-0.5.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.4.1.dist-info → python_corekit-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.4.1.dist-info → python_corekit-0.5.0.dist-info}/top_level.txt +0 -0
|
@@ -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) ->
|
|
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
|
-
|
|
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
|
"""
|
corekit/html/__init__.py
ADDED
|
@@ -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")
|
corekit/html/scraper.py
ADDED
|
@@ -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()
|
corekit/html/settings.py
ADDED
|
@@ -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)
|