moriarty-project 0.1.25__py3-none-any.whl → 0.1.27__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.
- moriarty/__init__.py +1 -1
- moriarty/cli/app.py +2 -2
- moriarty/cli/domain_cmd.py +8 -5
- moriarty/modules/domain_scanner.py +188 -60
- moriarty/modules/port_scanner_nmap.py +557 -0
- moriarty/modules/web_crawler.py +774 -152
- {moriarty_project-0.1.25.dist-info → moriarty_project-0.1.27.dist-info}/METADATA +7 -5
- {moriarty_project-0.1.25.dist-info → moriarty_project-0.1.27.dist-info}/RECORD +10 -11
- moriarty/cli/wifippler.py +0 -124
- moriarty/modules/port_scanner.py +0 -942
- {moriarty_project-0.1.25.dist-info → moriarty_project-0.1.27.dist-info}/WHEEL +0 -0
- {moriarty_project-0.1.25.dist-info → moriarty_project-0.1.27.dist-info}/entry_points.txt +0 -0
moriarty/modules/web_crawler.py
CHANGED
@@ -3,11 +3,16 @@ from __future__ import annotations
|
|
3
3
|
|
4
4
|
import asyncio
|
5
5
|
import random
|
6
|
-
import
|
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,
|
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
|
-
|
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
|
-
|
148
|
-
|
149
|
-
|
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.
|
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
|
-
|
176
|
-
|
177
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
250
|
-
|
251
|
-
|
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
|
-
|
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
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
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
|
-
|
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
|
279
|
-
if base_url and not url.startswith(('http://', 'https://')):
|
280
|
-
|
281
|
-
|
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
|
-
#
|
284
|
-
|
461
|
+
# Se a URL começar com //, adiciona o esquema
|
462
|
+
if url.startswith("//"):
|
463
|
+
url = f"https:{url}"
|
285
464
|
|
286
|
-
#
|
287
|
-
if
|
288
|
-
|
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
|
-
#
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
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("
|
310
|
-
return
|
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
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
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
|
-
|
425
|
-
|
426
|
-
|
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
|
-
#
|
431
|
-
|
765
|
+
# Contadores para métricas
|
766
|
+
total_links = 0
|
767
|
+
valid_links = 0
|
768
|
+
queued_links = 0
|
432
769
|
|
433
|
-
# Extrai
|
434
|
-
|
435
|
-
|
436
|
-
|
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
|
-
#
|
439
|
-
|
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
|
-
|
442
|
-
|
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
|
-
|
445
|
-
|
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("
|
449
|
-
|
450
|
-
|
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
|
-
|
453
|
-
"""Extrai
|
454
|
-
|
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
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
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
|
-
#
|
470
|
-
|
471
|
-
|
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("
|
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
|
477
|
-
"""Extrai
|
478
|
-
|
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
|
-
|
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
|
-
#
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
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
|
-
|
1013
|
+
form_data["inputs"].append(button_data)
|
501
1014
|
|
502
1015
|
except Exception as e:
|
503
|
-
logger.warning("
|
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
|
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
|
|