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.
Files changed (41) hide show
  1. wintergrab/__init__.py +91 -0
  2. wintergrab/__main__.py +5 -0
  3. wintergrab/adaptive/__init__.py +16 -0
  4. wintergrab/adaptive/fingerprint.py +286 -0
  5. wintergrab/adaptive/storage.py +142 -0
  6. wintergrab/cli.py +786 -0
  7. wintergrab/errors.py +77 -0
  8. wintergrab/fetchers/__init__.py +143 -0
  9. wintergrab/fetchers/blocking.py +63 -0
  10. wintergrab/fetchers/browser.py +897 -0
  11. wintergrab/fetchers/cache.py +407 -0
  12. wintergrab/fetchers/http.py +603 -0
  13. wintergrab/fetchers/response.py +404 -0
  14. wintergrab/parser/__init__.py +12 -0
  15. wintergrab/parser/autoextract.py +1440 -0
  16. wintergrab/parser/css.py +136 -0
  17. wintergrab/parser/extract.py +117 -0
  18. wintergrab/parser/selector.py +915 -0
  19. wintergrab/parser/structured.py +889 -0
  20. wintergrab/parser/text.py +331 -0
  21. wintergrab/proxy.py +200 -0
  22. wintergrab/py.typed +0 -0
  23. wintergrab/request.py +171 -0
  24. wintergrab/sitemaps.py +201 -0
  25. wintergrab/spider/__init__.py +8 -0
  26. wintergrab/spider/checkpoint.py +65 -0
  27. wintergrab/spider/engine.py +931 -0
  28. wintergrab/spider/exporters.py +376 -0
  29. wintergrab/spider/frontier.py +803 -0
  30. wintergrab/spider/progress.py +145 -0
  31. wintergrab/spider/robots.py +77 -0
  32. wintergrab/spider/scheduler.py +111 -0
  33. wintergrab/spider/sessions.py +73 -0
  34. wintergrab/spider/spider.py +448 -0
  35. wintergrab/spider/throttle.py +165 -0
  36. wintergrab/utils.py +187 -0
  37. wintergrab-0.2.0.dist-info/METADATA +303 -0
  38. wintergrab-0.2.0.dist-info/RECORD +41 -0
  39. wintergrab-0.2.0.dist-info/WHEEL +4 -0
  40. wintergrab-0.2.0.dist-info/entry_points.txt +2 -0
  41. 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,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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"