nostrax 2.0.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.
nostrax/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """nostrax - Extract URLs and paths from web pages.
2
+
3
+ Copyright (c) 2024 prodrom3 / radamic
4
+ Licensed under the MIT License.
5
+ """
6
+
7
+ from importlib import metadata as _metadata
8
+
9
+ try:
10
+ __version__ = _metadata.version("nostrax")
11
+ except _metadata.PackageNotFoundError:
12
+ # Running from a checkout without an install (e.g. in-tree tests).
13
+ __version__ = "0.0.0+unknown"
14
+
15
+ from nostrax.extractor import extract_urls
16
+ from nostrax.content import PageContent, extract_content
17
+ from nostrax.crawler import crawl, crawl_async, crawl_seeds, crawl_seeds_async
18
+ from nostrax.models import UrlResult
19
+ from nostrax.normalize import normalize_url
20
+ from nostrax.exceptions import NostraxError
21
+
22
+ __all__ = [
23
+ "extract_urls",
24
+ "extract_content",
25
+ "PageContent",
26
+ "crawl",
27
+ "crawl_async",
28
+ "crawl_seeds",
29
+ "crawl_seeds_async",
30
+ "UrlResult",
31
+ "normalize_url",
32
+ "NostraxError",
33
+ ]
nostrax/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Allow running nostrax as a module: python -m nostrax.
2
+
3
+ Copyright (c) 2024 prodrom3 / radamic
4
+ Licensed under the MIT License.
5
+ """
6
+
7
+ from nostrax.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ main()
nostrax/cache.py ADDED
@@ -0,0 +1,176 @@
1
+ """Disk-based crawl cache for resume support.
2
+
3
+ Copyright (c) 2024 prodrom3 / radamic
4
+ Licensed under the MIT License.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ from typing import IO
11
+
12
+ from nostrax.models import UrlResult
13
+ from nostrax.validation import is_path_within
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class CrawlCache:
19
+ """Persists crawl state to disk so interrupted crawls can resume."""
20
+
21
+ def __init__(self, cache_dir: str) -> None:
22
+ # Resolve to absolute path and ensure it's under cwd
23
+ cache_dir = os.path.realpath(cache_dir)
24
+ if not is_path_within(cache_dir, os.getcwd()):
25
+ raise ValueError(
26
+ f"Cache directory must be under current working directory: {cache_dir}"
27
+ )
28
+ self._dir = cache_dir
29
+ self._visited_path = os.path.join(cache_dir, "visited.json")
30
+ self._results_path = os.path.join(cache_dir, "results.jsonl")
31
+ self._frontier_path = os.path.join(cache_dir, "frontier.json")
32
+ self._incremental_path = os.path.join(cache_dir, "incremental.json")
33
+ self._visited: set[str] = set()
34
+ self._results_fh: IO[str] | None = None
35
+
36
+ def initialize(self) -> None:
37
+ """Create cache directory, load existing state, open result handle."""
38
+ os.makedirs(self._dir, exist_ok=True)
39
+
40
+ if os.path.isfile(self._visited_path):
41
+ try:
42
+ with open(self._visited_path, encoding="utf-8") as f:
43
+ self._visited = set(json.load(f))
44
+ logger.info("Resuming crawl: %d URLs already visited", len(self._visited))
45
+ except (json.JSONDecodeError, OSError) as e:
46
+ logger.warning("Could not load visited cache: %s", e)
47
+ self._visited = set()
48
+
49
+ # Keep the results file open for the life of the crawl. Previously
50
+ # save_result opened and closed once per URL, paying an open/close
51
+ # syscall on every discovered link.
52
+ self._results_fh = open(self._results_path, "a", encoding="utf-8")
53
+
54
+ @property
55
+ def visited(self) -> set[str]:
56
+ return self._visited
57
+
58
+ def mark_visited(self, url: str) -> None:
59
+ """Mark a URL as visited and persist to disk."""
60
+ self._visited.add(url)
61
+
62
+ def save_result(self, result: UrlResult) -> None:
63
+ """Append a result to the results file.
64
+
65
+ Flushes after each write so a resumed crawl sees everything that
66
+ was written before a process crash. fsync is intentionally skipped
67
+ here because it dominates the per-URL cost; power-loss safety is
68
+ provided only for the visited-set rewrite in :meth:`save_visited`.
69
+ """
70
+ if self._results_fh is None:
71
+ raise RuntimeError("CrawlCache.save_result called before initialize() or after close()")
72
+ self._results_fh.write(json.dumps(result.to_dict()) + "\n")
73
+ self._results_fh.flush()
74
+
75
+ def close(self) -> None:
76
+ """Close the append handle. Safe to call multiple times."""
77
+ if self._results_fh is not None:
78
+ self._results_fh.close()
79
+ self._results_fh = None
80
+
81
+ def save_visited(self) -> None:
82
+ """Persist the full visited set to disk atomically.
83
+
84
+ Writes to a sibling .tmp file, fsyncs, then renames into place.
85
+ A crash mid-write leaves either the previous file intact or the
86
+ fully-written new file, never a truncated target.
87
+ """
88
+ tmp_path = self._visited_path + ".tmp"
89
+ with open(tmp_path, "w", encoding="utf-8") as f:
90
+ json.dump(list(self._visited), f)
91
+ f.flush()
92
+ os.fsync(f.fileno())
93
+ os.replace(tmp_path, self._visited_path)
94
+
95
+ def save_frontier(self, items: list[tuple[str, int]]) -> None:
96
+ """Persist the pending frontier (URLs discovered but not yet crawled).
97
+
98
+ Written atomically like the visited set. On resume, these are
99
+ re-enqueued so an interrupted crawl continues from where it stopped
100
+ instead of only reloading the results it had already collected.
101
+ """
102
+ tmp_path = self._frontier_path + ".tmp"
103
+ with open(tmp_path, "w", encoding="utf-8") as f:
104
+ json.dump([[url, depth] for url, depth in items], f)
105
+ f.flush()
106
+ os.fsync(f.fileno())
107
+ os.replace(tmp_path, self._frontier_path)
108
+
109
+ def load_frontier(self) -> list[tuple[str, int]]:
110
+ """Load a previously persisted pending frontier, or [] if none."""
111
+ if not os.path.isfile(self._frontier_path):
112
+ return []
113
+ try:
114
+ with open(self._frontier_path, encoding="utf-8") as f:
115
+ data = json.load(f)
116
+ return [(str(url), int(depth)) for url, depth in data]
117
+ except (json.JSONDecodeError, OSError, ValueError, TypeError) as e:
118
+ logger.warning("Could not load frontier cache: %s", e)
119
+ return []
120
+
121
+ def save_incremental(self, store: dict) -> None:
122
+ """Persist the incremental-crawl store atomically.
123
+
124
+ ``store`` maps a normalized URL to a record of its last successful
125
+ crawl: HTTP validators (etag/last_modified), a body hash, its depth,
126
+ and the links it produced - enough to reuse a page on a later 304
127
+ without re-downloading or re-parsing it.
128
+ """
129
+ tmp_path = self._incremental_path + ".tmp"
130
+ with open(tmp_path, "w", encoding="utf-8") as f:
131
+ json.dump(store, f)
132
+ f.flush()
133
+ os.fsync(f.fileno())
134
+ os.replace(tmp_path, self._incremental_path)
135
+
136
+ def load_incremental(self) -> dict:
137
+ """Load the incremental-crawl store, or {} if none/corrupt."""
138
+ if not os.path.isfile(self._incremental_path):
139
+ return {}
140
+ try:
141
+ with open(self._incremental_path, encoding="utf-8") as f:
142
+ data = json.load(f)
143
+ return data if isinstance(data, dict) else {}
144
+ except (json.JSONDecodeError, OSError) as e:
145
+ logger.warning("Could not load incremental cache: %s", e)
146
+ return {}
147
+
148
+ def load_results(self) -> list[UrlResult]:
149
+ """Load previously saved results from disk."""
150
+ results: list[UrlResult] = []
151
+ if not os.path.isfile(self._results_path):
152
+ return results
153
+
154
+ with open(self._results_path, encoding="utf-8") as f:
155
+ for line in f:
156
+ line = line.strip()
157
+ if not line:
158
+ continue
159
+ try:
160
+ results.append(UrlResult.from_dict(json.loads(line)))
161
+ except (json.JSONDecodeError, KeyError) as e:
162
+ logger.warning("Skipping corrupt cache line: %s", e)
163
+ return results
164
+
165
+ def clear(self) -> None:
166
+ """Delete all cache files. Closes any open result handle first."""
167
+ self.close()
168
+ for path in [
169
+ self._visited_path,
170
+ self._results_path,
171
+ self._frontier_path,
172
+ self._incremental_path,
173
+ ]:
174
+ if os.path.isfile(path):
175
+ os.unlink(path)
176
+ logger.info("Cache cleared: %s", self._dir)