Rohan P H commited on
Commit
a0e1f24
·
1 Parent(s): 87c449b

feat: concurrent crawl with anti-bot, RSS fallback, and depth-by-depth processing

Browse files

- Refactor crawler from sequential FIFO loop to depth-by-depth concurrent
- Use crawl4ai arun_many() for parallel HTML crawling
- Use asyncio.gather() for parallel PDF/text file fetching
- Add User-Agent rotation, full browser headers, rate limiting
- Add retry with exponential backoff on 403/429/503
- Add anti-bot challenge detection (Cloudflare, Akamai, etc.)
- Add RSS/Atom feed detection and fallback before crawling
- Add crawl4ai stealth mode (simulate_user, override_navigator, magic)
- Add PyMuPDF to requirements for PDF extraction
- Mark URLs visited before batch execution to prevent race conditions

Files changed (4) hide show
  1. app.py +2 -1
  2. requirements.txt +1 -0
  3. src/config.py +8 -0
  4. src/crawler.py +307 -84
app.py CHANGED
@@ -106,6 +106,7 @@ async def run_crawl(url, depth, max_pages, progress=gr.Progress()):
106
  progress(0.05, desc=f"Crawling {url} (depth={depth}, max={max_pages})...")
107
  pages_df = await crawler.crawl(url, depth, max_pages, progress)
108
  pdf_count = crawler.pdf_count
 
109
  n_pages = len(pages_df)
110
 
111
  if n_pages == 0:
@@ -150,7 +151,7 @@ async def run_crawl(url, depth, max_pages, progress=gr.Progress()):
150
  progress(1.0, desc="Done!")
151
  return (
152
  f"**Crawl Complete!**\n"
153
- + f"- Pages crawled: {n_pages} ({pdf_count} PDF, {crawler.html_count} HTML, {crawler.text_count} text)\n"
154
  + f"- Chunks created: {n_chunks}\n"
155
  + f"- Facts extracted: {n_facts}\n"
156
  + f"- Indexes: {idx_status}\n"
 
106
  progress(0.05, desc=f"Crawling {url} (depth={depth}, max={max_pages})...")
107
  pages_df = await crawler.crawl(url, depth, max_pages, progress)
108
  pdf_count = crawler.pdf_count
109
+ rss_count = crawler.rss_count
110
  n_pages = len(pages_df)
111
 
112
  if n_pages == 0:
 
151
  progress(1.0, desc="Done!")
152
  return (
153
  f"**Crawl Complete!**\n"
154
+ + f"- Pages crawled: {n_pages} ({pdf_count} PDF, {crawler.html_count} HTML, {crawler.text_count} text, {rss_count} RSS)\n"
155
  + f"- Chunks created: {n_chunks}\n"
156
  + f"- Facts extracted: {n_facts}\n"
157
  + f"- Indexes: {idx_status}\n"
requirements.txt CHANGED
@@ -13,3 +13,4 @@ pydantic>=2.0.0
13
  httpx>=0.27.0
14
  beautifulsoup4>=4.12.0
15
  lxml>=5.0.0
 
 
13
  httpx>=0.27.0
14
  beautifulsoup4>=4.12.0
15
  lxml>=5.0.0
16
+ PyMuPDF>=1.24.0
src/config.py CHANGED
@@ -11,6 +11,14 @@ class Config:
11
  max_depth: int = 10
12
  max_pages_limit: int = 1000
13
 
 
 
 
 
 
 
 
 
14
  # Chunking settings
15
  chunk_max_chars: int = 800
16
  chunk_overlap: int = 100
 
11
  max_depth: int = 10
12
  max_pages_limit: int = 1000
13
 
14
+ # Anti-bot settings
15
+ crawl_delay_min: float = 0.5
16
+ crawl_delay_max: float = 2.0
17
+ crawl_max_retries: int = 3
18
+ crawl_retry_base_delay: float = 5.0
19
+ crawl_min_content_bytes: int = 200
20
+ crawl_rss_fallback: bool = True
21
+
22
  # Chunking settings
23
  chunk_max_chars: int = 800
24
  chunk_overlap: int = 100
src/crawler.py CHANGED
@@ -1,4 +1,5 @@
1
  import asyncio
 
2
  import time
3
  from pathlib import Path
4
  from urllib.parse import urljoin, urlparse
@@ -6,7 +7,7 @@ from urllib.parse import urljoin, urlparse
6
  import httpx
7
  import polars as pl
8
  from bs4 import BeautifulSoup
9
- from crawl4ai import AsyncWebCrawler
10
 
11
  from src.config import Config
12
  from src.utils import content_hash, normalize_url
@@ -14,6 +15,29 @@ from src.utils import content_hash, normalize_url
14
  PDF_TYPES = {".pdf"}
15
  TEXT_TYPES = {".txt", ".csv", ".xml", ".md", ".json"}
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  class Crawler:
19
  def __init__(self, config: Config):
@@ -23,8 +47,10 @@ class Crawler:
23
  self.pdf_count = 0
24
  self.html_count = 0
25
  self.text_count = 0
 
26
  self.domain: str = ""
27
  self.http_client: httpx.AsyncClient | None = None
 
28
 
29
  def _get_domain(self, url: str) -> str:
30
  return urlparse(url).netloc
@@ -36,6 +62,63 @@ class Crawler:
36
  path = urlparse(url).path.lower()
37
  return Path(path).suffix
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  async def crawl(self, url: str, depth: int, max_pages: int, progress=None) -> pl.DataFrame:
40
  url = normalize_url(url)
41
  self.domain = self._get_domain(url)
@@ -44,52 +127,100 @@ class Crawler:
44
  self.pdf_count = 0
45
  self.html_count = 0
46
  self.text_count = 0
47
-
48
- queue: list[tuple[str, int]] = [(url, 0)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  async with httpx.AsyncClient(
51
- timeout=30.0, follow_redirects=True,
52
- headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"},
53
  ) as self.http_client:
54
- async with AsyncWebCrawler() as crawler:
55
- while queue and len(self.pages) < max_pages:
56
- current_url, current_depth = queue.pop(0)
57
-
58
- if current_url in self.visited:
59
- continue
60
- if current_depth > depth:
61
- continue
62
- domain = self._get_domain(current_url)
63
- if domain and domain != self.domain:
64
- continue
65
-
66
- self.visited.add(current_url)
67
-
68
- ext = self._get_ext(current_url)
69
- page = None
70
-
71
- if ext == ".pdf":
72
- page = await self._process_pdf(current_url, current_depth)
73
- elif ext in TEXT_TYPES:
74
- page = await self._process_text_file(current_url, ext, current_depth)
75
- else:
76
- page = await self._process_html(crawler, current_url, current_depth)
77
-
78
- if page:
79
- links = page.pop("_links", [])
80
- self.pages.append(page)
81
-
82
- if progress:
83
- pct = min(len(self.pages) / max_pages, 1.0)
84
- details = f"{self.html_count} HTML"
85
- if self.pdf_count:
86
- details += f", {self.pdf_count} PDF"
87
- progress(pct, desc=f"Crawled {len(self.pages)} pages ({details})")
88
-
89
- if current_depth < depth:
90
- for link in links:
91
- if link not in self.visited:
92
- queue.append((link, current_depth + 1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  if not self.pages:
95
  return pl.DataFrame(schema={
@@ -102,11 +233,134 @@ class Crawler:
102
  df = df.unique(subset=["content_hash"], keep="first")
103
  return df
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  async def _process_pdf(self, url: str, depth: int) -> dict | None:
106
  try:
107
- resp = await self.http_client.get(url)
108
- resp.raise_for_status()
 
109
  content = resp.content
 
 
110
 
111
  import fitz
112
 
@@ -121,7 +375,7 @@ class Crawler:
121
  doc.close()
122
 
123
  markdown = "\n\n".join(md_parts)
124
- if not markdown.strip():
125
  return None
126
 
127
  self.pdf_count += 1
@@ -141,10 +395,11 @@ class Crawler:
141
 
142
  async def _process_text_file(self, url: str, ext: str, depth: int) -> dict | None:
143
  try:
144
- resp = await self.http_client.get(url)
145
- resp.raise_for_status()
 
146
  text = resp.text
147
- if not text.strip():
148
  return None
149
 
150
  title = Path(urlparse(url).path).stem or "file"
@@ -163,40 +418,6 @@ class Crawler:
163
  except Exception:
164
  return None
165
 
166
- async def _process_html(self, crawler: AsyncWebCrawler, url: str, depth: int) -> dict | None:
167
- try:
168
- result = await crawler.arun(
169
- url=url,
170
- word_count_threshold=10,
171
- bypass_cache=True,
172
- )
173
-
174
- if not result.success:
175
- return None
176
-
177
- html = result.html or ""
178
- markdown = result.markdown or ""
179
- if not markdown.strip():
180
- return None
181
-
182
- links = self._extract_links(html, url) if depth < self.config.max_depth else []
183
-
184
- self.html_count += 1
185
- return {
186
- "url": url,
187
- "title": (result.metadata.get("title", "") if result.metadata
188
- else Path(urlparse(url).path).stem or url),
189
- "markdown": markdown,
190
- "html": html,
191
- "depth": depth,
192
- "timestamp": time.time(),
193
- "content_hash": content_hash(markdown),
194
- "file_type": "html",
195
- "_links": links,
196
- }
197
- except Exception:
198
- return None
199
-
200
  def _extract_links(self, html: str, base_url: str) -> list[str]:
201
  soup = BeautifulSoup(html, "lxml")
202
  links = []
@@ -204,6 +425,8 @@ class Crawler:
204
  href = a_tag["href"].strip()
205
  if not href or href.startswith("#") or href.startswith("javascript:"):
206
  continue
 
 
207
  absolute_url = urljoin(base_url, href)
208
  absolute_url = absolute_url.rstrip("/")
209
  if absolute_url.startswith("http://"):
 
1
  import asyncio
2
+ import random
3
  import time
4
  from pathlib import Path
5
  from urllib.parse import urljoin, urlparse
 
7
  import httpx
8
  import polars as pl
9
  from bs4 import BeautifulSoup
10
+ from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
11
 
12
  from src.config import Config
13
  from src.utils import content_hash, normalize_url
 
15
  PDF_TYPES = {".pdf"}
16
  TEXT_TYPES = {".txt", ".csv", ".xml", ".md", ".json"}
17
 
18
+ USER_AGENTS = [
19
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
20
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
21
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
22
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:133.0) Gecko/20100101 Firefox/133.0",
23
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0",
24
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
25
+ "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0",
26
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Vivaldi/7.0.3495.11",
27
+ ]
28
+
29
+ RSS_PATHS = [
30
+ "/feed", "/rss", "/rss.xml", "/atom.xml", "/feed.xml",
31
+ "/index.xml", "/blog/feed", "/news/feed", "/feeds/posts/default",
32
+ ]
33
+
34
+ CHALLENGE_INDICATORS = [
35
+ "access denied", "blocked", "captcha", "verify you are human",
36
+ "ray id", "cloudflare", "incapsula", "akamai", "distil networks",
37
+ "press & hold", "checking your browser", "just a moment",
38
+ "attention required", "security check", "ddos protection",
39
+ ]
40
+
41
 
42
  class Crawler:
43
  def __init__(self, config: Config):
 
47
  self.pdf_count = 0
48
  self.html_count = 0
49
  self.text_count = 0
50
+ self.rss_count = 0
51
  self.domain: str = ""
52
  self.http_client: httpx.AsyncClient | None = None
53
+ self._last_request_time: float = 0.0
54
 
55
  def _get_domain(self, url: str) -> str:
56
  return urlparse(url).netloc
 
62
  path = urlparse(url).path.lower()
63
  return Path(path).suffix
64
 
65
+ def _is_static_asset(self, url: str) -> bool:
66
+ ext = self._get_ext(url)
67
+ return ext in PDF_TYPES or ext in TEXT_TYPES
68
+
69
+ def _random_ua(self) -> str:
70
+ return random.choice(USER_AGENTS)
71
+
72
+ def _build_headers(self) -> dict[str, str]:
73
+ return {
74
+ "User-Agent": self._random_ua(),
75
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
76
+ "Accept-Language": "en-US,en;q=0.9",
77
+ "Accept-Encoding": "gzip, deflate, br",
78
+ "Connection": "keep-alive",
79
+ "Upgrade-Insecure-Requests": "1",
80
+ "Sec-Fetch-Dest": "document",
81
+ "Sec-Fetch-Mode": "navigate",
82
+ "Sec-Fetch-Site": "none",
83
+ "Sec-Fetch-User": "?1",
84
+ "Cache-Control": "max-age=0",
85
+ }
86
+
87
+ async def _rate_limit(self):
88
+ elapsed = time.time() - self._last_request_time
89
+ delay = random.uniform(self.config.crawl_delay_min, self.config.crawl_delay_max)
90
+ if elapsed < delay:
91
+ await asyncio.sleep(delay - elapsed)
92
+ self._last_request_time = time.time()
93
+
94
+ def _is_blocked_content(self, html: str, content_length: int) -> bool:
95
+ if content_length < self.config.crawl_min_content_bytes:
96
+ return True
97
+ html_lower = html.lower()
98
+ for indicator in CHALLENGE_INDICATORS:
99
+ if indicator in html_lower:
100
+ return True
101
+ return False
102
+
103
+ async def _fetch_with_retry(self, url: str) -> httpx.Response | None:
104
+ for attempt in range(self.config.crawl_max_retries):
105
+ await self._rate_limit()
106
+ headers = self._build_headers()
107
+ try:
108
+ resp = await self.http_client.get(url, headers=headers)
109
+ content_len = len(resp.content)
110
+ if resp.status_code == 200 and not self._is_blocked_content(resp.text, content_len):
111
+ return resp
112
+ if resp.status_code in (403, 429, 503):
113
+ backoff = self.config.crawl_retry_base_delay * (2 ** attempt) + random.uniform(0, 2)
114
+ await asyncio.sleep(backoff)
115
+ continue
116
+ return resp
117
+ except (httpx.TimeoutException, httpx.ConnectError, httpx.ReadError):
118
+ backoff = self.config.crawl_retry_base_delay * (2 ** attempt) + random.uniform(0, 2)
119
+ await asyncio.sleep(backoff)
120
+ return None
121
+
122
  async def crawl(self, url: str, depth: int, max_pages: int, progress=None) -> pl.DataFrame:
123
  url = normalize_url(url)
124
  self.domain = self._get_domain(url)
 
127
  self.pdf_count = 0
128
  self.html_count = 0
129
  self.text_count = 0
130
+ self.rss_count = 0
131
+
132
+ if self.config.crawl_rss_fallback:
133
+ await self._try_rss_feed(url)
134
+
135
+ browser_config = BrowserConfig(
136
+ headless=True,
137
+ browser_type="chromium",
138
+ user_agent=self._random_ua(),
139
+ verbose=False,
140
+ enable_stealth=True,
141
+ )
142
+ crawl_config = CrawlerRunConfig(
143
+ word_count_threshold=10,
144
+ bypass_cache=True,
145
+ simulate_user=True,
146
+ override_navigator=True,
147
+ magic=True,
148
+ remove_overlay_elements=True,
149
+ remove_consent_popups=True,
150
+ semaphore_count=8,
151
+ )
152
 
153
  async with httpx.AsyncClient(
154
+ timeout=30.0,
155
+ follow_redirects=True,
156
  ) as self.http_client:
157
+ async with AsyncWebCrawler(config=browser_config) as crawler:
158
+ next_layer = [url]
159
+
160
+ for current_depth in range(depth + 1):
161
+ if not next_layer or len(self.pages) >= max_pages:
162
+ break
163
+
164
+ remaining = max_pages - len(self.pages)
165
+ current_layer = next_layer[:remaining]
166
+ next_layer = []
167
+
168
+ for u in current_layer:
169
+ self.visited.add(u)
170
+
171
+ html_urls: list[str] = []
172
+ static_tasks: list[tuple[str, str]] = []
173
+
174
+ for u in current_layer:
175
+ if self._is_static_asset(u):
176
+ ext = self._get_ext(u)
177
+ static_tasks.append((u, ext))
178
+ else:
179
+ html_urls.append(u)
180
+
181
+ if static_tasks:
182
+ coros = [
183
+ self._process_pdf(u, current_depth) if e in PDF_TYPES
184
+ else self._process_text_file(u, e, current_depth)
185
+ for u, e in static_tasks
186
+ ]
187
+ static_results = await asyncio.gather(*coros, return_exceptions=True)
188
+ for r in static_results:
189
+ if isinstance(r, dict) and r:
190
+ links = r.pop("_links", [])
191
+ self.pages.append(r)
192
+ if current_depth < depth:
193
+ for link in links:
194
+ if link not in self.visited:
195
+ self.visited.add(link)
196
+ next_layer.append(link)
197
+
198
+ if html_urls:
199
+ html_results = await crawler.arun_many(
200
+ urls=html_urls,
201
+ config=crawl_config,
202
+ )
203
+ for result in html_results:
204
+ page = self._parse_arun_result(result, current_depth)
205
+ if page:
206
+ links = page.pop("_links", [])
207
+ self.pages.append(page)
208
+ if current_depth < depth:
209
+ for link in links:
210
+ if link not in self.visited:
211
+ self.visited.add(link)
212
+ next_layer.append(link)
213
+
214
+ if progress:
215
+ pct = min(len(self.pages) / max_pages, 1.0)
216
+ details = f"{self.html_count} HTML"
217
+ if self.pdf_count:
218
+ details += f", {self.pdf_count} PDF"
219
+ if self.text_count:
220
+ details += f", {self.text_count} text"
221
+ if self.rss_count:
222
+ details += f", {self.rss_count} RSS"
223
+ progress(pct, desc=f"Depth {current_depth}: {len(self.pages)} pages ({details})")
224
 
225
  if not self.pages:
226
  return pl.DataFrame(schema={
 
233
  df = df.unique(subset=["content_hash"], keep="first")
234
  return df
235
 
236
+ def _parse_arun_result(self, result, depth: int) -> dict | None:
237
+ if not result.success:
238
+ return None
239
+
240
+ html = result.html or ""
241
+ markdown = result.markdown or ""
242
+ if hasattr(markdown, "raw_markdown"):
243
+ markdown = markdown.raw_markdown or ""
244
+
245
+ if not markdown.strip():
246
+ return None
247
+
248
+ if self._is_blocked_content(markdown, len(markdown)):
249
+ return None
250
+
251
+ links = self._extract_links(html, result.url) if depth < self.config.max_depth else []
252
+
253
+ self.html_count += 1
254
+ return {
255
+ "url": result.url,
256
+ "title": (
257
+ result.metadata.get("title", "") if result.metadata
258
+ else Path(urlparse(result.url).path).stem or result.url
259
+ ),
260
+ "markdown": markdown,
261
+ "html": html,
262
+ "depth": depth,
263
+ "timestamp": time.time(),
264
+ "content_hash": content_hash(markdown),
265
+ "file_type": "html",
266
+ "_links": links,
267
+ }
268
+
269
+ async def _try_rss_feed(self, base_url: str):
270
+ parsed = urlparse(base_url)
271
+ base = f"{parsed.scheme}://{parsed.netloc}"
272
+
273
+ feed_urls = [normalize_url(base + path) for path in RSS_PATHS]
274
+
275
+ for feed_url in feed_urls:
276
+ if feed_url in self.visited:
277
+ continue
278
+ try:
279
+ headers = self._build_headers()
280
+ resp = await self.http_client.get(feed_url, headers=headers)
281
+ if resp.status_code != 200:
282
+ continue
283
+ content_type = resp.headers.get("content-type", "").lower()
284
+ text = resp.text
285
+ if len(text) < 100:
286
+ continue
287
+ is_rss = (
288
+ "xml" in content_type
289
+ or "<rss" in text[:500]
290
+ or "<feed" in text[:500]
291
+ or "<rdf" in text[:500]
292
+ )
293
+ if not is_rss:
294
+ continue
295
+
296
+ self.visited.add(feed_url)
297
+ items = self._parse_rss(text, feed_url)
298
+ for item in items:
299
+ if len(self.pages) >= 50:
300
+ break
301
+ item["depth"] = 0
302
+ if not any(p["content_hash"] == item["content_hash"] for p in self.pages):
303
+ self.pages.append(item)
304
+ self.rss_count += 1
305
+ if items:
306
+ break
307
+ except Exception:
308
+ continue
309
+
310
+ def _parse_rss(self, xml_text: str, feed_url: str) -> list[dict]:
311
+ soup = BeautifulSoup(xml_text, "lxml-xml")
312
+ items = []
313
+
314
+ for item in soup.find_all(["item", "entry"]):
315
+ title_tag = item.find("title")
316
+ title = title_tag.get_text(strip=True) if title_tag else ""
317
+
318
+ link_tag = item.find("link")
319
+ if link_tag:
320
+ link = link_tag.get("href", "") or link_tag.get_text(strip=True)
321
+ else:
322
+ link = ""
323
+
324
+ desc_tag = item.find(["description", "summary", "content"])
325
+ description = desc_tag.get_text(strip=True) if desc_tag else ""
326
+
327
+ pub_tag = item.find(["pubDate", "published", "updated"])
328
+ pub_date = pub_tag.get_text(strip=True) if pub_tag else ""
329
+
330
+ if not description and not link:
331
+ continue
332
+
333
+ md = f"## {title}\n\n" if title else ""
334
+ if pub_date:
335
+ md += f"*Published: {pub_date}*\n\n"
336
+ md += description
337
+
338
+ if link:
339
+ link = urljoin(feed_url, link)
340
+ link = normalize_url(link)
341
+
342
+ items.append({
343
+ "url": link or feed_url,
344
+ "title": title or "RSS Item",
345
+ "markdown": md,
346
+ "html": f"<pre>{description}</pre>",
347
+ "depth": 0,
348
+ "timestamp": time.time(),
349
+ "content_hash": content_hash(md),
350
+ "file_type": "rss",
351
+ "_links": [link] if link else [],
352
+ })
353
+
354
+ return items
355
+
356
  async def _process_pdf(self, url: str, depth: int) -> dict | None:
357
  try:
358
+ resp = await self._fetch_with_retry(url)
359
+ if resp is None or resp.status_code != 200:
360
+ return None
361
  content = resp.content
362
+ if len(content) < 100:
363
+ return None
364
 
365
  import fitz
366
 
 
375
  doc.close()
376
 
377
  markdown = "\n\n".join(md_parts)
378
+ if not markdown.strip() or len(markdown) < self.config.crawl_min_content_bytes:
379
  return None
380
 
381
  self.pdf_count += 1
 
395
 
396
  async def _process_text_file(self, url: str, ext: str, depth: int) -> dict | None:
397
  try:
398
+ resp = await self._fetch_with_retry(url)
399
+ if resp is None or resp.status_code != 200:
400
+ return None
401
  text = resp.text
402
+ if not text.strip() or len(text) < self.config.crawl_min_content_bytes:
403
  return None
404
 
405
  title = Path(urlparse(url).path).stem or "file"
 
418
  except Exception:
419
  return None
420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  def _extract_links(self, html: str, base_url: str) -> list[str]:
422
  soup = BeautifulSoup(html, "lxml")
423
  links = []
 
425
  href = a_tag["href"].strip()
426
  if not href or href.startswith("#") or href.startswith("javascript:"):
427
  continue
428
+ if href.startswith("mailto:") or href.startswith("tel:"):
429
+ continue
430
  absolute_url = urljoin(base_url, href)
431
  absolute_url = absolute_url.rstrip("/")
432
  if absolute_url.startswith("http://"):