JumpingSpider 0.2.1__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.
- JumpingSpider/__init__.py +13 -0
- JumpingSpider/crawler.py +460 -0
- JumpingSpider/main.py +279 -0
- JumpingSpider/models.py +178 -0
- JumpingSpider/py.typed +0 -0
- JumpingSpider/reporters.py +281 -0
- JumpingSpider/tui.py +377 -0
- jumpingspider-0.2.1.dist-info/METADATA +147 -0
- jumpingspider-0.2.1.dist-info/RECORD +12 -0
- jumpingspider-0.2.1.dist-info/WHEEL +4 -0
- jumpingspider-0.2.1.dist-info/entry_points.txt +4 -0
- jumpingspider-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from JumpingSpider.crawler import SpiderCrawler
|
|
2
|
+
from JumpingSpider.main import crawl_site, main
|
|
3
|
+
from JumpingSpider.models import CrawlConfig, CrawlSummary, LinkReference, LinkResult
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"CrawlConfig",
|
|
7
|
+
"CrawlSummary",
|
|
8
|
+
"LinkReference",
|
|
9
|
+
"LinkResult",
|
|
10
|
+
"SpiderCrawler",
|
|
11
|
+
"crawl_site",
|
|
12
|
+
"main",
|
|
13
|
+
]
|
JumpingSpider/crawler.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
8
|
+
from urllib.parse import urljoin, urlparse
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from bs4 import BeautifulSoup
|
|
12
|
+
from requests.adapters import HTTPAdapter
|
|
13
|
+
|
|
14
|
+
from JumpingSpider.models import CrawlConfig, CrawlSummary, LinkReference, LinkResult
|
|
15
|
+
|
|
16
|
+
DOCUMENT_EXTENSIONS = {
|
|
17
|
+
".pdf",
|
|
18
|
+
".pptx",
|
|
19
|
+
".ppt",
|
|
20
|
+
".xlsx",
|
|
21
|
+
".xls",
|
|
22
|
+
".docx",
|
|
23
|
+
".doc",
|
|
24
|
+
".csv",
|
|
25
|
+
".txt",
|
|
26
|
+
".rtf",
|
|
27
|
+
".zip",
|
|
28
|
+
".tar",
|
|
29
|
+
".gz",
|
|
30
|
+
".tgz",
|
|
31
|
+
".rar",
|
|
32
|
+
".7z",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ASSET_EXTENSIONS = {
|
|
36
|
+
".png",
|
|
37
|
+
".jpg",
|
|
38
|
+
".jpeg",
|
|
39
|
+
".gif",
|
|
40
|
+
".svg",
|
|
41
|
+
".webp",
|
|
42
|
+
".ico",
|
|
43
|
+
".bmp",
|
|
44
|
+
".avif",
|
|
45
|
+
".mp4",
|
|
46
|
+
".mov",
|
|
47
|
+
".avi",
|
|
48
|
+
".webm",
|
|
49
|
+
".mkv",
|
|
50
|
+
".mp3",
|
|
51
|
+
".wav",
|
|
52
|
+
".ogg",
|
|
53
|
+
".m4a",
|
|
54
|
+
".css",
|
|
55
|
+
".js",
|
|
56
|
+
".mjs",
|
|
57
|
+
".map",
|
|
58
|
+
".woff",
|
|
59
|
+
".woff2",
|
|
60
|
+
".ttf",
|
|
61
|
+
".eot",
|
|
62
|
+
".otf",
|
|
63
|
+
".json",
|
|
64
|
+
".xml",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ASSET_TAGS: tuple[tuple[str, str], ...] = (
|
|
68
|
+
("img", "src"),
|
|
69
|
+
("script", "src"),
|
|
70
|
+
("link", "href"),
|
|
71
|
+
("video", "src"),
|
|
72
|
+
("audio", "src"),
|
|
73
|
+
("source", "src"),
|
|
74
|
+
("iframe", "src"),
|
|
75
|
+
("embed", "src"),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def classify_resource_type(url: str, tag: str = "a") -> str:
|
|
80
|
+
"""Classify a URL into 'page', 'document', or 'asset' based on extension and HTML tag."""
|
|
81
|
+
parsed = urlparse(url)
|
|
82
|
+
_, ext = os.path.splitext(parsed.path.lower())
|
|
83
|
+
if ext in DOCUMENT_EXTENSIONS:
|
|
84
|
+
return "document"
|
|
85
|
+
if ext in ASSET_EXTENSIONS:
|
|
86
|
+
return "asset"
|
|
87
|
+
if tag in {"img", "script", "link", "video", "audio", "source", "embed"}:
|
|
88
|
+
return "asset"
|
|
89
|
+
return "page"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def refine_resource_type_from_content_type(current_type: str, content_type: str) -> str:
|
|
93
|
+
"""Refine resource classification once HTTP Content-Type header is known."""
|
|
94
|
+
ct = content_type.lower()
|
|
95
|
+
if not ct:
|
|
96
|
+
return current_type
|
|
97
|
+
if "text/html" in ct or "application/xhtml+xml" in ct:
|
|
98
|
+
return "page" if current_type == "page" else current_type
|
|
99
|
+
if any(doc_ct in ct for doc_ct in ("application/pdf", "msword", "officedocument", "powerpoint", "excel", "zip")):
|
|
100
|
+
return "document"
|
|
101
|
+
if any(asset_ct in ct for asset_ct in ("image/", "video/", "audio/", "text/css", "javascript", "font/")):
|
|
102
|
+
return "asset"
|
|
103
|
+
return current_type
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SpiderCrawler:
|
|
107
|
+
"""Concurrent web crawler and link/asset validator."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
config: CrawlConfig,
|
|
112
|
+
on_url_started: Callable[[str, int, int], None] | None = None,
|
|
113
|
+
on_url_completed: Callable[[LinkResult, int, int], None] | None = None,
|
|
114
|
+
on_log: Callable[[str], None] | None = None,
|
|
115
|
+
session: requests.Session | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
self.config = config
|
|
118
|
+
self.on_url_started = on_url_started
|
|
119
|
+
self.on_url_completed = on_url_completed
|
|
120
|
+
self.on_log = on_log
|
|
121
|
+
self.stop_event = threading.Event()
|
|
122
|
+
|
|
123
|
+
self._lock = threading.RLock()
|
|
124
|
+
self._rate_lock = threading.Lock()
|
|
125
|
+
self._next_allowed_time = 0.0
|
|
126
|
+
|
|
127
|
+
self._registry: dict[str, LinkResult] = {}
|
|
128
|
+
self._discovered_order: list[str] = []
|
|
129
|
+
self._completed_count = 0
|
|
130
|
+
|
|
131
|
+
if session is not None:
|
|
132
|
+
self.session = session
|
|
133
|
+
self._owns_session = False
|
|
134
|
+
else:
|
|
135
|
+
self.session = requests.Session()
|
|
136
|
+
pool_size = max(10, config.workers * 2)
|
|
137
|
+
adapter = HTTPAdapter(pool_connections=pool_size, pool_maxsize=pool_size)
|
|
138
|
+
self.session.mount("http://", adapter)
|
|
139
|
+
self.session.mount("https://", adapter)
|
|
140
|
+
self.session.headers.update({"User-Agent": config.user_agent})
|
|
141
|
+
self._owns_session = True
|
|
142
|
+
|
|
143
|
+
def stop(self) -> None:
|
|
144
|
+
"""Signal the crawler to stop queuing and processing remaining URLs."""
|
|
145
|
+
self.stop_event.set()
|
|
146
|
+
|
|
147
|
+
def _log(self, message: str) -> None:
|
|
148
|
+
if self.on_log is not None:
|
|
149
|
+
self.on_log(message)
|
|
150
|
+
|
|
151
|
+
def _wait_for_rate_limit(self) -> None:
|
|
152
|
+
delay = self.config.delay
|
|
153
|
+
if delay <= 0:
|
|
154
|
+
return
|
|
155
|
+
with self._rate_lock:
|
|
156
|
+
now = time.monotonic()
|
|
157
|
+
if now < self._next_allowed_time:
|
|
158
|
+
sleep_duration = self._next_allowed_time - now
|
|
159
|
+
self._next_allowed_time += delay
|
|
160
|
+
else:
|
|
161
|
+
sleep_duration = delay
|
|
162
|
+
self._next_allowed_time = now + delay
|
|
163
|
+
if sleep_duration > 0 and not self.stop_event.is_set():
|
|
164
|
+
time.sleep(sleep_duration)
|
|
165
|
+
|
|
166
|
+
def _is_internal(self, url: str) -> bool:
|
|
167
|
+
return url == self.config.start_url or url.startswith(self.config.base_prefix)
|
|
168
|
+
|
|
169
|
+
def _should_parse_html(self, result: LinkResult) -> bool:
|
|
170
|
+
if result.scope != "internal":
|
|
171
|
+
return False
|
|
172
|
+
if result.resource_type != "page":
|
|
173
|
+
return False
|
|
174
|
+
return not (self.config.max_depth is not None and result.depth > self.config.max_depth)
|
|
175
|
+
|
|
176
|
+
def _register_url(
|
|
177
|
+
self,
|
|
178
|
+
url: str,
|
|
179
|
+
depth: int,
|
|
180
|
+
reference: LinkReference | None = None,
|
|
181
|
+
tag: str = "a",
|
|
182
|
+
) -> bool:
|
|
183
|
+
"""
|
|
184
|
+
Registers a URL in the thread-safe registry.
|
|
185
|
+
Returns True if the URL is newly discovered and should be fetched, False if already seen.
|
|
186
|
+
"""
|
|
187
|
+
scope = "internal" if self._is_internal(url) else "external"
|
|
188
|
+
resource_type = classify_resource_type(url, tag=tag)
|
|
189
|
+
|
|
190
|
+
with self._lock:
|
|
191
|
+
existing = self._registry.get(url)
|
|
192
|
+
if existing is not None:
|
|
193
|
+
if reference is not None:
|
|
194
|
+
existing.add_reference(reference)
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
result = LinkResult(
|
|
198
|
+
link=url,
|
|
199
|
+
success=0,
|
|
200
|
+
status_code=0,
|
|
201
|
+
scope=scope,
|
|
202
|
+
resource_type=resource_type,
|
|
203
|
+
depth=depth,
|
|
204
|
+
references=[reference] if reference is not None else [],
|
|
205
|
+
)
|
|
206
|
+
self._registry[url] = result
|
|
207
|
+
self._discovered_order.append(url)
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
def _extract_references_from_html(self, source_url: str, html_text: str) -> list[tuple[str, LinkReference]]:
|
|
211
|
+
"""Extracts candidate (normalized_url, LinkReference) pairs from an HTML document."""
|
|
212
|
+
soup = BeautifulSoup(html_text, "html.parser")
|
|
213
|
+
discovered: list[tuple[str, LinkReference]] = []
|
|
214
|
+
|
|
215
|
+
# 1. Extract <a href="..."> links
|
|
216
|
+
for a_tag in soup.find_all("a", href=True):
|
|
217
|
+
raw_href = str(a_tag["href"]).strip()
|
|
218
|
+
if not raw_href or raw_href.startswith(("javascript:", "mailto:", "tel:", "#")):
|
|
219
|
+
continue
|
|
220
|
+
full_url = urljoin(source_url, raw_href).split("#")[0]
|
|
221
|
+
if urlparse(full_url).scheme not in ("http", "https"):
|
|
222
|
+
continue
|
|
223
|
+
text = (
|
|
224
|
+
a_tag.get_text(" ", strip=True)
|
|
225
|
+
or str(a_tag.get("title", "")).strip()
|
|
226
|
+
or str(a_tag.get("aria-label", "")).strip()
|
|
227
|
+
)
|
|
228
|
+
discovered.append(
|
|
229
|
+
(
|
|
230
|
+
full_url,
|
|
231
|
+
LinkReference(
|
|
232
|
+
source_url=source_url,
|
|
233
|
+
tag="a",
|
|
234
|
+
attribute="href",
|
|
235
|
+
text=text[:200],
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# 2. Extract asset tags if enabled
|
|
241
|
+
if self.config.check_assets:
|
|
242
|
+
for tag_name, attr_name in ASSET_TAGS:
|
|
243
|
+
for tag_el in soup.find_all(tag_name):
|
|
244
|
+
raw_val = tag_el.get(attr_name)
|
|
245
|
+
if not raw_val:
|
|
246
|
+
continue
|
|
247
|
+
raw_val_str = str(raw_val).strip()
|
|
248
|
+
if not raw_val_str or raw_val_str.startswith(("data:", "javascript:", "blob:", "#")):
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
# Skip <link> tags that are purely DNS prefetch / preconnect hints without real assets
|
|
252
|
+
if tag_name == "link":
|
|
253
|
+
rel_val = tag_el.get("rel")
|
|
254
|
+
rels = [r.lower() for r in rel_val] if isinstance(rel_val, list) else [str(rel_val or "").lower()]
|
|
255
|
+
if any(r in {"dns-prefetch", "preconnect"} for r in rels):
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
full_url = urljoin(source_url, raw_val_str).split("#")[0]
|
|
259
|
+
if urlparse(full_url).scheme not in ("http", "https"):
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
label = (
|
|
263
|
+
str(tag_el.get("alt", "")).strip()
|
|
264
|
+
or str(tag_el.get("title", "")).strip()
|
|
265
|
+
or (
|
|
266
|
+
" ".join(tag_el.get("rel", []))
|
|
267
|
+
if isinstance(tag_el.get("rel"), list)
|
|
268
|
+
else str(tag_el.get("rel", "")).strip()
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
discovered.append(
|
|
272
|
+
(
|
|
273
|
+
full_url,
|
|
274
|
+
LinkReference(
|
|
275
|
+
source_url=source_url,
|
|
276
|
+
tag=tag_name,
|
|
277
|
+
attribute=attr_name,
|
|
278
|
+
text=label[:200],
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return discovered
|
|
284
|
+
|
|
285
|
+
def _fetch_and_process(self, url: str) -> list[str]:
|
|
286
|
+
"""Fetches a single URL, updates its LinkResult, and returns newly queued URLs."""
|
|
287
|
+
if self.stop_event.is_set():
|
|
288
|
+
return []
|
|
289
|
+
|
|
290
|
+
with self._lock:
|
|
291
|
+
result = self._registry[url]
|
|
292
|
+
total_queued = len(self._discovered_order)
|
|
293
|
+
completed_so_far = self._completed_count
|
|
294
|
+
|
|
295
|
+
if self.on_url_started is not None:
|
|
296
|
+
self.on_url_started(url, completed_so_far, total_queued)
|
|
297
|
+
self._log(f"Checking: {url}")
|
|
298
|
+
|
|
299
|
+
self._wait_for_rate_limit()
|
|
300
|
+
if self.stop_event.is_set():
|
|
301
|
+
return []
|
|
302
|
+
|
|
303
|
+
need_html = self._should_parse_html(result)
|
|
304
|
+
start_time = time.perf_counter()
|
|
305
|
+
new_urls_to_crawl: list[str] = []
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
response: requests.Response
|
|
309
|
+
if need_html:
|
|
310
|
+
result.method_used = "GET"
|
|
311
|
+
response = self.session.get(url, timeout=self.config.timeout, stream=True)
|
|
312
|
+
else:
|
|
313
|
+
result.method_used = "HEAD"
|
|
314
|
+
response = self.session.head(url, timeout=self.config.timeout, allow_redirects=True)
|
|
315
|
+
if response.status_code in (403, 405, 501):
|
|
316
|
+
response.close()
|
|
317
|
+
result.method_used = "GET"
|
|
318
|
+
response = self.session.get(url, timeout=self.config.timeout, stream=True, allow_redirects=True)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
322
|
+
status_code = response.status_code
|
|
323
|
+
is_success = 1 if 200 <= status_code < 400 else 0
|
|
324
|
+
content_type = response.headers.get("Content-Type", "")
|
|
325
|
+
|
|
326
|
+
redirect_chain = [resp.url for resp in response.history]
|
|
327
|
+
final_url = response.url or url
|
|
328
|
+
is_redirect = len(redirect_chain) > 0
|
|
329
|
+
|
|
330
|
+
with self._lock:
|
|
331
|
+
result.status_code = status_code
|
|
332
|
+
result.success = is_success
|
|
333
|
+
result.final_url = final_url
|
|
334
|
+
result.is_redirect = is_redirect
|
|
335
|
+
result.redirect_chain = redirect_chain
|
|
336
|
+
result.content_type = content_type
|
|
337
|
+
result.response_time_ms = elapsed_ms
|
|
338
|
+
result.resource_type = refine_resource_type_from_content_type(
|
|
339
|
+
result.resource_type, content_type
|
|
340
|
+
)
|
|
341
|
+
if not is_success:
|
|
342
|
+
result.error_type = "HTTPError"
|
|
343
|
+
result.error_message = f"HTTP {status_code}"
|
|
344
|
+
|
|
345
|
+
# If redirected to a different internal URL, queue the final URL rather than
|
|
346
|
+
# attributing the destination page's links to the redirecting URL.
|
|
347
|
+
if is_redirect and final_url != url:
|
|
348
|
+
if self._is_internal(final_url) or self.config.check_external:
|
|
349
|
+
ref = LinkReference(
|
|
350
|
+
source_url=url,
|
|
351
|
+
tag="redirect",
|
|
352
|
+
attribute="Location",
|
|
353
|
+
text=f"Redirect from {url}",
|
|
354
|
+
)
|
|
355
|
+
if self._register_url(final_url, depth=result.depth, reference=ref, tag="a"):
|
|
356
|
+
new_urls_to_crawl.append(final_url)
|
|
357
|
+
elif (
|
|
358
|
+
need_html
|
|
359
|
+
and is_success == 1
|
|
360
|
+
and "text/html" in content_type.lower()
|
|
361
|
+
and not self.stop_event.is_set()
|
|
362
|
+
):
|
|
363
|
+
html_text = response.text
|
|
364
|
+
extracted = self._extract_references_from_html(final_url, html_text)
|
|
365
|
+
next_depth = result.depth + 1
|
|
366
|
+
for target_url, ref in extracted:
|
|
367
|
+
is_internal = self._is_internal(target_url)
|
|
368
|
+
if not is_internal and not self.config.check_external:
|
|
369
|
+
continue
|
|
370
|
+
if self._register_url(target_url, depth=next_depth, reference=ref, tag=ref.tag):
|
|
371
|
+
new_urls_to_crawl.append(target_url)
|
|
372
|
+
finally:
|
|
373
|
+
response.close()
|
|
374
|
+
|
|
375
|
+
except requests.exceptions.Timeout as exc:
|
|
376
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
377
|
+
with self._lock:
|
|
378
|
+
result.success = 0
|
|
379
|
+
result.status_code = 0
|
|
380
|
+
result.response_time_ms = elapsed_ms
|
|
381
|
+
result.error_type = "Timeout"
|
|
382
|
+
result.error_message = str(exc)
|
|
383
|
+
self._log(f"Timeout connecting to {url}: {exc}")
|
|
384
|
+
|
|
385
|
+
except requests.exceptions.SSLError as exc:
|
|
386
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
387
|
+
with self._lock:
|
|
388
|
+
result.success = 0
|
|
389
|
+
result.status_code = 0
|
|
390
|
+
result.response_time_ms = elapsed_ms
|
|
391
|
+
result.error_type = "SSLError"
|
|
392
|
+
result.error_message = str(exc)
|
|
393
|
+
self._log(f"SSL error on {url}: {exc}")
|
|
394
|
+
|
|
395
|
+
except requests.exceptions.ConnectionError as exc:
|
|
396
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
397
|
+
with self._lock:
|
|
398
|
+
result.success = 0
|
|
399
|
+
result.status_code = 0
|
|
400
|
+
result.response_time_ms = elapsed_ms
|
|
401
|
+
result.error_type = "ConnectionError"
|
|
402
|
+
result.error_message = str(exc)
|
|
403
|
+
self._log(f"Failed to connect to {url}: {exc}")
|
|
404
|
+
|
|
405
|
+
except requests.exceptions.RequestException as exc:
|
|
406
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
407
|
+
with self._lock:
|
|
408
|
+
result.success = 0
|
|
409
|
+
result.status_code = 0
|
|
410
|
+
result.response_time_ms = elapsed_ms
|
|
411
|
+
result.error_type = "RequestException"
|
|
412
|
+
result.error_message = str(exc)
|
|
413
|
+
self._log(f"Request failed for {url}: {exc}")
|
|
414
|
+
|
|
415
|
+
with self._lock:
|
|
416
|
+
self._completed_count += 1
|
|
417
|
+
completed_now = self._completed_count
|
|
418
|
+
total_now = len(self._discovered_order)
|
|
419
|
+
|
|
420
|
+
if self.on_url_completed is not None:
|
|
421
|
+
self.on_url_completed(result, completed_now, total_now)
|
|
422
|
+
|
|
423
|
+
return new_urls_to_crawl
|
|
424
|
+
|
|
425
|
+
def run(self) -> CrawlSummary:
|
|
426
|
+
"""Executes the crawl until all discovered URLs are checked or stop() is called."""
|
|
427
|
+
self.stop_event.clear()
|
|
428
|
+
started_at = time.perf_counter()
|
|
429
|
+
|
|
430
|
+
self._register_url(self.config.start_url, depth=0, reference=None, tag="a")
|
|
431
|
+
|
|
432
|
+
workers = max(1, self.config.workers)
|
|
433
|
+
try:
|
|
434
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
435
|
+
pending: set[Future[list[str]]] = {
|
|
436
|
+
executor.submit(self._fetch_and_process, self.config.start_url)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
while pending and not self.stop_event.is_set():
|
|
440
|
+
done, pending = wait(pending, timeout=0.1, return_when=FIRST_COMPLETED)
|
|
441
|
+
for fut in done:
|
|
442
|
+
new_urls = fut.result()
|
|
443
|
+
if self.stop_event.is_set():
|
|
444
|
+
break
|
|
445
|
+
for next_url in new_urls:
|
|
446
|
+
pending.add(executor.submit(self._fetch_and_process, next_url))
|
|
447
|
+
finally:
|
|
448
|
+
if self._owns_session:
|
|
449
|
+
self.session.close()
|
|
450
|
+
|
|
451
|
+
duration = time.perf_counter() - started_at
|
|
452
|
+
with self._lock:
|
|
453
|
+
ordered_results = [self._registry[u] for u in self._discovered_order]
|
|
454
|
+
|
|
455
|
+
return CrawlSummary(
|
|
456
|
+
start_url=self.config.start_url,
|
|
457
|
+
base_prefix=self.config.base_prefix,
|
|
458
|
+
results=ordered_results,
|
|
459
|
+
duration_seconds=duration,
|
|
460
|
+
)
|