208 lines
7.8 KiB
Python
208 lines
7.8 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, query: dict | None = None):
|
|
headers = device.headers()
|
|
if token:
|
|
headers["apasstk"] = token
|
|
response = self._request(method, url, headers, body or None, query)
|
|
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, body: dict | None, query: dict | None) -> httpx.Response:
|
|
last_error = None
|
|
options: dict = {"headers": headers}
|
|
if query:
|
|
options["params"] = query
|
|
if body:
|
|
options["json"] = body
|
|
elif method == "POST":
|
|
options["content"] = b""
|
|
for attempt in range(6):
|
|
try:
|
|
return self._client_locked().request(method, url, **options)
|
|
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()
|