Files
RaptDramaDump/server/services/http.py
T

201 lines
7.5 KiB
Python

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()