wintergrab 0.2.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.
- wintergrab/__init__.py +91 -0
- wintergrab/__main__.py +5 -0
- wintergrab/adaptive/__init__.py +16 -0
- wintergrab/adaptive/fingerprint.py +286 -0
- wintergrab/adaptive/storage.py +142 -0
- wintergrab/cli.py +786 -0
- wintergrab/errors.py +77 -0
- wintergrab/fetchers/__init__.py +143 -0
- wintergrab/fetchers/blocking.py +63 -0
- wintergrab/fetchers/browser.py +897 -0
- wintergrab/fetchers/cache.py +407 -0
- wintergrab/fetchers/http.py +603 -0
- wintergrab/fetchers/response.py +404 -0
- wintergrab/parser/__init__.py +12 -0
- wintergrab/parser/autoextract.py +1440 -0
- wintergrab/parser/css.py +136 -0
- wintergrab/parser/extract.py +117 -0
- wintergrab/parser/selector.py +915 -0
- wintergrab/parser/structured.py +889 -0
- wintergrab/parser/text.py +331 -0
- wintergrab/proxy.py +200 -0
- wintergrab/py.typed +0 -0
- wintergrab/request.py +171 -0
- wintergrab/sitemaps.py +201 -0
- wintergrab/spider/__init__.py +8 -0
- wintergrab/spider/checkpoint.py +65 -0
- wintergrab/spider/engine.py +931 -0
- wintergrab/spider/exporters.py +376 -0
- wintergrab/spider/frontier.py +803 -0
- wintergrab/spider/progress.py +145 -0
- wintergrab/spider/robots.py +77 -0
- wintergrab/spider/scheduler.py +111 -0
- wintergrab/spider/sessions.py +73 -0
- wintergrab/spider/spider.py +448 -0
- wintergrab/spider/throttle.py +165 -0
- wintergrab/utils.py +187 -0
- wintergrab-0.2.0.dist-info/METADATA +303 -0
- wintergrab-0.2.0.dist-info/RECORD +41 -0
- wintergrab-0.2.0.dist-info/WHEEL +4 -0
- wintergrab-0.2.0.dist-info/entry_points.txt +2 -0
- wintergrab-0.2.0.dist-info/licenses/LICENSE +21 -0
wintergrab/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""wintergrab - friendly web scraping that scales from one page to big crawls.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import wintergrab as wg
|
|
6
|
+
|
|
7
|
+
page = wg.get("https://quotes.toscrape.com/")
|
|
8
|
+
for quote in page.css(".quote"):
|
|
9
|
+
print(quote.css(".text::text").get(), "-", quote.css(".author::text").get())
|
|
10
|
+
|
|
11
|
+
See https://github.com/opensourcewinter/wintergrab for the full docs.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
__version__ = "0.2.0"
|
|
17
|
+
|
|
18
|
+
# The parser must be imported before the adaptive package (they reference each other).
|
|
19
|
+
from .parser import Field, Selector, SelectorList, parse # isort: skip
|
|
20
|
+
from .adaptive import MemoryStorage, SQLiteStorage
|
|
21
|
+
from .errors import (
|
|
22
|
+
BrowserNotAvailable,
|
|
23
|
+
CheckpointError,
|
|
24
|
+
FetchError,
|
|
25
|
+
HTTPStatusError,
|
|
26
|
+
SelectorSyntaxError,
|
|
27
|
+
WintergrabError,
|
|
28
|
+
)
|
|
29
|
+
from .fetchers import (
|
|
30
|
+
AsyncBrowserFetcher,
|
|
31
|
+
AsyncFetcher,
|
|
32
|
+
BrowserFetcher,
|
|
33
|
+
CacheMiss,
|
|
34
|
+
CapturedResponse,
|
|
35
|
+
Fetcher,
|
|
36
|
+
HTTPCache,
|
|
37
|
+
Response,
|
|
38
|
+
aget,
|
|
39
|
+
apost,
|
|
40
|
+
arender,
|
|
41
|
+
get,
|
|
42
|
+
post,
|
|
43
|
+
render,
|
|
44
|
+
)
|
|
45
|
+
from .parser.autoextract import LearnedSchema, RecordGroup
|
|
46
|
+
from .proxy import ProxyRotator
|
|
47
|
+
from .request import Request
|
|
48
|
+
from .sitemaps import SitemapEntry, sitemap
|
|
49
|
+
from .spider import AutoThrottle, CrawlResult, SessionManager, Spider
|
|
50
|
+
from .utils import configure_logging
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"AsyncBrowserFetcher",
|
|
54
|
+
"AsyncFetcher",
|
|
55
|
+
"AutoThrottle",
|
|
56
|
+
"BrowserFetcher",
|
|
57
|
+
"BrowserNotAvailable",
|
|
58
|
+
"CacheMiss",
|
|
59
|
+
"CapturedResponse",
|
|
60
|
+
"CheckpointError",
|
|
61
|
+
"CrawlResult",
|
|
62
|
+
"FetchError",
|
|
63
|
+
"Fetcher",
|
|
64
|
+
"Field",
|
|
65
|
+
"HTTPCache",
|
|
66
|
+
"HTTPStatusError",
|
|
67
|
+
"LearnedSchema",
|
|
68
|
+
"MemoryStorage",
|
|
69
|
+
"ProxyRotator",
|
|
70
|
+
"RecordGroup",
|
|
71
|
+
"Request",
|
|
72
|
+
"Response",
|
|
73
|
+
"SQLiteStorage",
|
|
74
|
+
"Selector",
|
|
75
|
+
"SelectorList",
|
|
76
|
+
"SelectorSyntaxError",
|
|
77
|
+
"SessionManager",
|
|
78
|
+
"SitemapEntry",
|
|
79
|
+
"Spider",
|
|
80
|
+
"WintergrabError",
|
|
81
|
+
"__version__",
|
|
82
|
+
"aget",
|
|
83
|
+
"apost",
|
|
84
|
+
"arender",
|
|
85
|
+
"configure_logging",
|
|
86
|
+
"get",
|
|
87
|
+
"parse",
|
|
88
|
+
"post",
|
|
89
|
+
"render",
|
|
90
|
+
"sitemap",
|
|
91
|
+
]
|
wintergrab/__main__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Adaptive selectors: remember elements and find them again after a redesign."""
|
|
2
|
+
|
|
3
|
+
from .fingerprint import fingerprint, relocate, similar_elements, similarity
|
|
4
|
+
from .storage import AdaptiveStorage, MemoryStorage, SQLiteStorage, default_storage, default_storage_path
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AdaptiveStorage",
|
|
8
|
+
"MemoryStorage",
|
|
9
|
+
"SQLiteStorage",
|
|
10
|
+
"default_storage",
|
|
11
|
+
"default_storage_path",
|
|
12
|
+
"fingerprint",
|
|
13
|
+
"relocate",
|
|
14
|
+
"similar_elements",
|
|
15
|
+
"similarity",
|
|
16
|
+
]
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Element fingerprints and similarity scoring for adaptive selectors.
|
|
2
|
+
|
|
3
|
+
A fingerprint captures what an element *looks like* (tag, attributes, text)
|
|
4
|
+
and *where it lives* (ancestor path, parent, siblings, children). When a
|
|
5
|
+
selector stops matching after a site redesign, every element of the new page
|
|
6
|
+
is scored against the saved fingerprint and the closest match wins.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
13
|
+
from difflib import SequenceMatcher
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from lxml import etree
|
|
17
|
+
|
|
18
|
+
from ..parser.text import own_text, tag_name
|
|
19
|
+
|
|
20
|
+
MAX_TEXT = 200
|
|
21
|
+
MAX_ATTR = 200
|
|
22
|
+
|
|
23
|
+
# Attributes whose *values* differ between otherwise identical items
|
|
24
|
+
# (links, images, ids...). For these only the presence of the key is compared.
|
|
25
|
+
VOLATILE_ATTRS = frozenset(
|
|
26
|
+
{"href", "src", "srcset", "alt", "title", "id", "value", "content", "datetime", "style", "name", "for", "action"}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
W_TAG = 1.0
|
|
30
|
+
W_ATTRS = 1.0
|
|
31
|
+
W_TEXT = 1.5
|
|
32
|
+
W_PATH = 1.0
|
|
33
|
+
W_PARENT = 0.75
|
|
34
|
+
W_SIBLINGS = 0.5
|
|
35
|
+
W_CHILDREN = 0.5
|
|
36
|
+
W_POSITION = 0.25
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _attrs(el: etree._Element) -> dict[str, str]:
|
|
40
|
+
return {str(k): str(v)[:MAX_ATTR] for k, v in el.attrib.items()}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _elements(nodes: Iterable[Any]) -> list[etree._Element]:
|
|
44
|
+
return [n for n in nodes if isinstance(n.tag, str)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Family:
|
|
48
|
+
"""Per-parent facts shared by all of its children (tag counts, positions)."""
|
|
49
|
+
|
|
50
|
+
__slots__ = ("counts", "parent_info", "positions")
|
|
51
|
+
|
|
52
|
+
def __init__(self, parent: etree._Element) -> None:
|
|
53
|
+
self.counts: Counter[str] = Counter()
|
|
54
|
+
self.positions: dict[etree._Element, int] = {}
|
|
55
|
+
seen: Counter[Any] = Counter()
|
|
56
|
+
for child in parent:
|
|
57
|
+
if not isinstance(child.tag, str):
|
|
58
|
+
continue
|
|
59
|
+
self.counts[tag_name(child)] += 1
|
|
60
|
+
self.positions[child] = seen[child.tag]
|
|
61
|
+
seen[child.tag] += 1
|
|
62
|
+
self.parent_info = {"tag": tag_name(parent), "attrs": _attrs(parent), "text": own_text(parent)[:MAX_TEXT]}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fingerprint(el: etree._Element, _cache: dict[Any, _Family] | None = None) -> dict[str, Any]:
|
|
66
|
+
"""Describe ``el`` as a JSON-serialisable dict.
|
|
67
|
+
|
|
68
|
+
``_cache`` lets :func:`relocate` share per-parent work between siblings, so
|
|
69
|
+
fingerprinting a page with thousands of rows stays linear.
|
|
70
|
+
"""
|
|
71
|
+
parent = el.getparent()
|
|
72
|
+
tag = tag_name(el)
|
|
73
|
+
fp: dict[str, Any] = {
|
|
74
|
+
"tag": tag,
|
|
75
|
+
"attrs": _attrs(el),
|
|
76
|
+
"text": own_text(el)[:MAX_TEXT],
|
|
77
|
+
"path": [tag_name(a) for a in el.iterancestors()][::-1],
|
|
78
|
+
"children": dict(Counter(tag_name(c) for c in _elements(el))),
|
|
79
|
+
"parent": None,
|
|
80
|
+
"siblings": {},
|
|
81
|
+
"position": 0,
|
|
82
|
+
}
|
|
83
|
+
if parent is not None:
|
|
84
|
+
family = _cache.get(parent) if _cache is not None else None
|
|
85
|
+
if family is None:
|
|
86
|
+
family = _Family(parent)
|
|
87
|
+
if _cache is not None:
|
|
88
|
+
_cache[parent] = family
|
|
89
|
+
siblings = family.counts.copy()
|
|
90
|
+
siblings[tag] -= 1
|
|
91
|
+
fp["parent"] = dict(family.parent_info)
|
|
92
|
+
fp["siblings"] = {k: v for k, v in siblings.items() if v > 0}
|
|
93
|
+
fp["position"] = family.positions.get(el, 0)
|
|
94
|
+
return fp
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --------------------------------------------------------------------------- #
|
|
98
|
+
# similarity primitives
|
|
99
|
+
# --------------------------------------------------------------------------- #
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ratio(a: str | Sequence[str], b: str | Sequence[str]) -> float:
|
|
103
|
+
if a == b:
|
|
104
|
+
return 1.0
|
|
105
|
+
if not a or not b:
|
|
106
|
+
return 0.0
|
|
107
|
+
return SequenceMatcher(None, a, b, autojunk=False).ratio()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _counter_overlap(a: dict[str, int], b: dict[str, int]) -> float:
|
|
111
|
+
"""Multiset similarity: shared counts over total counts."""
|
|
112
|
+
if not a and not b:
|
|
113
|
+
return 1.0
|
|
114
|
+
keys = set(a) | set(b)
|
|
115
|
+
shared = sum(min(a.get(k, 0), b.get(k, 0)) for k in keys)
|
|
116
|
+
total = sum(max(a.get(k, 0), b.get(k, 0)) for k in keys)
|
|
117
|
+
return shared / total if total else 1.0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _class_similarity(a: str, b: str) -> float:
|
|
121
|
+
"""Fuzzy token-set similarity so ``product`` ~ ``product-card``."""
|
|
122
|
+
ta, tb = set(a.split()), set(b.split())
|
|
123
|
+
if ta == tb:
|
|
124
|
+
return 1.0
|
|
125
|
+
if not ta or not tb:
|
|
126
|
+
return 0.0
|
|
127
|
+
|
|
128
|
+
def directed(x: set[str], y: set[str]) -> float:
|
|
129
|
+
return sum(max(_ratio(t, u) for u in y) for t in x) / len(x)
|
|
130
|
+
|
|
131
|
+
return (directed(ta, tb) + directed(tb, ta)) / 2
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def attrs_similarity(
|
|
135
|
+
a: dict[str, str],
|
|
136
|
+
b: dict[str, str],
|
|
137
|
+
*,
|
|
138
|
+
ignore_values: frozenset[str] = frozenset(),
|
|
139
|
+
partial_values: frozenset[str] = frozenset(),
|
|
140
|
+
) -> float:
|
|
141
|
+
"""0..1 similarity of two attribute dicts (class compared as fuzzy token sets).
|
|
142
|
+
|
|
143
|
+
Keys in ``ignore_values`` (and ``data-*`` when it is set) only need to be
|
|
144
|
+
present on both sides; keys in ``partial_values`` get half credit for
|
|
145
|
+
being present and half for how similar their values are.
|
|
146
|
+
"""
|
|
147
|
+
keys = set(a) | set(b)
|
|
148
|
+
if not keys:
|
|
149
|
+
return 1.0
|
|
150
|
+
total = 0.0
|
|
151
|
+
for key in keys:
|
|
152
|
+
if key not in a or key not in b:
|
|
153
|
+
continue
|
|
154
|
+
if key == "class":
|
|
155
|
+
total += _class_similarity(a[key], b[key])
|
|
156
|
+
elif key in ignore_values or (ignore_values and key.startswith("data-")):
|
|
157
|
+
total += 1.0 # same key present on both; the value is expected to differ
|
|
158
|
+
elif key in partial_values or (partial_values and key.startswith("data-")):
|
|
159
|
+
total += 0.5 + 0.5 * _ratio(a[key], b[key])
|
|
160
|
+
else:
|
|
161
|
+
total += _ratio(a[key], b[key])
|
|
162
|
+
return total / len(keys)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parent_similarity(sp: dict[str, Any] | None, cp: dict[str, Any] | None) -> float:
|
|
166
|
+
if not sp or not cp:
|
|
167
|
+
return 1.0 if sp == cp else 0.0
|
|
168
|
+
text = _ratio(sp["text"], cp["text"]) if (sp["text"] or cp["text"]) else 1.0
|
|
169
|
+
return ((1.0 if sp["tag"] == cp["tag"] else 0.0) + attrs_similarity(sp["attrs"], cp["attrs"]) + text) / 3
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def similarity(saved: dict[str, Any], candidate: dict[str, Any], floor: float = 0.0) -> float:
|
|
173
|
+
"""Weighted 0..1 similarity between two fingerprints.
|
|
174
|
+
|
|
175
|
+
Components are computed cheapest first; if the score provably cannot reach
|
|
176
|
+
``floor`` the function stops early and returns a value below ``floor``.
|
|
177
|
+
"""
|
|
178
|
+
has_text = bool(saved["text"] or candidate["text"])
|
|
179
|
+
components: list[tuple[float, Callable[[], float]]] = [
|
|
180
|
+
(W_TAG, lambda: 1.0 if saved["tag"] == candidate["tag"] else 0.0),
|
|
181
|
+
(W_POSITION, lambda: 1.0 / (1 + abs(saved.get("position", 0) - candidate.get("position", 0)))),
|
|
182
|
+
(W_CHILDREN, lambda: _counter_overlap(saved["children"], candidate["children"])),
|
|
183
|
+
(W_SIBLINGS, lambda: _counter_overlap(saved["siblings"], candidate["siblings"])),
|
|
184
|
+
(W_PATH, lambda: _ratio(saved["path"], candidate["path"])),
|
|
185
|
+
(W_ATTRS, lambda: attrs_similarity(saved["attrs"], candidate["attrs"], partial_values=VOLATILE_ATTRS)),
|
|
186
|
+
(W_PARENT, lambda: _parent_similarity(saved.get("parent"), candidate.get("parent"))),
|
|
187
|
+
]
|
|
188
|
+
if has_text:
|
|
189
|
+
components.append((W_TEXT, lambda: _ratio(saved["text"], candidate["text"])))
|
|
190
|
+
total_weight = sum(w for w, _ in components)
|
|
191
|
+
target = floor * total_weight
|
|
192
|
+
score = 0.0
|
|
193
|
+
remaining = total_weight
|
|
194
|
+
for weight, compute in components:
|
|
195
|
+
remaining -= weight
|
|
196
|
+
score += weight * compute()
|
|
197
|
+
if score + remaining < target:
|
|
198
|
+
return (score + remaining) / total_weight - 1e-9
|
|
199
|
+
return score / total_weight
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# --------------------------------------------------------------------------- #
|
|
203
|
+
# relocation
|
|
204
|
+
# --------------------------------------------------------------------------- #
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def relocate(
|
|
208
|
+
root: etree._Element,
|
|
209
|
+
record: dict[str, Any],
|
|
210
|
+
*,
|
|
211
|
+
min_score: float = 0.55,
|
|
212
|
+
) -> tuple[list[etree._Element], float]:
|
|
213
|
+
"""Find the elements in ``root`` that best match a saved record.
|
|
214
|
+
|
|
215
|
+
Returns the matching elements (in document order) and the best score.
|
|
216
|
+
When the saved selector originally matched several elements (a list of
|
|
217
|
+
products, say) the best match is expanded to its structurally similar
|
|
218
|
+
siblings with :func:`similar_elements`.
|
|
219
|
+
"""
|
|
220
|
+
saved_fps: list[dict[str, Any]] = record.get("elements") or []
|
|
221
|
+
if not saved_fps:
|
|
222
|
+
return [], 0.0
|
|
223
|
+
candidates = [el for el in root.iter(etree.Element) if isinstance(el.tag, str)]
|
|
224
|
+
if not candidates:
|
|
225
|
+
return [], 0.0
|
|
226
|
+
cache: dict[Any, _Family] = {}
|
|
227
|
+
cand_fps = [fingerprint(el, cache) for el in candidates]
|
|
228
|
+
|
|
229
|
+
matches: dict[int, float] = {}
|
|
230
|
+
best_score, best_index = 0.0, -1
|
|
231
|
+
for saved in saved_fps:
|
|
232
|
+
top_score, top_index = 0.0, -1
|
|
233
|
+
for i, fp in enumerate(cand_fps):
|
|
234
|
+
s = similarity(saved, fp, floor=max(top_score, min_score * 0.5))
|
|
235
|
+
if s > top_score:
|
|
236
|
+
top_score, top_index = s, i
|
|
237
|
+
if top_index >= 0 and top_score >= min_score:
|
|
238
|
+
matches[top_index] = max(matches.get(top_index, 0.0), top_score)
|
|
239
|
+
if top_score > best_score:
|
|
240
|
+
best_score, best_index = top_score, top_index
|
|
241
|
+
|
|
242
|
+
if best_score < min_score:
|
|
243
|
+
return [], best_score
|
|
244
|
+
|
|
245
|
+
found = {candidates[i] for i in matches}
|
|
246
|
+
if int(record.get("count", 1)) > 1:
|
|
247
|
+
found.update(similar_elements(candidates[best_index], root=root))
|
|
248
|
+
order = {el: i for i, el in enumerate(candidates)}
|
|
249
|
+
return sorted(found, key=lambda el: order.get(el, 0)), best_score
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def similar_elements(
|
|
253
|
+
el: etree._Element,
|
|
254
|
+
*,
|
|
255
|
+
root: etree._Element | None = None,
|
|
256
|
+
threshold: float = 0.5,
|
|
257
|
+
ignore_attributes: Iterable[str] = VOLATILE_ATTRS,
|
|
258
|
+
) -> list[etree._Element]:
|
|
259
|
+
"""Elements that look structurally like ``el`` (e.g. the other cards in a grid).
|
|
260
|
+
|
|
261
|
+
Candidates must share the tag, depth, parent tag and grandparent tag of
|
|
262
|
+
``el``; they are then scored on attribute similarity (values of volatile
|
|
263
|
+
attributes like ``href`` are ignored) and on the shape of their children.
|
|
264
|
+
"""
|
|
265
|
+
if not isinstance(el.tag, str):
|
|
266
|
+
return []
|
|
267
|
+
if root is None:
|
|
268
|
+
root = el.getroottree().getroot()
|
|
269
|
+
ignore = frozenset(ignore_attributes)
|
|
270
|
+
ancestors = [tag_name(a) for a in el.iterancestors()]
|
|
271
|
+
depth = len(ancestors)
|
|
272
|
+
lineage = ancestors[:2]
|
|
273
|
+
base_attrs = _attrs(el)
|
|
274
|
+
base_children = dict(Counter(tag_name(c) for c in _elements(el)))
|
|
275
|
+
out = []
|
|
276
|
+
for cand in root.iter(el.tag):
|
|
277
|
+
if cand is el:
|
|
278
|
+
continue
|
|
279
|
+
cand_ancestors = [tag_name(a) for a in cand.iterancestors()]
|
|
280
|
+
if len(cand_ancestors) != depth or cand_ancestors[:2] != lineage:
|
|
281
|
+
continue
|
|
282
|
+
attr_score = attrs_similarity(base_attrs, _attrs(cand), ignore_values=ignore)
|
|
283
|
+
child_score = _counter_overlap(base_children, dict(Counter(tag_name(c) for c in _elements(cand))))
|
|
284
|
+
if 0.6 * attr_score + 0.4 * child_score >= threshold:
|
|
285
|
+
out.append(cand)
|
|
286
|
+
return out
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Where adaptive selectors remember the elements they matched."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Protocol, runtime_checkable
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class AdaptiveStorage(Protocol):
|
|
18
|
+
"""Anything with ``save``/``load``/``delete`` can store element fingerprints."""
|
|
19
|
+
|
|
20
|
+
def save(self, domain: str, identifier: str, record: dict[str, Any]) -> None: ...
|
|
21
|
+
|
|
22
|
+
def load(self, domain: str, identifier: str) -> dict[str, Any] | None: ...
|
|
23
|
+
|
|
24
|
+
def delete(self, domain: str, identifier: str) -> None: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MemoryStorage:
|
|
28
|
+
"""Keeps fingerprints in a dict. Handy for tests and short scripts."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._data: dict[tuple[str, str], dict[str, Any]] = {}
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
def save(self, domain: str, identifier: str, record: dict[str, Any]) -> None:
|
|
35
|
+
with self._lock:
|
|
36
|
+
self._data[(domain, identifier)] = json.loads(json.dumps(record))
|
|
37
|
+
|
|
38
|
+
def load(self, domain: str, identifier: str) -> dict[str, Any] | None:
|
|
39
|
+
with self._lock:
|
|
40
|
+
return self._data.get((domain, identifier))
|
|
41
|
+
|
|
42
|
+
def delete(self, domain: str, identifier: str) -> None:
|
|
43
|
+
with self._lock:
|
|
44
|
+
self._data.pop((domain, identifier), None)
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
return len(self._data)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SQLiteStorage:
|
|
51
|
+
"""Stores fingerprints in a small SQLite database (safe across threads).
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
path: Database file. Defaults to :func:`default_storage_path`.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, path: str | os.PathLike[str] | None = None) -> None:
|
|
58
|
+
self.path = Path(path) if path else default_storage_path()
|
|
59
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self._lock = threading.Lock()
|
|
61
|
+
self._conn = sqlite3.connect(str(self.path), check_same_thread=False, isolation_level=None)
|
|
62
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
63
|
+
self._conn.execute(
|
|
64
|
+
"CREATE TABLE IF NOT EXISTS elements ("
|
|
65
|
+
" domain TEXT NOT NULL, identifier TEXT NOT NULL, record TEXT NOT NULL, updated REAL NOT NULL,"
|
|
66
|
+
" PRIMARY KEY (domain, identifier))"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def save(self, domain: str, identifier: str, record: dict[str, Any]) -> None:
|
|
70
|
+
payload = json.dumps(record, ensure_ascii=False)
|
|
71
|
+
with self._lock:
|
|
72
|
+
self._conn.execute(
|
|
73
|
+
"INSERT OR REPLACE INTO elements (domain, identifier, record, updated) VALUES (?, ?, ?, ?)",
|
|
74
|
+
(domain, identifier, payload, time.time()),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def load(self, domain: str, identifier: str) -> dict[str, Any] | None:
|
|
78
|
+
with self._lock:
|
|
79
|
+
row = self._conn.execute(
|
|
80
|
+
"SELECT record FROM elements WHERE domain = ? AND identifier = ?", (domain, identifier)
|
|
81
|
+
).fetchone()
|
|
82
|
+
return json.loads(row[0]) if row else None
|
|
83
|
+
|
|
84
|
+
def delete(self, domain: str, identifier: str) -> None:
|
|
85
|
+
with self._lock:
|
|
86
|
+
self._conn.execute("DELETE FROM elements WHERE domain = ? AND identifier = ?", (domain, identifier))
|
|
87
|
+
|
|
88
|
+
def identifiers(self, domain: str | None = None) -> list[tuple[str, str]]:
|
|
89
|
+
"""List saved ``(domain, identifier)`` pairs."""
|
|
90
|
+
with self._lock:
|
|
91
|
+
if domain is None:
|
|
92
|
+
rows = self._conn.execute("SELECT domain, identifier FROM elements ORDER BY domain, identifier")
|
|
93
|
+
else:
|
|
94
|
+
rows = self._conn.execute(
|
|
95
|
+
"SELECT domain, identifier FROM elements WHERE domain = ? ORDER BY identifier", (domain,)
|
|
96
|
+
)
|
|
97
|
+
return [(r[0], r[1]) for r in rows.fetchall()]
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
with self._lock:
|
|
101
|
+
self._conn.close()
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
return f"SQLiteStorage({str(self.path)!r})"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def default_storage_path() -> Path:
|
|
108
|
+
"""``$WINTERGRAB_ADAPTIVE_DB`` or a file in the user's cache directory."""
|
|
109
|
+
env = os.environ.get("WINTERGRAB_ADAPTIVE_DB")
|
|
110
|
+
if env:
|
|
111
|
+
return Path(env).expanduser()
|
|
112
|
+
if sys.platform == "win32":
|
|
113
|
+
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
|
114
|
+
elif sys.platform == "darwin":
|
|
115
|
+
base = Path.home() / "Library" / "Caches"
|
|
116
|
+
else:
|
|
117
|
+
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
|
|
118
|
+
return base / "wintergrab" / "adaptive.sqlite3"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_default: SQLiteStorage | None = None
|
|
122
|
+
_default_lock = threading.Lock()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def default_storage() -> SQLiteStorage:
|
|
126
|
+
"""The shared default :class:`SQLiteStorage` (created on first use)."""
|
|
127
|
+
global _default
|
|
128
|
+
with _default_lock:
|
|
129
|
+
path = default_storage_path()
|
|
130
|
+
if _default is None or _default.path != path:
|
|
131
|
+
_default = SQLiteStorage(path)
|
|
132
|
+
return _default
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def storage_key(url: str | None) -> str:
|
|
136
|
+
"""The domain part of the storage key for a page URL."""
|
|
137
|
+
if not url:
|
|
138
|
+
return "default"
|
|
139
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
140
|
+
if host.startswith("www."):
|
|
141
|
+
host = host[4:]
|
|
142
|
+
return host or "default"
|