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.
@@ -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
+ ]
@@ -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
+ )