moriarty-project 0.1.24__py3-none-any.whl → 0.1.26__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.
@@ -3,11 +3,16 @@ from __future__ import annotations
3
3
 
4
4
  import asyncio
5
5
  import random
6
- import time
6
+ from typing import TYPE_CHECKING
7
+ import re
8
+ import socket
7
9
  import ssl
10
+ import time
11
+ import types
8
12
  import certifi
13
+ import urllib.robotparser
9
14
  from dataclasses import dataclass, field
10
- from typing import Dict, List, Optional, Set, Tuple, Any, TYPE_CHECKING
15
+ from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union
11
16
  from urllib.parse import urlparse, urljoin
12
17
 
13
18
  import httpx
@@ -103,11 +108,24 @@ class WebCrawler:
103
108
 
104
109
  # Configurações de domínio
105
110
  self.parsed_base_url = self._parse_url(base_url)
106
- self.base_domain = self._get_base_domain(self.parsed_base_url.hostname or '')
111
+ # Garante que a URL base seja uma string
112
+ self.base_url_str = str(self.parsed_base_url).rstrip("/")
113
+ # Obtém o host e domínio base como strings
114
+ host = self.parsed_base_url.host
115
+ if isinstance(host, (bytes, bytearray)):
116
+ self.base_host = host.decode("utf-8")
117
+ else:
118
+ self.base_host = str(host or "")
119
+ self.base_domain = self._get_base_domain(self.base_host)
120
+
121
+ # Domínios permitidos
107
122
  self.allowed_domains = {self.base_domain}
108
123
  if follow_subdomains:
109
124
  self.allowed_domains.add(f".{self.base_domain}")
110
125
 
126
+ # Configuração do parser de robots.txt
127
+ self.robots: Optional[urllib.robotparser.RobotFileParser] = None
128
+
111
129
  # Estado do crawler
112
130
  self.visited: Set[str] = set()
113
131
  self.results: Dict[str, CrawlPage] = {}
@@ -118,35 +136,72 @@ class WebCrawler:
118
136
  self.sem: Optional[asyncio.Semaphore] = None
119
137
 
120
138
  async def _init_session(self) -> None:
121
- """Inicializa a sessão HTTP com configurações de segurança e performance."""
122
- # Configuração SSL
123
- ssl_context = ssl.create_default_context(cafile=certifi.where())
124
- if not self.verify_ssl:
125
- ssl_context.check_hostname = False
126
- ssl_context.verify_mode = ssl.CERT_NONE
127
-
128
- # Configuração do transporte HTTP
129
- limits = httpx.Limits(
130
- max_keepalive_connections=10,
131
- max_connections=20,
132
- keepalive_expiry=60.0
133
- )
134
-
135
- # Configuração do cliente HTTP
136
- self.session = httpx.AsyncClient(
137
- timeout=self.timeout,
138
- follow_redirects=True,
139
- max_redirects=self.max_redirects,
140
- http_versions=["HTTP/1.1", "HTTP/2"],
141
- limits=limits,
142
- verify=ssl_context if self.verify_ssl else False,
143
- headers=DEFAULT_HEADERS.copy(),
144
- cookies=self.session_cookies
145
- )
139
+ """Inicializa a sessão HTTP com configurações de segurança e performance.
146
140
 
147
- # Atualiza o user-agent
148
- if self.user_agent:
149
- self.session.headers["User-Agent"] = self.user_agent
141
+ Esta função configura o cliente HTTP com:
142
+ - Suporte a HTTP/1.1 e HTTP/2
143
+ - Timeout personalizado
144
+ - Limites de conexão
145
+ - Verificação SSL configurável
146
+ - Headers padrão
147
+ - Cookies de sessão
148
+ """
149
+ try:
150
+ # Configuração SSL
151
+ ssl_context = ssl.create_default_context(cafile=certifi.where())
152
+ if not self.verify_ssl:
153
+ ssl_context.check_hostname = False
154
+ ssl_context.verify_mode = ssl.CERT_NONE
155
+
156
+ # Configuração do transporte HTTP
157
+ limits = httpx.Limits(
158
+ max_keepalive_connections=10,
159
+ max_connections=20,
160
+ keepalive_expiry=60.0
161
+ )
162
+
163
+ # Configuração do cliente HTTP
164
+ transport = httpx.AsyncHTTPTransport(
165
+ verify=ssl_context if self.verify_ssl else False,
166
+ retries=3, # Número de tentativas de reconexão
167
+ http2=True, # Habilita HTTP/2
168
+ limits=limits
169
+ )
170
+
171
+ # Configuração do cliente HTTP
172
+ self.session = httpx.AsyncClient(
173
+ timeout=self.timeout,
174
+ follow_redirects=True,
175
+ max_redirects=self.max_redirects,
176
+ transport=transport,
177
+ headers=DEFAULT_HEADERS.copy(),
178
+ cookies=self.session_cookies,
179
+ trust_env=False # Ignora variáveis de ambiente como HTTP_PROXY
180
+ )
181
+
182
+ # Atualiza o user-agent
183
+ if self.user_agent:
184
+ self.session.headers["User-Agent"] = self.user_agent
185
+
186
+ # Adiciona headers adicionais para evitar detecção
187
+ self.session.headers.update({
188
+ "Accept-Encoding": "gzip, deflate, br",
189
+ "Accept-Language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7",
190
+ "Cache-Control": "no-cache",
191
+ "Pragma": "no-cache",
192
+ "Upgrade-Insecure-Requests": "1"
193
+ })
194
+
195
+ logger.info("session_initialized",
196
+ verify_ssl=self.verify_ssl,
197
+ timeout=self.timeout,
198
+ max_redirects=self.max_redirects)
199
+
200
+ except Exception as e:
201
+ logger.error("session_initialization_error",
202
+ error=str(e),
203
+ exc_info=True)
204
+ raise RuntimeError(f"Falha ao inicializar a sessão HTTP: {str(e)}") from e
150
205
 
151
206
  # Adiciona headers adicionais de stealth
152
207
  self.session.headers.update({
@@ -168,13 +223,14 @@ class WebCrawler:
168
223
  if not self.session:
169
224
  return
170
225
 
171
- robots_url = f"{self.parsed_base_url.scheme}://{self.parsed_base_url.netloc}/robots.txt"
226
+ robots_url = f"{self.parsed_base_url.scheme.decode() if isinstance(self.parsed_base_url.scheme, (bytes, bytearray)) else self.parsed_base_url.scheme}://{self.base_host}/robots.txt"
172
227
  try:
173
228
  response = await self.session.get(robots_url)
174
- if response.status_code == 200:
175
- # Aqui você pode implementar um parser de robots.txt mais sofisticado
176
- self.robots_txt = {"content": response.text}
177
- logger.info("robots_txt_found", url=robots_url)
229
+ if response.status_code == 200 and response.text:
230
+ rp = urllib.robotparser.RobotFileParser()
231
+ rp.parse(response.text.splitlines())
232
+ self.robots = rp
233
+ logger.info("robots_txt_parsed", url=robots_url)
178
234
  except Exception as e:
179
235
  logger.warning("robots_txt_error", url=robots_url, error=str(e))
180
236
 
@@ -200,8 +256,10 @@ class WebCrawler:
200
256
 
201
257
  # Inicializa a fila de URLs a serem processadas
202
258
  queue: asyncio.Queue = asyncio.Queue()
203
- initial_url = f"{self.parsed_base_url.scheme}://{self.parsed_base_url.netloc}"
259
+ # Usa a URL base já normalizada
260
+ initial_url = self.base_url_str
204
261
  await queue.put((initial_url, 0))
262
+ logger.info("crawl_started", initial_url=initial_url, max_pages=self.max_pages, max_depth=self.max_depth)
205
263
 
206
264
  # Função worker para processar URLs em paralelo
207
265
  async def worker() -> None:
@@ -234,80 +292,227 @@ class WebCrawler:
234
292
  return self.results
235
293
 
236
294
  def _parse_url(self, url: str) -> httpx.URL:
237
- """Parseia uma URL e retorna um objeto URL do httpx."""
295
+ """Parseia uma URL e retorna um objeto URL do httpx.
296
+
297
+ Args:
298
+ url: URL a ser parseada (pode ser string, bytes ou qualquer objeto que possa ser convertido para string)
299
+
300
+ Returns:
301
+ httpx.URL: Objeto URL parseado
302
+
303
+ Raises:
304
+ ValueError: Se a URL for inválida ou não puder ser parseada
305
+ """
238
306
  try:
239
- return httpx.URL(url)
307
+ # Garante que a URL seja uma string
308
+ if isinstance(url, bytes):
309
+ url = url.decode('utf-8', errors='replace').strip()
310
+ elif not isinstance(url, str):
311
+ url = str(url).strip()
312
+ else:
313
+ url = url.strip()
314
+
315
+ # Se a URL estiver vazia, levanta um erro
316
+ if not url:
317
+ raise ValueError("URL não pode ser vazia")
318
+
319
+ # Remove espaços em branco e caracteres de controle
320
+ url = ''.join(char for char in url if ord(char) >= 32 or char in '\t\n\r')
321
+
322
+ # Remove caracteres de nova linha e tabulação
323
+ url = url.replace('\n', '').replace('\r', '').replace('\t', '')
324
+
325
+ # Adiciona o esquema se não existir
326
+ if not url.startswith(('http://', 'https://')):
327
+ # Se a URL começar com //, adiciona https:
328
+ if url.startswith('//'):
329
+ url = f'https:{url}'
330
+ # Se parecer um domínio ou IP, adiciona https://
331
+ elif '://' not in url and ('.' in url or 'localhost' in url):
332
+ url = f'https://{url}'
333
+ else:
334
+ # Se não for possível determinar o esquema, usa https por padrão
335
+ url = f'https://{url}'
336
+
337
+ # Parseia a URL para garantir que é válida
338
+ parsed = httpx.URL(url)
339
+
340
+ # Garante que o host não está vazio
341
+ if not parsed.host:
342
+ raise ValueError(f"URL inválida: host não especificado em '{url}'")
343
+
344
+ return parsed
345
+
240
346
  except Exception as e:
241
- logger.error("url_parse_error", url=url, error=str(e))
242
- raise ValueError(f"URL inválida: {url}") from e
347
+ logger.error("url_parse_error", url=url, error=str(e), exc_info=True)
348
+ raise ValueError(f"URL inválida: {url} - {str(e)}") from e
243
349
 
244
- def _get_base_domain(self, hostname: str) -> str:
245
- """Extrai o domínio base de um hostname."""
350
+ def _get_base_domain(self, hostname: Union[str, bytes]) -> str:
351
+ """Extrai o domínio base de um hostname.
352
+
353
+ Args:
354
+ hostname: Hostname como string ou bytes
355
+
356
+ Returns:
357
+ str: Domínio base extraído
358
+ """
359
+ if isinstance(hostname, (bytes, bytearray)):
360
+ hostname = hostname.decode("utf-8", "ignore")
246
361
  if not hostname:
247
362
  return ""
363
+
364
+ # Remove porta se presente
365
+ if ":" in hostname:
366
+ hostname = hostname.split(":")[0]
367
+
248
368
  parts = hostname.split(".")
249
- if len(parts) > 2:
250
- return ".".join(parts[-2:])
251
- return hostname
369
+ # Se tiver menos de 3 partes, retorna o hostname completo
370
+ if len(parts) <= 2:
371
+ return hostname
372
+
373
+ # Para domínios como .co.uk, .com.br, etc.
374
+ tlds = ["co", "com", "org", "net", "gov", "edu", "mil"]
375
+ if len(parts) > 2 and parts[-2] in tlds:
376
+ return ".".join(parts[-3:])
377
+
378
+ return ".".join(parts[-2:])
252
379
 
253
380
  def _is_same_domain(self, url: str) -> bool:
254
- """Verifica se uma URL pertence ao mesmo domínio do alvo."""
381
+ """Verifica se uma URL pertence ao mesmo domínio do alvo.
382
+
383
+ Args:
384
+ url: URL a ser verificada
385
+
386
+ Returns:
387
+ bool: True se a URL pertencer ao mesmo domínio (ou subdomínio, se permitido)
388
+ """
255
389
  try:
390
+ # Parseia a URL para extrair o host
256
391
  parsed = self._parse_url(url)
257
- if not parsed.host:
392
+ host = (parsed.host or b"").decode("utf-8")
393
+ if not host:
394
+ logger.warning("no_host_in_url", url=url)
258
395
  return False
396
+
397
+ # Se não estivermos seguindo subdomínios, verifica se o host é exatamente o mesmo
398
+ if not self.follow_subdomains:
399
+ return host == self.base_host
400
+
401
+ # Se estivermos seguindo subdomínios, verifica se o domínio base é o mesmo
402
+ base = self.base_domain.lstrip(".")
403
+
404
+ # Verifica se o host é exatamente o domínio base
405
+ if host == base:
406
+ return True
407
+
408
+ # Verifica se o host termina com .domínio_base
409
+ if host.endswith(f".{base}"):
410
+ return True
259
411
 
260
- # Verifica se o domínio é o mesmo ou um subdomínio
261
- if self.follow_subdomains:
262
- return parsed.host.endswith(self.base_domain) or f".{parsed.host}".endswith(f".{self.base_domain}")
263
- return parsed.host == self.parsed_base_url.host
264
- except Exception:
412
+ # Verifica se o domínio base do host é o mesmo do domínio base alvo
413
+ return self._get_base_domain(host) == self.base_domain
414
+
415
+ except Exception as e:
416
+ logger.warning("domain_check_error", url=url, error=str(e), exc_info=True)
265
417
  return False
266
418
 
267
419
  def _normalize_url(self, url: str, base_url: Optional[str] = None) -> str:
268
- """Normaliza uma URL, resolvendo URLs relativas e removendo fragmentos."""
420
+ """Normaliza uma URL, resolvendo URLs relativas e removendo fragmentos.
421
+
422
+ Args:
423
+ url: URL a ser normalizada
424
+ base_url: URL base para resolver URLs relativas (opcional)
425
+
426
+ Returns:
427
+ str: URL normalizada ou string vazia em caso de erro
428
+ """
269
429
  try:
270
- if not url:
430
+ # Se a URL for vazia, retorna vazio
431
+ if not url or not isinstance(url, (str, bytes)):
271
432
  return ""
272
433
 
434
+ # Converte para string se for bytes
435
+ if isinstance(url, bytes):
436
+ url = url.decode('utf-8', errors='replace')
437
+
438
+ # Remove espaços em branco e caracteres de controle
439
+ url = url.strip()
440
+ url = ''.join(char for char in url if ord(char) >= 32 or char in '\t\n\r')
441
+
273
442
  # Remove fragmentos e espaços em branco
274
443
  url = url.split("#")[0].strip()
275
444
  if not url:
276
445
  return ""
277
446
 
278
- # Se for uma URL relativa, resolve em relação à base_url
279
- if base_url and not url.startswith(('http://', 'https://')):
280
- base = self._parse_url(base_url)
281
- url = str(base.join(url))
447
+ # Se for uma URL relativa e tivermos uma URL base, resolve em relação a ela
448
+ if base_url and not url.startswith(('http://', 'https://', '//')):
449
+ try:
450
+ # Remove parâmetros de consulta e fragmentos da URL base
451
+ base = str(base_url).split("?")[0].split("#")[0]
452
+ # Garante que a base termine com / se for um diretório
453
+ if not base.endswith("/"):
454
+ base = f"{base}/"
455
+ # Resolve a URL relativa
456
+ url = urljoin(base, url)
457
+ except Exception as e:
458
+ logger.warning("url_join_error", base=base_url, url=url, error=str(e), exc_info=True)
459
+ return ""
282
460
 
283
- # Parseia a URL para normalização
284
- parsed = self._parse_url(url)
461
+ # Se a URL começar com //, adiciona o esquema
462
+ if url.startswith("//"):
463
+ url = f"https:{url}"
285
464
 
286
- # Remove parâmetros de rastreamento comuns
287
- if parsed.query:
288
- query_params = []
289
- for param in parsed.query.decode().split('&'):
290
- if '=' in param and any(t in param.lower() for t in ['utm_', 'ref=', 'source=', 'fbclid=', 'gclid=']):
291
- continue
292
- query_params.append(param)
293
-
294
- # Reconstrói a URL sem os parâmetros de rastreamento
295
- if query_params:
296
- parsed = parsed.copy_with(query='&'.join(query_params))
297
- else:
298
- parsed = parsed.copy_with(query=None)
465
+ # Se ainda não tiver esquema, adiciona https://
466
+ if not url.startswith(("http://", "https://")):
467
+ url = f"https://{url}"
299
468
 
300
- # Remove barras finais desnecessárias
301
- path = parsed.path.decode()
302
- if path.endswith('/'):
303
- path = path.rstrip('/') or '/'
304
- parsed = parsed.copy_with(path=path)
305
-
306
- return str(parsed)
469
+ # Parseia a URL para normalização
470
+ try:
471
+ parsed = self._parse_url(url)
472
+
473
+ # Remove parâmetros de rastreamento comuns
474
+ if parsed.query:
475
+ query_params = []
476
+ for param in parsed.query.decode('utf-8', errors='replace').split('&'):
477
+ if '=' in param and any(t in param.lower() for t in ['utm_', 'ref=', 'source=', 'fbclid=', 'gclid=']):
478
+ continue
479
+ query_params.append(param)
480
+
481
+ # Reconstrói a URL sem os parâmetros de rastreamento
482
+ if query_params:
483
+ parsed = parsed.copy_with(query='&'.join(query_params).encode('utf-8'))
484
+ else:
485
+ parsed = parsed.copy_with(query=None)
486
+
487
+ # Remove barras finais desnecessárias
488
+ path = parsed.path.decode('utf-8', errors='replace')
489
+ if path.endswith('/'):
490
+ path = path.rstrip('/') or '/'
491
+ parsed = parsed.copy_with(path=path.encode('utf-8'))
492
+
493
+ # Remove a porta padrão se for a porta 443 (HTTPS) ou 80 (HTTP)
494
+ netloc = parsed.host.decode('utf-8', errors='replace')
495
+ if ":443" in netloc and parsed.scheme == b'https':
496
+ netloc = netloc.replace(":443", "")
497
+ parsed = parsed.copy_with(host=netloc.encode('utf-8'))
498
+ elif ":80" in netloc and parsed.scheme == b'http':
499
+ netloc = netloc.replace(":80", "")
500
+ parsed = parsed.copy_with(host=netloc.encode('utf-8'))
501
+
502
+ # Remove www. se existir para normalização
503
+ if netloc.startswith("www."):
504
+ netloc = netloc[4:]
505
+ parsed = parsed.copy_with(host=netloc.encode('utf-8'))
506
+
507
+ return str(parsed)
508
+
509
+ except Exception as e:
510
+ logger.warning("url_parsing_error", url=url, error=str(e), exc_info=True)
511
+ return ""
307
512
 
308
513
  except Exception as e:
309
- logger.warning("url_normalize_error", url=url, error=str(e))
310
- return url
514
+ logger.warning("url_normalization_error", url=url, error=str(e), exc_info=True)
515
+ return ""
311
516
 
312
517
  def _build_headers(self, referer: Optional[str] = None) -> Dict[str, str]:
313
518
  """Constrói os headers para a requisição HTTP."""
@@ -414,93 +619,486 @@ class WebCrawler:
414
619
  depth: Profundidade atual do rastreamento
415
620
  queue: Fila de URLs para processamento
416
621
  """
417
- # Cria o objeto da página com os dados básicos
418
- page = CrawlPage(
419
- url=url,
420
- status=response.status_code,
421
- redirect_chain=[(str(r.url), r.status_code) for r in response.history]
422
- )
622
+ try:
623
+ # Registra informações de depuração
624
+ logger.debug("processing_response",
625
+ url=url,
626
+ status=response.status_code,
627
+ content_type=response.headers.get("content-type"),
628
+ content_length=len(response.content) if response.content else 0)
629
+
630
+ # Cria o objeto da página com os dados básicos
631
+ page = CrawlPage(
632
+ url=url,
633
+ status=response.status_code,
634
+ redirect_chain=[(str(r.url), r.status_code) for r in response.history]
635
+ )
636
+
637
+ # Se não for uma resposta de sucesso, adiciona aos resultados e retorna
638
+ if response.status_code >= 400:
639
+ page.error = f"HTTP Error: {response.status_code}"
640
+ self.results[url] = page
641
+ logger.warning("http_error_response",
642
+ url=url,
643
+ status=response.status_code,
644
+ error=page.error)
645
+ return
646
+
647
+ # Obtém o tipo de conteúdo
648
+ content_type = response.headers.get("content-type", "").lower()
649
+
650
+ # Se não for HTML, adiciona aos resultados e retorna
651
+ if not any(ct in content_type for ct in ["text/html", "application/xhtml+xml"]):
652
+ self.results[url] = page
653
+ logger.debug("non_html_response",
654
+ url=url,
655
+ content_type=content_type)
656
+ return
657
+
658
+ try:
659
+ # Tenta decodificar o conteúdo da resposta
660
+ try:
661
+ # Tenta decodificar como UTF-8 primeiro
662
+ html_content = response.text
663
+ except UnicodeDecodeError:
664
+ # Se falhar, tenta com diferentes codificações comuns
665
+ encodings = ['latin-1', 'iso-8859-1', 'windows-1252', 'utf-8']
666
+ for encoding in encodings:
667
+ try:
668
+ html_content = response.content.decode(encoding, errors='replace')
669
+ break
670
+ except UnicodeDecodeError:
671
+ continue
672
+ else:
673
+ # Se nenhuma codificação funcionar, usa replace para caracteres inválidos
674
+ html_content = response.content.decode('utf-8', errors='replace')
675
+
676
+ # Parseia o HTML
677
+ parser = HTMLParser(html_content)
678
+
679
+ # Extrai o título da página
680
+ title = parser.css_first("title")
681
+ if title and hasattr(title, 'text'):
682
+ try:
683
+ page.title = title.text(strip=True)[:500] # Limita o tamanho do título
684
+ except Exception as e:
685
+ logger.warning("title_extraction_error", url=url, error=str(e))
686
+ page.title = "[Título não disponível]"
687
+
688
+ # Extrai os links da página
689
+ try:
690
+ await self._extract_links(parser, url, depth, queue, page)
691
+ except Exception as e:
692
+ logger.error("link_extraction_failed",
693
+ url=url,
694
+ error=str(e),
695
+ exc_info=True)
696
+
697
+ # Extrai os formulários da página
698
+ try:
699
+ self._extract_forms(parser, page)
700
+ except Exception as e:
701
+ logger.error("form_extraction_failed",
702
+ url=url,
703
+ error=str(e),
704
+ exc_info=True)
705
+
706
+ # Adiciona a página aos resultados
707
+ self.results[url] = page
708
+
709
+ # Log de sucesso
710
+ logger.info("page_processed",
711
+ url=url,
712
+ status=response.status_code,
713
+ title=page.title[:100] if page.title else "",
714
+ links_count=len(page.links),
715
+ forms_count=len(page.forms))
716
+
717
+ except Exception as e:
718
+ error_msg = f"Error processing HTML content: {str(e)}"
719
+ logger.error("html_processing_error",
720
+ url=url,
721
+ error=error_msg,
722
+ exc_info=True)
723
+ page.error = error_msg
724
+ self.results[url] = page
725
+
726
+ except Exception as e:
727
+ error_msg = f"Unexpected error in _process_response: {str(e)}"
728
+ logger.error("process_response_error",
729
+ url=url,
730
+ error=error_msg,
731
+ exc_info=True)
732
+
733
+ # Cria uma página de erro se ainda não existir
734
+ if url not in self.results:
735
+ self.results[url] = CrawlPage(
736
+ url=url,
737
+ status=response.status_code if 'response' in locals() else 0,
738
+ error=error_msg,
739
+ redirect_chain=[(str(r.url), r.status_code) for r in response.history] if 'response' in locals() else []
740
+ )
741
+
742
+ async def _extract_links(self, parser: HTMLParser, base_url: str, depth: int, queue: asyncio.Queue, page: CrawlPage) -> None:
743
+ """Extrai links do HTML e os adiciona à fila de processamento.
423
744
 
424
- # Se não for uma resposta de sucesso ou não for HTML, retorna
425
- if response.status_code >= 400 or not response.headers.get("content-type", "").startswith("text"):
426
- self.results[url] = page
745
+ Args:
746
+ parser: Objeto HTMLParser com o conteúdo da página
747
+ base_url: URL base para resolver URLs relativas
748
+ depth: Profundidade atual do rastreamento
749
+ queue: Fila de URLs para processamento
750
+
751
+ Este método:
752
+ 1. Extrai todos os links <a href> da página
753
+ 2. Filtra e normaliza as URLs
754
+ 3. Verifica se as URLs são válidas e seguras para rastreamento
755
+ 4. Adiciona as URLs válidas à fila de processamento
756
+ """
757
+ if depth >= self.max_depth:
758
+ logger.debug("max_depth_reached",
759
+ base_url=base_url,
760
+ current_depth=depth,
761
+ max_depth=self.max_depth)
427
762
  return
428
763
 
429
764
  try:
430
- # Parseia o HTML
431
- parser = HTMLParser(response.text)
765
+ # Contadores para métricas
766
+ total_links = 0
767
+ valid_links = 0
768
+ queued_links = 0
432
769
 
433
- # Extrai o título da página
434
- title = parser.css_first("title")
435
- if title and hasattr(title, 'text') and callable(title.text):
436
- page.title = title.text(strip=True)
770
+ # Extrai todos os links de uma vez para melhor desempenho
771
+ for link in parser.css("a[href]"):
772
+ try:
773
+ total_links += 1
774
+ href = link.attributes.get("href", "").strip()
775
+
776
+ # Ignora links vazios, âncoras e javascript
777
+ if not href or href.startswith("#") or href.lower().startswith(("javascript:", "mailto:", "tel:", "data:", "about:", "blob:")):
778
+ continue
779
+
780
+ # Normaliza a URL
781
+ url = self._normalize_url(href, base_url)
782
+ if not url:
783
+ continue
784
+
785
+ # Adiciona o link à lista de links da página
786
+ if page and url not in page.links:
787
+ page.links.append(url)
788
+
789
+ # Verifica se a URL é válida e segura para rastreamento
790
+ if not self.is_valid_url(url):
791
+ continue
792
+
793
+ valid_links += 1
794
+
795
+ # Verifica se a URL já foi processada ou está na fila
796
+ if self.is_url_queued_or_processed(url, queue):
797
+ continue
798
+
799
+ # Adiciona à fila de processamento
800
+ # Verifica se ainda não atingimos o limite de páginas
801
+ if len(self.results) + queue.qsize() < self.max_pages:
802
+ queue.put_nowait((url, depth + 1))
803
+ queued_links += 1
804
+
805
+ # Log detalhado para depuração
806
+ logger.debug("link_queued",
807
+ source_url=base_url,
808
+ target_url=url,
809
+ depth=depth,
810
+ queue_size=queue.qsize())
811
+
812
+ # Verifica se atingimos o limite de páginas
813
+ if len(self.results) + queue.qsize() >= self.max_pages:
814
+ logger.debug("max_pages_reached_during_extraction",
815
+ base_url=base_url,
816
+ max_pages=self.max_pages)
817
+ break
818
+
819
+ except asyncio.QueueFull:
820
+ logger.warning("queue_full_during_extraction",
821
+ queue_size=queue.qsize(),
822
+ max_pages=self.max_pages)
823
+ break
824
+
825
+ except Exception as e:
826
+ logger.warning("link_processing_error",
827
+ href=href[:200] if href else "",
828
+ base_url=base_url,
829
+ error=str(e),
830
+ exc_info=True)
437
831
 
438
- # Extrai os links da página
439
- await self._extract_links(parser, url, depth, queue)
832
+ # Log de resumo da extração de links
833
+ logger.debug("links_extraction_summary",
834
+ base_url=base_url,
835
+ total_links_processed=total_links,
836
+ valid_links=valid_links,
837
+ links_queued=queued_links,
838
+ queue_size=queue.qsize(),
839
+ visited_count=len(self.visited),
840
+ max_depth=self.max_depth,
841
+ current_depth=depth)
842
+
843
+ except Exception as e:
844
+ logger.error("extract_links_error",
845
+ base_url=base_url,
846
+ error=str(e),
847
+ exc_info=True)
848
+
849
+ # Verifica se devemos continuar processando
850
+ if queue.qsize() == 0 and len(self.visited) > 0:
851
+ logger.info("no_more_links_to_process",
852
+ base_url=base_url,
853
+ total_visited=len(self.visited),
854
+ max_pages=self.max_pages)
855
+
856
+ def _extract_forms(self, parser: HTMLParser, page: CrawlPage) -> None:
857
+ """Extrai formulários HTML da página para análise.
858
+
859
+ Args:
860
+ parser: Objeto HTMLParser com o conteúdo da página
861
+ page: Objeto CrawlPage onde os formulários serão armazenados
440
862
 
441
- # Extrai os formulários da página
442
- self._extract_forms(parser, page)
863
+ Este método extrai todos os formulários da página, incluindo:
864
+ - Método (GET/POST)
865
+ - URL de ação
866
+ - Campos de entrada (inputs, textareas, selects)
867
+ - Valores padrão
868
+ - Atributos importantes (required, type, etc.)
869
+ """
870
+ try:
871
+ forms = parser.css("form")
872
+ logger.debug("extracting_forms",
873
+ url=page.url,
874
+ form_count=len(forms))
443
875
 
444
- # Adiciona a página aos resultados
445
- self.results[url] = page
876
+ for form in forms:
877
+ try:
878
+ form_data = {
879
+ "method": form.attributes.get("method", "GET").upper(),
880
+ "enctype": form.attributes.get("enctype", "application/x-www-form-urlencoded"),
881
+ "inputs": []
882
+ }
883
+
884
+ # Obtém a ação do formulário
885
+ action = form.attributes.get("action", "").strip()
886
+ if action:
887
+ form_data["action"] = self._normalize_url(action, page.url)
888
+ else:
889
+ form_data["action"] = page.url
890
+
891
+ # Extrai os campos do formulário
892
+ self._extract_form_fields(form, form_data)
893
+
894
+ # Extrai botões de submit
895
+ self._extract_buttons(form, form_data)
896
+
897
+ # Verifica se o formulário tem campos relevantes
898
+ if form_data["inputs"]:
899
+ # Adiciona metadados adicionais
900
+ form_data["id"] = form.attributes.get("id", "")
901
+ form_data["class"] = form.attributes.get("class", "").split()
902
+
903
+ # Adiciona o formulário à página
904
+ page.forms.append(form_data)
905
+
906
+ logger.debug("form_extracted",
907
+ url=page.url,
908
+ form_action=form_data["action"],
909
+ method=form_data["method"],
910
+ input_count=len(form_data["inputs"]))
911
+
912
+ except Exception as e:
913
+ logger.error("form_processing_error",
914
+ url=page.url,
915
+ error=str(e),
916
+ exc_info=True)
446
917
 
918
+ # Log de resumo
919
+ if page.forms:
920
+ logger.info("forms_extracted",
921
+ url=page.url,
922
+ total_forms=len(page.forms),
923
+ total_inputs=sum(len(f["inputs"]) for f in page.forms))
924
+
447
925
  except Exception as e:
448
- logger.error("process_response_error", url=url, error=str(e))
449
- page.error = f"Error processing response: {str(e)}"
450
- self.results[url] = page
926
+ logger.error("form_extraction_error",
927
+ url=page.url if hasattr(page, 'url') else 'unknown',
928
+ error=str(e),
929
+ exc_info=True)
451
930
 
452
- async def _extract_links(self, parser: HTMLParser, base_url: str, depth: int, queue: asyncio.Queue) -> None:
453
- """Extrai links do HTML e os adiciona à fila de processamento."""
454
- for link in parser.css("a[href]"):
931
+ def _extract_form_fields(self, form: Any, form_data: Dict[str, Any]) -> None:
932
+ """Extrai campos de um formulário HTML.
933
+
934
+ Args:
935
+ form: Elemento HTML do formulário
936
+ form_data: Dicionário onde os campos serão armazenados
937
+ """
938
+ # Processa inputs padrão
939
+ for input_elem in form.css("input, textarea, select"):
455
940
  try:
456
- href = link.attributes.get("href", "").strip()
457
- if not href or href.startswith("#") or href.startswith("javascript:"):
458
- continue
459
-
460
- # Normaliza a URL
461
- url = self._normalize_url(href, base_url)
462
- if not url:
463
- continue
464
-
465
- # Verifica se a URL pertence ao mesmo domínio
466
- if not self._is_same_domain(url):
941
+ input_type = input_elem.attributes.get("type", "text").lower()
942
+ input_name = input_elem.attributes.get("name", "")
943
+
944
+ # Ignora inputs sem nome, a menos que sejam especiais
945
+ if not input_name and input_type not in ["submit", "button", "image"]:
467
946
  continue
468
-
469
- # Adiciona à fila se ainda não foi visitada
470
- if url not in self.visited and url not in self.results:
471
- queue.put_nowait((url, depth + 1))
472
-
947
+
948
+ # Cria o objeto de input
949
+ input_data = {
950
+ "tag": input_elem.tag.lower(),
951
+ "type": input_type,
952
+ "name": input_name,
953
+ "value": input_elem.attributes.get("value", ""),
954
+ "required": "required" in input_elem.attributes,
955
+ "disabled": "disabled" in input_elem.attributes,
956
+ "readonly": "readonly" in input_elem.attributes,
957
+ "id": input_elem.attributes.get("id", ""),
958
+ "class": input_elem.attributes.get("class", "").split(),
959
+ "placeholder": input_elem.attributes.get("placeholder", ""),
960
+ "pattern": input_elem.attributes.get("pattern", ""),
961
+ "minlength": input_elem.attributes.get("minlength", ""),
962
+ "maxlength": input_elem.attributes.get("maxlength", ""),
963
+ "autocomplete": input_elem.attributes.get("autocomplete", ""),
964
+ }
965
+
966
+ # Processa tipos especiais de input
967
+ if input_elem.tag.lower() == "select":
968
+ input_data["options"] = [
969
+ {
970
+ "value": option.attributes.get("value", ""),
971
+ "text": option.text.strip(),
972
+ "selected": "selected" in option.attributes
973
+ }
974
+ for option in input_elem.css("option")
975
+ ]
976
+ elif input_type == "radio" or input_type == "checkbox":
977
+ input_data["checked"] = "checked" in input_elem.attributes
978
+
979
+ form_data["inputs"].append(input_data)
980
+
473
981
  except Exception as e:
474
- logger.warning("link_extraction_error", href=href, error=str(e))
982
+ logger.warning("input_processing_error",
983
+ tag=input_elem.tag if hasattr(input_elem, 'tag') else 'unknown',
984
+ error=str(e))
475
985
 
476
- def _extract_forms(self, parser: HTMLParser, page: CrawlPage) -> None:
477
- """Extrai formulários do HTML."""
478
- for form in parser.css("form"):
986
+ def _extract_buttons(self, form: Any, form_data: Dict[str, Any]) -> None:
987
+ """Extrai botões de um formulário HTML.
988
+
989
+ Args:
990
+ form: Elemento HTML do formulário
991
+ form_data: Dicionário onde os botões serão armazenados
992
+ """
993
+ # Adiciona botões de submit como inputs especiais
994
+ for button in form.css("button, input[type='submit'], input[type='button'], input[type='image']"):
479
995
  try:
480
- form_data = {"method": form.attributes.get("method", "GET").upper()}
996
+ button_type = button.attributes.get("type", "submit" if button.tag == "button" else button.attributes.get("type", "")).lower()
997
+ button_name = button.attributes.get("name", "")
998
+ button_value = button.attributes.get("value", "")
481
999
 
482
- # Obtém a ação do formulário
483
- action = form.attributes.get("action", "").strip()
484
- if action:
485
- form_data["action"] = self._normalize_url(action, page.url)
486
- else:
487
- form_data["action"] = page.url
488
-
489
- # Extrai os campos do formulário
490
- form_data["fields"] = []
491
- for field in form.css("input, textarea, select"):
492
- field_data = {
493
- "name": field.attributes.get("name", ""),
494
- "type": field.attributes.get("type", "text"),
495
- "value": field.attributes.get("value", ""),
496
- "required": "required" in field.attributes
497
- }
498
- form_data["fields"].append(field_data)
1000
+ # Se for um botão sem nome, usa o texto como valor
1001
+ if not button_name and button.tag == "button":
1002
+ button_value = button.text.strip() or button_value
1003
+
1004
+ button_data = {
1005
+ "tag": button.tag,
1006
+ "type": button_type,
1007
+ "name": button_name,
1008
+ "value": button_value,
1009
+ "id": button.attributes.get("id", ""),
1010
+ "class": button.attributes.get("class", "").split(),
1011
+ }
499
1012
 
500
- page.forms.append(form_data)
1013
+ form_data["inputs"].append(button_data)
501
1014
 
502
1015
  except Exception as e:
503
- logger.warning("form_extraction_error", error=str(e))
1016
+ logger.warning("button_processing_error",
1017
+ tag=button.tag if hasattr(button, 'tag') else 'unknown',
1018
+ error=str(e))
1019
+
1020
+ def is_url_queued_or_processed(self, url: str, queue: Optional[asyncio.Queue] = None) -> bool:
1021
+ """Verifica se uma URL já foi processada ou está na fila de processamento.
1022
+
1023
+ Args:
1024
+ url: URL a ser verificada
1025
+ queue: Fila de processamento (opcional)
1026
+
1027
+ Returns:
1028
+ bool: True se a URL já foi processada ou está na fila
1029
+ """
1030
+ # Verifica se a URL está na lista de visitadas
1031
+ if url in self.visited:
1032
+ return True
1033
+
1034
+ # Verifica se a URL está nos resultados
1035
+ if url in self.results:
1036
+ return True
1037
+
1038
+ # Verifica se a URL está na fila de processamento
1039
+ if queue is not None:
1040
+ # Cria uma lista temporária para armazenar os itens da fila
1041
+ items = []
1042
+
1043
+ # Esvazia a fila temporariamente
1044
+ while not queue.empty():
1045
+ try:
1046
+ item = queue.get_nowait()
1047
+ items.append(item)
1048
+ # Verifica se a URL atual está na fila
1049
+ if item[0] == url:
1050
+ # Recoloca os itens na fila
1051
+ for i in items:
1052
+ queue.put_nowait(i)
1053
+ return True
1054
+ except asyncio.QueueEmpty:
1055
+ break
1056
+
1057
+ # Recoloca os itens na fila
1058
+ for item in items:
1059
+ queue.put_nowait(item)
1060
+
1061
+ return False
1062
+
1063
+ def is_valid_url(self, url: str) -> bool:
1064
+ """Verifica se uma URL é válida e segura para rastreamento.
1065
+
1066
+ Args:
1067
+ url: URL a ser validada
1068
+
1069
+ Returns:
1070
+ bool: True se a URL for válida e segura para rastreamento
1071
+ """
1072
+ try:
1073
+ # Parseia a URL para verificar se é válida
1074
+ parsed = self._parse_url(url)
1075
+
1076
+ # Verifica o esquema
1077
+ if parsed.scheme not in (b'http', b'https'):
1078
+ return False
1079
+
1080
+ # Obtém o host como string
1081
+ host = (parsed.host or b'').decode('utf-8')
1082
+ if not host:
1083
+ return False
1084
+
1085
+ # Verifica se o domínio é permitido
1086
+ if not self._is_same_domain(url):
1087
+ return False
1088
+
1089
+ # Verifica o robots.txt se estiver habilitado
1090
+ if self.respect_robots and self.robots:
1091
+ # Obtém o user-agent da sessão ou usa um padrão
1092
+ ua = (self.session.headers.get("User-Agent") if self.session else None) or "*"
1093
+ if not self.robots.can_fetch(ua, url):
1094
+ logger.debug("blocked_by_robots", url=url)
1095
+ return False
1096
+
1097
+ return True
1098
+
1099
+ except Exception as e:
1100
+ logger.debug("invalid_url", url=url, error=str(e))
1101
+ return False
504
1102
 
505
1103
  async def close(self) -> None:
506
1104
  """Fecha a sessão HTTP."""
@@ -508,11 +1106,35 @@ class WebCrawler:
508
1106
  await self.session.aclose()
509
1107
  self.session = None
510
1108
 
511
- async def __aenter__(self):
1109
+ async def __aenter__(self) -> 'WebCrawler':
1110
+ """Método de entrada para gerenciamento de contexto assíncrono.
1111
+
1112
+ Este método é chamado quando o bloco 'async with' é iniciado.
1113
+ Inicializa a sessão HTTP automaticamente.
1114
+
1115
+ Returns:
1116
+ WebCrawler: A própria instância do WebCrawler
1117
+
1118
+ Example:
1119
+ async with WebCrawler("https://example.com") as crawler:
1120
+ results = await crawler.crawl()
1121
+ """
512
1122
  await self._init_session()
513
1123
  return self
514
1124
 
515
- async def __aexit__(self, exc_type, exc_val, exc_tb):
1125
+ async def __aexit__(self, exc_type: Optional[Type[BaseException]],
1126
+ exc_val: Optional[BaseException],
1127
+ exc_tb: Optional[types.TracebackType]) -> None:
1128
+ """Método de saída para gerenciamento de contexto assíncrono.
1129
+
1130
+ Este método é chamado quando o bloco 'async with' é finalizado,
1131
+ garantindo que os recursos sejam liberados corretamente.
1132
+
1133
+ Args:
1134
+ exc_type: Tipo da exceção, se ocorreu alguma
1135
+ exc_val: Instância da exceção, se ocorreu alguma
1136
+ exc_tb: Objeto de traceback, se ocorreu alguma exceção
1137
+ """
516
1138
  await self.close()
517
1139
 
518
1140