diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 169c39c..2caf46f 100755 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,9 +5,9 @@ name: Python package on: push: - branches: [ main ] + branches: [ httpx ] pull_request: - branches: [ main ] + branches: [ httpx ] jobs: build: diff --git a/duckduckgo_search/cli.py b/duckduckgo_search/cli.py index 8dd071b..9065785 100644 --- a/duckduckgo_search/cli.py +++ b/duckduckgo_search/cli.py @@ -6,10 +6,10 @@ from urllib.parse import unquote import click -from curl_cffi import requests +import httpx from .duckduckgo_search import DDGS -from .utils import json_dumps +from .utils import _get_ssl_context, json_dumps from .version import __version__ logger = logging.getLogger(__name__) @@ -81,12 +81,12 @@ def _sanitize_keywords(keywords): def _download_file(url, dir_path, filename, proxy): try: - resp = requests.get(url, proxy=proxy, impersonate="chrome", timeout=10) + resp = httpx.get(url, proxy=proxy, timeout=10, follow_redirects=True, verify=_get_ssl_context()) resp.raise_for_status() with open(os.path.join(dir_path, filename[:200]), "wb") as file: file.write(resp.content) except Exception as ex: - logger.debug(f"download_file url={url} {type(ex).__name__} {ex}") + logger.info(f"download_file url={url} {type(ex).__name__} {ex}") def _download_results(keywords, results, images=False, proxy=None, threads=None): diff --git a/duckduckgo_search/duckduckgo_search.py b/duckduckgo_search/duckduckgo_search.py index f2f11a9..db1f56f 100644 --- a/duckduckgo_search/duckduckgo_search.py +++ b/duckduckgo_search/duckduckgo_search.py @@ -9,7 +9,7 @@ class DDGS(AsyncDDGS): _loop: asyncio.AbstractEventLoop = asyncio.new_event_loop() - Thread(target=_loop.run_forever, daemon=True).start() # Start the event loop run in a separate thread. + Thread(target=_loop.run_forever, daemon=True).start() # Start the event loop in a separate thread. def __init__( self, @@ -40,12 +40,12 @@ def __exit__( self._close_session() def __del__(self) -> None: - if self._asession._closed is False: + if self._asession.is_closed is False: self._close_session() def _close_session(self) -> None: """Close the curl-cffi async session.""" - self._run_async_in_thread(self._asession.close()) + self._run_async_in_thread(self._asession.aclose()) def _run_async_in_thread(self, coro: Awaitable[Any]) -> Any: """Runs an async coroutine in a separate thread.""" diff --git a/duckduckgo_search/duckduckgo_search_async.py b/duckduckgo_search/duckduckgo_search_async.py index 36b10bc..4fbb78a 100644 --- a/duckduckgo_search/duckduckgo_search_async.py +++ b/duckduckgo_search/duckduckgo_search_async.py @@ -8,13 +8,13 @@ from functools import cached_property, partial from itertools import cycle, islice from types import TracebackType -from typing import Dict, List, Optional, Tuple, Type, Union, cast +from typing import Any, Dict, List, Optional, Tuple, Type, Union -from curl_cffi import requests +import httpx try: + from lxml.html import Element, document_fromstring from lxml.html import HTMLParser as LHTMLParser - from lxml.html import document_fromstring LXML_AVAILABLE = True except ImportError: @@ -24,6 +24,8 @@ from .utils import ( _calculate_distance, _extract_vqd, + _get_headers, + _get_ssl_context, _normalize, _normalize_url, _text_extract_json, @@ -58,12 +60,13 @@ def __init__( if not proxy and proxies: warnings.warn("'proxies' is deprecated, use 'proxy' instead.", stacklevel=1) self.proxy = proxies.get("http") or proxies.get("https") if isinstance(proxies, dict) else proxies - self._asession = requests.AsyncSession( - headers=headers, + self._asession = httpx.AsyncClient( + headers=_get_headers() if headers is None else headers, proxy=self.proxy, timeout=timeout, - impersonate="chrome", - allow_redirects=False, + follow_redirects=False, + http2=True, + verify=_get_ssl_context(), ) self._asession.headers["Referer"] = "https://duckduckgo.com/" self._exception_event = asyncio.Event() @@ -77,15 +80,15 @@ async def __aexit__( exc_val: Optional[BaseException] = None, exc_tb: Optional[TracebackType] = None, ) -> None: - await self._asession.close() + await self._asession.aclose() def __del__(self) -> None: - if self._asession._closed is False: + if self._asession.is_closed is False: with suppress(RuntimeError): - asyncio.create_task(self._asession.close()) + asyncio.create_task(self._asession.aclose()) @cached_property - def parser(self) -> Optional["LHTMLParser"]: + def parser(self) -> "LHTMLParser": """Get HTML parser.""" return LHTMLParser(remove_blank_text=True, remove_comments=True, remove_pis=True, collect_ids=False) @@ -97,28 +100,29 @@ def _get_executor(cls, max_workers: int = 1) -> ThreadPoolExecutor: return cls._executor @property - def executor(cls) -> Optional[ThreadPoolExecutor]: + def executor(cls) -> ThreadPoolExecutor: return cls._get_executor() async def _aget_url( self, method: str, url: str, - data: Optional[Union[Dict[str, str], bytes]] = None, + content: Optional[Union[str, bytes]] = None, + data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, str]] = None, ) -> bytes: if self._exception_event.is_set(): raise DuckDuckGoSearchException("Exception occurred in previous call.") try: - resp = await self._asession.request(method, url, data=data, params=params) + resp = await self._asession.request(method, url, content=content, data=data, params=params) + except httpx.TimeoutException as ex: + self._exception_event.set() + raise TimeoutException(f"{url} {type(ex).__name__}: {ex}") from ex except Exception as ex: self._exception_event.set() - if "time" in str(ex).lower(): - raise TimeoutException(f"{url} {type(ex).__name__}: {ex}") from ex raise DuckDuckGoSearchException(f"{url} {type(ex).__name__}: {ex}") from ex - logger.debug(f"_aget_url() {resp.url} {resp.status_code} {resp.elapsed:.2f} {len(resp.content)}") if resp.status_code == 200: - return cast(bytes, resp.content) + return resp.content self._exception_event.set() if resp.status_code in (202, 301, 403): raise RatelimitException(f"{resp.url} {resp.status_code} Ratelimit") @@ -303,7 +307,7 @@ async def _text_html_page(s: int, page: int) -> None: if b"No results." in resp_content: return - tree = await self._asession.loop.run_in_executor( + tree: Element = await asyncio.get_running_loop().run_in_executor( self.executor, partial(document_fromstring, resp_content, self.parser) ) @@ -382,7 +386,7 @@ async def _text_lite_page(s: int, page: int) -> None: if b"No more results." in resp_content: return - tree = await self._asession.loop.run_in_executor( + tree: Element = await asyncio.get_running_loop().run_in_executor( self.executor, partial(document_fromstring, resp_content, self.parser) ) @@ -854,7 +858,7 @@ async def maps( lat_b -= Decimal(radius) * Decimal(0.008983) lon_l -= Decimal(radius) * Decimal(0.008983) lon_r += Decimal(radius) * Decimal(0.008983) - logger.debug(f"bbox coordinates\n{lat_t} {lon_l}\n{lat_b} {lon_r}") + logger.info(f"bbox coordinates\n{lat_t} {lon_l}\n{lat_b} {lon_r}") cache = set() results: List[Dict[str, str]] = [] @@ -981,7 +985,7 @@ async def _translate_keyword(keyword: str) -> None: "POST", "https://duckduckgo.com/translation.js", params=payload, - data=keyword.encode(), + content=keyword, ) page_data = json_loads(resp_content) page_data["original"] = keyword diff --git a/duckduckgo_search/utils.py b/duckduckgo_search/utils.py index daa957a..dcab3a3 100644 --- a/duckduckgo_search/utils.py +++ b/duckduckgo_search/utils.py @@ -1,15 +1,292 @@ import re +import ssl from decimal import Decimal from html import unescape from math import atan2, cos, radians, sin, sqrt +from random import SystemRandom, choices from typing import Any, Dict, List, Union from urllib.parse import unquote +import certifi import orjson from .exceptions import DuckDuckGoSearchException +CRYPTORAND = SystemRandom() REGEX_STRIP_TAGS = re.compile("<.*?>") +SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where()) +# Include all cipher suites that Cloudflare supports today. https://developers.cloudflare.com/ssl/reference/cipher-suites/recommendations/ +DEFAULT_CIPHERS = [ + "ECDHE-ECDSA-AES128-GCM-SHA256", # modern + "ECDHE-ECDSA-CHACHA20-POLY1305", # modern + "ECDHE-RSA-AES128-GCM-SHA256", # modern + "ECDHE-RSA-CHACHA20-POLY1305", # modern + "ECDHE-ECDSA-AES256-GCM-SHA384", # modern + "ECDHE-RSA-AES256-GCM-SHA384", # modern + "ECDHE-ECDSA-AES128-SHA256", # compatible + "ECDHE-RSA-AES128-SHA256", # compatible + "ECDHE-ECDSA-AES256-SHA384", # compatible + "ECDHE-RSA-AES256-SHA384", # compatible + "ECDHE-ECDSA-AES128-SHA", # legacy + "ECDHE-RSA-AES128-SHA", # legacy + "AES128-GCM-SHA256", # legacy + "AES128-SHA256", # legacy + "AES128-SHA", # legacy + "ECDHE-RSA-AES256-SHA", # legacy + "AES256-GCM-SHA384", # legacy + "AES256-SHA256", # legacy + "AES256-SHA", # legacy + "DES-CBC3-SHA", # legacy +] +# +DEFAULT_HEADERS: List[Dict[str, Union[Dict[str, str], float]]] = [ # 04.04.2024 + { + "header": { + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "sec-ch-ua": '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"', + "Upgrade-Insecure-Requests": "1", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.3231, + }, + { + "header": { + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"macOS"', + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "sec-ch-ua": '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"', + "Upgrade-Insecure-Requests": "1", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.1944, + }, + { + "header": { + "sec-ch-ua-mobile": "?1", + "sec-ch-ua-platform": '"Android"', + "User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "sec-ch-ua": '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"', + "Upgrade-Insecure-Requests": "1", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.1035, + }, + { + "header": { + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"macOS"', + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "DNT": "1", + "sec-ch-ua": '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"', + "Upgrade-Insecure-Requests": "1", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.0568, + }, + { + "header": { + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "DNT": "1", + "sec-ch-ua": '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"', + "Upgrade-Insecure-Requests": "1", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.0345, + }, + { + "header": { + "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/123.0.6312.52 Mobile/15E148 Safari/604.1", + "Accept-Encoding": "gzip, deflate, br", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.0281, + }, + { + "header": { + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", + "Accept-Encoding": "gzip, deflate, br", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "sec-ch-ua": '"Chromium";v="122", "Not(A:Brand";v="24", "Microsoft Edge";v="122"', + "Upgrade-Insecure-Requests": "1", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.0235, + }, + { + "header": { + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Linux"', + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "sec-ch-ua": '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"', + "Upgrade-Insecure-Requests": "1", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.0224, + }, + { + "header": { + "sec-ch-ua-mobile": "?1", + "sec-ch-ua-platform": '"Android"', + "User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "DNT": "1", + "sec-ch-ua": '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"', + "Upgrade-Insecure-Requests": "1", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.0218, + }, + { + "header": { + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"macOS"', + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "sec-ch-ua": '"Chromium";v="123", "Not:A-Brand";v="8"', + "Upgrade-Insecure-Requests": "1", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.0202, + }, + { + "header": { + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Linux"', + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "DNT": "1", + "sec-ch-ua": '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"', + "Upgrade-Insecure-Requests": "1", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.0158, + }, + { + "header": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0", + "Accept-Encoding": "gzip, deflate, br", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Te": "trailers", + "Upgrade-Insecure-Requests": "1", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.014, + }, + { + "header": { + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "sec-ch-ua": '"Microsoft Edge";v="123", "Not:A-Brand";v="8", "Chromium";v="123"', + "Upgrade-Insecure-Requests": "1", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.0137, + }, + { + "header": { + "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/123.0.6312.52 Mobile/15E148 Safari/604.1", + "Accept-Encoding": "gzip, deflate, br", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US;q=1.0", + "Sec-Fetch-Mode": "same-site", + "Sec-Fetch-Dest": "navigate", + "Sec-Fetch-Site": "?1", + "Sec-Fetch-User": "document", + }, + "probability": 0.0104, + }, +] +HEADERS: List[Dict[str, str]] = [item["header"] for item in DEFAULT_HEADERS if isinstance(item["header"], dict)] +HEADERS_PROB: List[float] = [item["probability"] for item in DEFAULT_HEADERS if isinstance(item["probability"], float)] + + +def _get_headers() -> Dict[str, str]: + """Get random headers using probability.""" + return choices(HEADERS, weights=HEADERS_PROB)[0] + + +def _get_ssl_context() -> ssl.SSLContext: + """Get SSL context with shuffled ciphers.""" + CRYPTORAND.shuffle(DEFAULT_CIPHERS[:6]) + SSL_CONTEXT.set_ciphers(":".join(DEFAULT_CIPHERS)) + return SSL_CONTEXT def json_dumps(obj: Any) -> str: diff --git a/pyproject.toml b/pyproject.toml index 6833f29..7409b8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,8 +28,8 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "click>=8.1.7", - "curl_cffi>=0.6.2", + "click>=8.1.7", + "httpx[brotli, http2, socks]>=0.27.0", "orjson>=3.10.0", ] dynamic = ["version"] @@ -50,7 +50,7 @@ lxml = [ dev = [ "mypy>=1.9.0", "pytest>=8.1.1", - "ruff>=0.3.4", + "ruff>=0.3.5", ] [tool.ruff] @@ -68,11 +68,11 @@ select = [ ] ignore = ["D100"] +[tool.ruff.per-file-ignores] +"utils.py" = ["E501"] + [tool.mypy] python_version = "3.8" strict = true exclude = ['cli\.py$', '__main__\.py$', "tests/", "build/"] -[[tool.mypy.overrides]] -module = "curl_cffi" -ignore_missing_imports = true