import threading import time from urllib.parse import quote from urllib.request import getproxies import httpx from server.services.device import DeviceProfile class HttpClient: def __init__(self) -> None: self._proxy = { "mode": "off", "scheme": "http", "host": "", "port": "", "username": "", "password": "", } self._client: httpx.Client | None = None self._lock = threading.Lock() def configure_proxy(self, proxy: dict) -> None: data = proxy or {} mode = data.get("mode") or "off" if mode not in ("off", "system", "custom"): raise RuntimeError("代理方式无效") scheme = data.get("scheme") or "http" if scheme not in ("http", "socks5"): raise RuntimeError("代理协议无效") host = (data.get("host") or "").strip() port = str(data.get("port") or "").strip() if mode == "custom": if not host: raise RuntimeError("请填写代理地址") if not port.isdigit() or not 1 <= int(port) <= 65535: raise RuntimeError("请填写 1 到 65535 的端口") with self._lock: self._proxy = { "mode": mode, "scheme": scheme, "host": host, "port": port, "username": (data.get("username") or "").strip(), "password": data.get("password") or "", } self._close_locked() def request_json(self, method: str, url: str, token: str, device: DeviceProfile, body: dict | None) -> dict: data = self.request_data(method, url, token, device, body) if not isinstance(data, dict): raise RuntimeError("接口没有返回数据") return data def request_data(self, method: str, url: str, token: str, device: DeviceProfile, body: dict | None): headers = device.headers() if token: headers["apasstk"] = token response = self._request(method, url, headers=headers, json=body) payload = response.json() if payload.get("code") != 200: raise RuntimeError(f"{method} {url} -> {payload.get('code')} {payload.get('msg')}") if "data" not in payload: raise RuntimeError("接口没有返回数据") return payload.get("data") def _request(self, method: str, url: str, headers: dict, json: dict | None) -> httpx.Response: last_error = None for attempt in range(6): try: return self._client_locked().request(method, url, headers=headers, json=json) except (httpx.HTTPError, TimeoutError, OSError) as exc: last_error = exc time.sleep(min(10, 1.5 * (attempt + 1))) raise RuntimeError(f"请求失败: {last_error}") def _client_locked(self) -> httpx.Client: with self._lock: if self._client is None or self._client.is_closed: self._client = httpx.Client(**self._client_kwargs()) return self._client def get_bytes(self, url: str) -> tuple[bytes, str]: response = self._client_locked().get(url, headers={"User-Agent": "RaptDrama"}) response.raise_for_status() media = response.headers.get("content-type", "image/jpeg").split(";", 1)[0].strip() or "image/jpeg" if not media.startswith("image/"): raise RuntimeError("不是图片") return response.content, media def download_file(self, url: str, dest, on_chunk=None, stop=None) -> int: from pathlib import Path target = Path(dest) target.parent.mkdir(parents=True, exist_ok=True) temporary = target.with_name(target.name + ".part") last_error = None for _attempt in range(3): if stop and stop(): raise RuntimeError("已取消") try: with self._client_locked().stream("GET", url, headers={"User-Agent": "RaptDrama"}) as response: response.raise_for_status() size = 0 with temporary.open("wb") as handle: for chunk in response.iter_bytes(64 * 1024): if stop and stop(): raise RuntimeError("已取消") if not chunk: continue handle.write(chunk) size += len(chunk) if on_chunk: on_chunk(len(chunk)) temporary.replace(target) return size except RuntimeError: temporary.unlink(missing_ok=True) raise except (httpx.HTTPError, TimeoutError, OSError) as exc: last_error = exc temporary.unlink(missing_ok=True) time.sleep(0.4) raise RuntimeError(f"下载失败: {last_error}") def read_text(self, url: str) -> str: last_error = None for _attempt in range(3): try: response = self._client_locked().get(url, headers={"User-Agent": "RaptDrama"}) response.raise_for_status() return response.text except (httpx.HTTPError, TimeoutError, OSError) as exc: last_error = exc time.sleep(0.4) raise RuntimeError(f"下载失败: {last_error}") def open_stream(self, url: str) -> httpx.Response: client = self._client_locked() request = client.build_request("GET", url, headers={"User-Agent": "RaptDrama"}) response = client.send(request, stream=True) try: response.raise_for_status() except Exception: response.close() raise return response def _close_locked(self) -> None: if self._client is not None: self._client.close() self._client = None def _client_kwargs(self) -> dict: kwargs = { "trust_env": False, "follow_redirects": True, "timeout": httpx.Timeout(25.0), } mode = self._proxy["mode"] if mode == "off": kwargs["proxy"] = None return kwargs if mode == "custom": kwargs["proxy"] = self._proxy_url() return kwargs http_proxy, https_proxy = self._system_proxies() if http_proxy and https_proxy and http_proxy != https_proxy: kwargs["mounts"] = { "http://": httpx.HTTPTransport(proxy=http_proxy), "https://": httpx.HTTPTransport(proxy=https_proxy), } return kwargs kwargs["proxy"] = https_proxy or http_proxy or None return kwargs def _proxy_url(self) -> str: scheme = "socks5h" if self._proxy["scheme"] == "socks5" else "http" auth = "" username = self._proxy["username"] if username: password = quote(self._proxy["password"] or "", safe="") auth = f"{quote(username, safe='')}:{password}@" return f"{scheme}://{auth}{self._proxy['host']}:{self._proxy['port']}" @staticmethod def _system_proxies() -> tuple[str, str]: found = getproxies() return _with_scheme(found.get("http")), _with_scheme(found.get("https")) def _with_scheme(value: str | None) -> str: text = (value or "").strip() if text and "://" not in text: return "http://" + text return text http = HttpClient()