fix: 修复proxy_service和account_service中不当的顶层import
- proxy_service.py: 将requests和WhitelistManager改为函数内延迟import, 避免启动时加载不需要的依赖;移除未使用的get_exit_ip_via_proxy导入 - account_service.py: 移除未使用的func、joinedload、AuditLog、 user_has_permission顶层导入
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 先复制依赖文件,利用 Docker 缓存层
|
# 先复制依赖文件,利用 Docker 缓存层
|
||||||
COPY pyproject.toml uv.lock* README.md ./
|
COPY pyproject.toml uv.lock* README.md alembic.ini ./
|
||||||
RUN uv venv /app/.venv && uv pip install -e .
|
RUN uv venv /app/.venv && uv pip install -e .
|
||||||
|
|
||||||
# 复制项目代码
|
# 复制项目代码
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ douyu_login_py/
|
|||||||
│ ├── backend/ # FastAPI 后端
|
│ ├── backend/ # FastAPI 后端
|
||||||
│ │ ├── main.py # 入口
|
│ │ ├── main.py # 入口
|
||||||
│ │ ├── routers/ # API 路由
|
│ │ ├── routers/ # API 路由
|
||||||
|
│ │ ├── migrations/ # Alembic 数据库迁移
|
||||||
│ │ └── services/ # 业务服务
|
│ │ └── services/ # 业务服务
|
||||||
│ └── frontend/ # React + Ant Design 前端
|
│ └── frontend/ # React + Ant Design 前端
|
||||||
├── utils/ # 通用工具
|
├── utils/ # 通用工具
|
||||||
@@ -79,6 +80,7 @@ cd web/frontend && npm install && npm run dev
|
|||||||
- 后端:FastAPI + SQLAlchemy + SQLite + JWT
|
- 后端:FastAPI + SQLAlchemy + SQLite + JWT
|
||||||
- 前端:React + Ant Design + Vite
|
- 前端:React + Ant Design + Vite
|
||||||
- 核心:Python + OpenCV + pycryptodome
|
- 核心:Python + OpenCV + pycryptodome
|
||||||
|
- 迁移:Alembic
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = web/backend/migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = sqlite:///data/web.db
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
+14
-468
@@ -1,470 +1,16 @@
|
|||||||
"""代理管理模块"""
|
"""代理管理模块兼容导出。"""
|
||||||
|
|
||||||
import json
|
from .proxy_manager import ProxyManager, get_proxy_manager
|
||||||
import re
|
from .proxy_parser import parse_proxy_response
|
||||||
import threading
|
from .proxy_resolver import ProxyResolver, resolve_working_proxy
|
||||||
import time
|
from .proxy_verifier import verify_proxies_concurrent, verify_proxy_url
|
||||||
import requests
|
|
||||||
from typing import Optional
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
class ProxyManager:
|
"ProxyManager",
|
||||||
"""代理管理器(带已验证代理池缓存,批次内共享复用)"""
|
"ProxyResolver",
|
||||||
|
"get_proxy_manager",
|
||||||
def __init__(self, api_url: str = "", whitelist_uid: str = "", whitelist_ukey: str = ""):
|
"parse_proxy_response",
|
||||||
self.api_url = api_url
|
"resolve_working_proxy",
|
||||||
self.whitelist_uid = whitelist_uid
|
"verify_proxies_concurrent",
|
||||||
self.whitelist_ukey = whitelist_ukey
|
"verify_proxy_url",
|
||||||
self.current_proxy: Optional[str] = None
|
]
|
||||||
self._cond = threading.Condition()
|
|
||||||
# 已验证可用的代理池: {proxy_url: validated_timestamp}
|
|
||||||
self._verified_pool: dict[str, float] = {}
|
|
||||||
# 正在使用中的代理(取走但未归还),避免并发账号用同一个代理
|
|
||||||
self._in_use: set[str] = set()
|
|
||||||
self._pool_ttl = 90 # 代理验证后90秒内可复用
|
|
||||||
self._fetching = False # 是否有线程正在获取代理
|
|
||||||
|
|
||||||
def _pick_from_pool_locked(self) -> Optional[str]:
|
|
||||||
"""从池中取一个未过期且未在使用的代理(调用前需持有锁)"""
|
|
||||||
now = time.time()
|
|
||||||
# 清理过期代理
|
|
||||||
expired = [p for p, t in self._verified_pool.items() if now - t > self._pool_ttl]
|
|
||||||
for p in expired:
|
|
||||||
del self._verified_pool[p]
|
|
||||||
self._in_use.discard(p)
|
|
||||||
# 取一个不在使用中的
|
|
||||||
for proxy in self._verified_pool:
|
|
||||||
if proxy not in self._in_use:
|
|
||||||
self._in_use.add(proxy)
|
|
||||||
self.current_proxy = proxy
|
|
||||||
return proxy
|
|
||||||
# 所有代理都在使用中,但池非空(并发数>代理数),允许复用第一个
|
|
||||||
for proxy in self._verified_pool:
|
|
||||||
self.current_proxy = proxy
|
|
||||||
return proxy
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_proxy(self, max_attempts: int = 5) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
获取可用代理IP,优先从已验证代理池复用。
|
|
||||||
线程安全:池空时只有一个线程调API获取并验证所有代理入池,其他线程等待后复用。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
代理URL,格式: http://ip:port
|
|
||||||
"""
|
|
||||||
with self._cond:
|
|
||||||
# 1. 优先从池中取未过期的
|
|
||||||
proxy = self._pick_from_pool_locked()
|
|
||||||
if proxy:
|
|
||||||
logger.debug(f"从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})")
|
|
||||||
return proxy
|
|
||||||
# 2. 已有线程在获取,等待结果
|
|
||||||
if self._fetching:
|
|
||||||
logger.debug("代理池空,等待其他线程获取...")
|
|
||||||
self._cond.wait(timeout=60)
|
|
||||||
proxy = self._pick_from_pool_locked()
|
|
||||||
if proxy:
|
|
||||||
logger.debug(f"等待后从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})")
|
|
||||||
return proxy
|
|
||||||
return None
|
|
||||||
# 3. 自己去获取
|
|
||||||
self._fetching = True
|
|
||||||
|
|
||||||
# 释放锁后执行耗时的API调用+验证
|
|
||||||
try:
|
|
||||||
proxies_all, msg = self._fetch_and_verify_all(max_attempts)
|
|
||||||
finally:
|
|
||||||
with self._cond:
|
|
||||||
self._fetching = False
|
|
||||||
self._cond.notify_all()
|
|
||||||
|
|
||||||
if proxies_all:
|
|
||||||
with self._cond:
|
|
||||||
now = time.time()
|
|
||||||
for p in proxies_all:
|
|
||||||
self._verified_pool[p] = now
|
|
||||||
first = proxies_all[0]
|
|
||||||
self.current_proxy = first
|
|
||||||
logger.info(f"代理池充盈 {len(proxies_all)} 个可用代理: {first} ...")
|
|
||||||
return first
|
|
||||||
logger.warning(f"获取代理失败: {msg}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _fetch_and_verify_all(self, max_attempts: int) -> tuple[Optional[list[str]], str]:
|
|
||||||
"""调代理API获取一批代理,并发验证所有可用代理,自动处理白名单同步。"""
|
|
||||||
last_error = ""
|
|
||||||
last_synced_ip: Optional[str] = None
|
|
||||||
|
|
||||||
for attempt in range(1, max_attempts + 1):
|
|
||||||
if attempt > 1:
|
|
||||||
time.sleep(min(attempt - 1, 2))
|
|
||||||
|
|
||||||
# 每次尝试前主动同步白名单(出口IP可能漂移,需重新同步)
|
|
||||||
if self.whitelist_uid and self.whitelist_ukey:
|
|
||||||
from core.douyu.whitelist import get_local_exit_ip, WhitelistManager
|
|
||||||
local_ip = get_local_exit_ip()
|
|
||||||
if local_ip and local_ip != last_synced_ip:
|
|
||||||
logger.info(f"[尝试 {attempt}] 检测出口IP: {local_ip},同步白名单...")
|
|
||||||
manager = WhitelistManager(self.whitelist_uid, self.whitelist_ukey)
|
|
||||||
ok, sync_msg = manager.sync_ip(local_ip)
|
|
||||||
if ok:
|
|
||||||
last_synced_ip = local_ip
|
|
||||||
logger.info(f"白名单同步成功: {sync_msg}")
|
|
||||||
# 等待白名单生效
|
|
||||||
time.sleep(2)
|
|
||||||
else:
|
|
||||||
logger.warning(f"白名单同步失败: {sync_msg}")
|
|
||||||
elif local_ip == last_synced_ip:
|
|
||||||
logger.debug(f"[尝试 {attempt}] 出口IP未变: {local_ip}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.get(self.api_url, timeout=10)
|
|
||||||
response.raise_for_status()
|
|
||||||
text = response.text.strip()
|
|
||||||
|
|
||||||
proxy_urls, whitelist_ip = parse_proxy_response(text)
|
|
||||||
|
|
||||||
if proxy_urls:
|
|
||||||
logger.info(f"获取到 {len(proxy_urls)} 个代理,并发验证所有")
|
|
||||||
available, msg = verify_proxies_concurrent(proxy_urls, return_all=True)
|
|
||||||
if available:
|
|
||||||
return available, msg
|
|
||||||
last_error = msg
|
|
||||||
logger.warning(f"代理预检 {attempt}/{max_attempts}: {last_error}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 代理API返回白名单错误
|
|
||||||
if whitelist_ip and self.whitelist_uid and self.whitelist_ukey:
|
|
||||||
logger.warning(f"代理需要白名单IP: {whitelist_ip},自动同步...")
|
|
||||||
from core.douyu.whitelist import WhitelistManager
|
|
||||||
manager = WhitelistManager(self.whitelist_uid, self.whitelist_ukey)
|
|
||||||
ok, sync_msg = manager.sync_ip(whitelist_ip)
|
|
||||||
if ok:
|
|
||||||
last_synced_ip = whitelist_ip
|
|
||||||
logger.info("白名单已更新,等待2秒后重试...")
|
|
||||||
time.sleep(2)
|
|
||||||
continue
|
|
||||||
return None, f'白名单同步失败: {sync_msg}'
|
|
||||||
|
|
||||||
last_error = '代理API响应无法解析'
|
|
||||||
logger.warning(f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}")
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
last_error = f'代理API请求失败: {exc}'
|
|
||||||
logger.warning(f"代理预检 {attempt}/{max_attempts}: {last_error}")
|
|
||||||
|
|
||||||
return None, f'代理预检失败({max_attempts}次尝试均失败): {last_error}'
|
|
||||||
|
|
||||||
def mark_bad(self, proxy_url: str) -> None:
|
|
||||||
"""标记代理为不可用,从池中移除(极验失败/代理连接失败时调用)"""
|
|
||||||
with self._cond:
|
|
||||||
removed = self._verified_pool.pop(proxy_url, None)
|
|
||||||
self._in_use.discard(proxy_url)
|
|
||||||
if self.current_proxy == proxy_url:
|
|
||||||
self.current_proxy = None
|
|
||||||
if removed:
|
|
||||||
logger.info(f"代理标记为不可用并移出池: {proxy_url} (池剩余 {len(self._verified_pool)})")
|
|
||||||
self._cond.notify_all()
|
|
||||||
|
|
||||||
def release_proxy(self, proxy_url: str) -> None:
|
|
||||||
"""归还代理到池(登录完成后调用,让其他账号可以复用)"""
|
|
||||||
with self._cond:
|
|
||||||
self._in_use.discard(proxy_url)
|
|
||||||
self._cond.notify_all()
|
|
||||||
|
|
||||||
def get_proxies_dict(self, proxy: str = None) -> dict:
|
|
||||||
"""获取requests使用的proxies字典"""
|
|
||||||
proxy = proxy or self.current_proxy
|
|
||||||
if proxy:
|
|
||||||
return {'http': proxy, 'https': proxy}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def verify_proxy(self, proxy: str = None) -> bool:
|
|
||||||
"""验证代理是否可用"""
|
|
||||||
proxy = proxy or self.current_proxy
|
|
||||||
if not proxy:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
response = requests.get(
|
|
||||||
'https://httpbin.org/ip',
|
|
||||||
proxies={'http': proxy, 'https': proxy},
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
logger.info(f"代理验证成功,当前IP: {data.get('origin')}")
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"代理验证失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_proxy_manager(
|
|
||||||
api_url: str = "",
|
|
||||||
whitelist_uid: str = "",
|
|
||||||
whitelist_ukey: str = "",
|
|
||||||
) -> ProxyManager:
|
|
||||||
"""获取代理管理器实例"""
|
|
||||||
return ProxyManager(api_url, whitelist_uid=whitelist_uid, whitelist_ukey=whitelist_ukey)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]:
|
|
||||||
"""
|
|
||||||
解析代理API响应,支持 JSON 格式与旧版纯文本格式。
|
|
||||||
|
|
||||||
JSON 格式示例:
|
|
||||||
正常: {"code":0,"success":"true","msg":"","data":[{"IP":"1.2.3.4","Port":5791,...}]}
|
|
||||||
白名单错误: {"code":-1,"success":"true","msg":"51.请先添加白名单:39.144.109.21","data":""}
|
|
||||||
|
|
||||||
旧版纯文本格式(兼容):
|
|
||||||
正常: 1.2.3.4:5791\\n5.6.7.8:8080
|
|
||||||
白名单错误: 请先添加白名单:39.144.109.21
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(proxy_urls, whitelist_ip)
|
|
||||||
- proxy_urls: 解析到的所有代理地址列表(http://ip:port)
|
|
||||||
- whitelist_ip: 需要添加到白名单的IP(当API返回白名单错误时),无错误时为 None
|
|
||||||
"""
|
|
||||||
text = (text or "").strip()
|
|
||||||
if not text:
|
|
||||||
return [], None
|
|
||||||
|
|
||||||
# 优先尝试 JSON 解析
|
|
||||||
try:
|
|
||||||
data = json.loads(text)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
# 白名单错误:code != 0 且 msg 含白名单提示
|
|
||||||
code = data.get("code")
|
|
||||||
msg = data.get("msg", "") or ""
|
|
||||||
if code != 0 and ("白名单" in msg or "添加白名单" in msg):
|
|
||||||
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', msg)
|
|
||||||
if ip_match:
|
|
||||||
return [], ip_match.group(1)
|
|
||||||
|
|
||||||
# 正常返回:从 data 数组提取 IP/Port
|
|
||||||
data_field = data.get("data")
|
|
||||||
proxies: list[str] = []
|
|
||||||
if isinstance(data_field, list):
|
|
||||||
for item in data_field:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
ip = item.get("IP") or item.get("ip")
|
|
||||||
port = item.get("Port") or item.get("port")
|
|
||||||
if ip and port:
|
|
||||||
proxies.append(f"http://{ip}:{port}")
|
|
||||||
if proxies:
|
|
||||||
return proxies, None
|
|
||||||
|
|
||||||
# data 为空但 code==0,可能代理暂时不可用
|
|
||||||
if code == 0 and not proxies:
|
|
||||||
return [], None
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
# 不是 JSON,回退到文本解析
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 旧版文本格式:白名单错误优先检测
|
|
||||||
if '添加白名单' in text or '白名单' in text:
|
|
||||||
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text)
|
|
||||||
if ip_match:
|
|
||||||
return [], ip_match.group(1)
|
|
||||||
|
|
||||||
# 旧版文本格式:解析所有 ip:port
|
|
||||||
matches = re.findall(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text)
|
|
||||||
proxies = [f"http://{ip}:{port}" for ip, port in matches]
|
|
||||||
if proxies:
|
|
||||||
return proxies, None
|
|
||||||
|
|
||||||
return [], None
|
|
||||||
|
|
||||||
|
|
||||||
def verify_proxy_url(proxy_url: str, timeout: tuple = (5, 8)) -> tuple[bool, str]:
|
|
||||||
"""
|
|
||||||
验证代理是否可用,只验证斗鱼主站(登录的最终目标)。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(是否可用, 消息)
|
|
||||||
"""
|
|
||||||
from utils.http_logger import log_http
|
|
||||||
import time as _time
|
|
||||||
|
|
||||||
proxies = {'http': proxy_url, 'https': proxy_url}
|
|
||||||
started = _time.monotonic()
|
|
||||||
try:
|
|
||||||
response = requests.get(
|
|
||||||
'https://www.douyu.com',
|
|
||||||
proxies=proxies,
|
|
||||||
timeout=timeout,
|
|
||||||
headers={'User-Agent': 'Mozilla/5.0'},
|
|
||||||
)
|
|
||||||
elapsed = _time.monotonic() - started
|
|
||||||
response.raise_for_status()
|
|
||||||
log_http(
|
|
||||||
category="proxy_verify", method="GET", url="https://www.douyu.com",
|
|
||||||
status_code=response.status_code, response_body=f"代理可用: {proxy_url}",
|
|
||||||
duration=elapsed, proxy=proxy_url, tag="proxy_verify",
|
|
||||||
)
|
|
||||||
return True, '代理可用 → 斗鱼主站'
|
|
||||||
except Exception as e:
|
|
||||||
elapsed = _time.monotonic() - started
|
|
||||||
err_msg = str(e)
|
|
||||||
if 'Tunnel connection failed' in err_msg or '503' in err_msg:
|
|
||||||
detail = '代理拒绝连接(白名单可能未生效)'
|
|
||||||
elif 'timed out' in err_msg.lower():
|
|
||||||
detail = '连接超时'
|
|
||||||
else:
|
|
||||||
detail = type(e).__name__
|
|
||||||
logger.debug(f"代理验证失败 [{proxy_url}]: {detail}")
|
|
||||||
log_http(
|
|
||||||
category="proxy_verify", method="GET", url="https://www.douyu.com",
|
|
||||||
duration=elapsed, error=f"[{proxy_url}] {detail}: {err_msg[:200]}",
|
|
||||||
proxy=proxy_url, tag="proxy_verify",
|
|
||||||
)
|
|
||||||
return False, detail
|
|
||||||
|
|
||||||
|
|
||||||
def verify_proxies_concurrent(proxy_urls: list[str], timeout: tuple = (5, 8), max_workers: int = 5, return_all: bool = False) -> tuple[Optional[str | list[str]], str]:
|
|
||||||
"""
|
|
||||||
并发验证多个代理URL。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
proxy_urls: 代理URL列表
|
|
||||||
timeout: 验证超时
|
|
||||||
max_workers: 最大并发数
|
|
||||||
return_all: True 时返回所有可用代理列表;False(默认)返回第一个可用的
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
return_all=False: (可用代理URL或None, 消息)
|
|
||||||
return_all=True: (可用代理URL列表或None, 消息)
|
|
||||||
"""
|
|
||||||
if not proxy_urls:
|
|
||||||
return None, '无代理可验证'
|
|
||||||
|
|
||||||
if len(proxy_urls) == 1:
|
|
||||||
ok, msg = verify_proxy_url(proxy_urls[0], timeout)
|
|
||||||
if ok:
|
|
||||||
return (proxy_urls if return_all else proxy_urls[0]), msg
|
|
||||||
return None, msg
|
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
||||||
|
|
||||||
if return_all:
|
|
||||||
# 收集所有可用代理(不取消任何任务)
|
|
||||||
available: list[str] = []
|
|
||||||
with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor:
|
|
||||||
future_map = {
|
|
||||||
executor.submit(verify_proxy_url, p, timeout): p
|
|
||||||
for p in proxy_urls
|
|
||||||
}
|
|
||||||
for future in as_completed(future_map):
|
|
||||||
try:
|
|
||||||
ok, _ = future.result()
|
|
||||||
if ok:
|
|
||||||
available.append(future_map[future])
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if available:
|
|
||||||
logger.success(f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理")
|
|
||||||
return available, f'找到 {len(available)} 个可用代理'
|
|
||||||
return None, f'共 {len(proxy_urls)} 个代理均不可用'
|
|
||||||
|
|
||||||
# 默认:返回第一个可用的,取消其余
|
|
||||||
with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor:
|
|
||||||
future_map = {
|
|
||||||
executor.submit(verify_proxy_url, p, timeout): p
|
|
||||||
for p in proxy_urls
|
|
||||||
}
|
|
||||||
for future in as_completed(future_map):
|
|
||||||
proxy_url = future_map[future]
|
|
||||||
try:
|
|
||||||
ok, msg = future.result()
|
|
||||||
if ok:
|
|
||||||
logger.success(f"并发验证找到可用代理: {proxy_url}")
|
|
||||||
for f in future_map:
|
|
||||||
if f != future:
|
|
||||||
f.cancel()
|
|
||||||
return proxy_url, msg
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return None, f'共 {len(proxy_urls)} 个代理均不可用'
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_working_proxy(
|
|
||||||
api_url: str,
|
|
||||||
whitelist_uid: str = "",
|
|
||||||
whitelist_ukey: str = "",
|
|
||||||
max_attempts: int = 4,
|
|
||||||
log_func=None,
|
|
||||||
) -> tuple[Optional[str], str]:
|
|
||||||
"""
|
|
||||||
从代理API获取可用代理,自动处理白名单同步。
|
|
||||||
支持API返回多个代理IP,逐一验证直到找到可用的。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
api_url: 代理API地址
|
|
||||||
whitelist_uid: 白名单UID(启用白名单时传入)
|
|
||||||
whitelist_ukey: 白名单UKEY
|
|
||||||
max_attempts: 最大获取尝试次数(默认4次)
|
|
||||||
log_func: 日志回调函数 (level, message)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(代理URL, 消息)
|
|
||||||
"""
|
|
||||||
def log(level, msg):
|
|
||||||
if log_func:
|
|
||||||
log_func(level, msg)
|
|
||||||
else:
|
|
||||||
getattr(logger, level if level in ('info', 'warning', 'error', 'success') else 'info', logger.info)(msg)
|
|
||||||
|
|
||||||
synced_whitelist = False
|
|
||||||
last_error = ""
|
|
||||||
|
|
||||||
for attempt in range(1, max_attempts + 1):
|
|
||||||
# 重试之间增加退避延迟,避免代理API返回同一个不可用IP
|
|
||||||
if attempt > 1:
|
|
||||||
delay = min(attempt - 1, 2)
|
|
||||||
log('info', f'等待 {delay}s 后重试...')
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
log('info', f'代理预检 {attempt}/{max_attempts}: 正在获取代理')
|
|
||||||
try:
|
|
||||||
response = requests.get(api_url, timeout=10)
|
|
||||||
response.raise_for_status()
|
|
||||||
text = response.text.strip()
|
|
||||||
|
|
||||||
proxy_urls, whitelist_ip = parse_proxy_response(text)
|
|
||||||
|
|
||||||
if proxy_urls:
|
|
||||||
log('info', f'获取到 {len(proxy_urls)} 个代理,并发验证')
|
|
||||||
proxy_url, msg = verify_proxies_concurrent(proxy_urls)
|
|
||||||
if proxy_url:
|
|
||||||
log('success', f'代理预检成功: {proxy_url}')
|
|
||||||
return proxy_url, msg
|
|
||||||
last_error = msg
|
|
||||||
log('warning', f'代理预检 {attempt}/{max_attempts}: {last_error}')
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 代理API返回白名单错误
|
|
||||||
if whitelist_ip and not synced_whitelist and whitelist_uid and whitelist_ukey:
|
|
||||||
log('warning', f'代理需要白名单IP: {whitelist_ip},自动同步...')
|
|
||||||
from core.douyu.whitelist import WhitelistManager
|
|
||||||
manager = WhitelistManager(whitelist_uid, whitelist_ukey)
|
|
||||||
ok, sync_msg = manager.sync_ip(whitelist_ip)
|
|
||||||
log('success' if ok else 'error', f'白名单同步: {sync_msg}')
|
|
||||||
if ok:
|
|
||||||
synced_whitelist = True
|
|
||||||
log('info', '白名单已更新,等待2秒后重试...')
|
|
||||||
time.sleep(2)
|
|
||||||
continue
|
|
||||||
return None, f'白名单同步失败: {sync_msg}'
|
|
||||||
|
|
||||||
last_error = f'代理API响应无法解析'
|
|
||||||
log('warning', f'代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}')
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
last_error = f'代理API请求失败: {exc}'
|
|
||||||
log('warning', f'代理预检 {attempt}/{max_attempts}: {last_error}')
|
|
||||||
|
|
||||||
return None, f'代理预检失败({max_attempts}次尝试均失败): {last_error}'
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""代理池管理。"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from .proxy_resolver import ProxyResolver
|
||||||
|
from .proxy_whitelist import DouyuWhitelistSyncer
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyManager:
|
||||||
|
"""代理管理器(带已验证代理池缓存,批次内共享复用)"""
|
||||||
|
|
||||||
|
def __init__(self, api_url: str = "", whitelist_uid: str = "", whitelist_ukey: str = ""):
|
||||||
|
self.api_url = api_url
|
||||||
|
self.whitelist_uid = whitelist_uid
|
||||||
|
self.whitelist_ukey = whitelist_ukey
|
||||||
|
self.current_proxy: Optional[str] = None
|
||||||
|
self._cond = threading.Condition()
|
||||||
|
# 已验证可用的代理池: {proxy_url: validated_timestamp}
|
||||||
|
self._verified_pool: dict[str, float] = {}
|
||||||
|
# 正在使用中的代理(取走但未归还),避免并发账号用同一个代理
|
||||||
|
self._in_use: set[str] = set()
|
||||||
|
self._pool_ttl = 90
|
||||||
|
self._fetching = False
|
||||||
|
self._whitelist_syncer = (
|
||||||
|
DouyuWhitelistSyncer(whitelist_uid, whitelist_ukey)
|
||||||
|
if whitelist_uid and whitelist_ukey
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def _pick_from_pool_locked(self) -> Optional[str]:
|
||||||
|
"""从池中取一个未过期且未在使用的代理(调用前需持有锁)。"""
|
||||||
|
now = time.time()
|
||||||
|
expired = [proxy for proxy, ts in self._verified_pool.items() if now - ts > self._pool_ttl]
|
||||||
|
for proxy in expired:
|
||||||
|
del self._verified_pool[proxy]
|
||||||
|
self._in_use.discard(proxy)
|
||||||
|
|
||||||
|
for proxy in self._verified_pool:
|
||||||
|
if proxy not in self._in_use:
|
||||||
|
self._in_use.add(proxy)
|
||||||
|
self.current_proxy = proxy
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
for proxy in self._verified_pool:
|
||||||
|
self.current_proxy = proxy
|
||||||
|
return proxy
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_proxy(self, max_attempts: int = 5) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取可用代理 IP,优先从已验证代理池复用。
|
||||||
|
|
||||||
|
线程安全:池空时只有一个线程调 API 获取并验证所有代理入池,
|
||||||
|
其他线程等待后复用。
|
||||||
|
"""
|
||||||
|
with self._cond:
|
||||||
|
proxy = self._pick_from_pool_locked()
|
||||||
|
if proxy:
|
||||||
|
logger.debug(f"从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})")
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
if self._fetching:
|
||||||
|
logger.debug("代理池空,等待其他线程获取...")
|
||||||
|
self._cond.wait(timeout=60)
|
||||||
|
proxy = self._pick_from_pool_locked()
|
||||||
|
if proxy:
|
||||||
|
logger.debug(f"等待后从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})")
|
||||||
|
return proxy
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._fetching = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
proxies_all, msg = self._fetch_and_verify_all(max_attempts)
|
||||||
|
finally:
|
||||||
|
with self._cond:
|
||||||
|
self._fetching = False
|
||||||
|
self._cond.notify_all()
|
||||||
|
|
||||||
|
if proxies_all:
|
||||||
|
with self._cond:
|
||||||
|
now = time.time()
|
||||||
|
for proxy in proxies_all:
|
||||||
|
self._verified_pool[proxy] = now
|
||||||
|
first = proxies_all[0]
|
||||||
|
self._in_use.add(first)
|
||||||
|
self.current_proxy = first
|
||||||
|
logger.info(f"代理池充盈 {len(proxies_all)} 个可用代理: {first} ...")
|
||||||
|
return first
|
||||||
|
|
||||||
|
logger.warning(f"获取代理失败: {msg}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _fetch_and_verify_all(self, max_attempts: int) -> tuple[Optional[list[str]], str]:
|
||||||
|
"""调代理 API 获取一批代理,并发验证所有可用代理。"""
|
||||||
|
resolver = ProxyResolver(
|
||||||
|
api_url=self.api_url,
|
||||||
|
whitelist_syncer=self._whitelist_syncer,
|
||||||
|
sync_local_exit_ip=bool(self._whitelist_syncer),
|
||||||
|
sync_whitelist_once=False,
|
||||||
|
)
|
||||||
|
available, msg = resolver.fetch_verified(max_attempts=max_attempts, return_all=True)
|
||||||
|
if isinstance(available, list):
|
||||||
|
return available, msg
|
||||||
|
if isinstance(available, str):
|
||||||
|
return [available], msg
|
||||||
|
return None, msg
|
||||||
|
|
||||||
|
def mark_bad(self, proxy_url: str) -> None:
|
||||||
|
"""标记代理为不可用,从池中移除(极验失败/代理连接失败时调用)。"""
|
||||||
|
with self._cond:
|
||||||
|
removed = self._verified_pool.pop(proxy_url, None)
|
||||||
|
self._in_use.discard(proxy_url)
|
||||||
|
if self.current_proxy == proxy_url:
|
||||||
|
self.current_proxy = None
|
||||||
|
if removed:
|
||||||
|
logger.info(f"代理标记为不可用并移出池: {proxy_url} (池剩余 {len(self._verified_pool)})")
|
||||||
|
self._cond.notify_all()
|
||||||
|
|
||||||
|
def release_proxy(self, proxy_url: str) -> None:
|
||||||
|
"""归还代理到池(登录完成后调用,让其他账号可以复用)。"""
|
||||||
|
with self._cond:
|
||||||
|
self._in_use.discard(proxy_url)
|
||||||
|
self._cond.notify_all()
|
||||||
|
|
||||||
|
def get_proxies_dict(self, proxy: str = None) -> dict:
|
||||||
|
"""获取 requests 使用的 proxies 字典。"""
|
||||||
|
proxy = proxy or self.current_proxy
|
||||||
|
if proxy:
|
||||||
|
return {'http': proxy, 'https': proxy}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def verify_proxy(self, proxy: str = None) -> bool:
|
||||||
|
"""验证当前代理是否可用。"""
|
||||||
|
proxy = proxy or self.current_proxy
|
||||||
|
if not proxy:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
'https://httpbin.org/ip',
|
||||||
|
proxies={'http': proxy, 'https': proxy},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"代理验证成功,当前IP: {data.get('origin')}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"代理验证失败: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_proxy_manager(
|
||||||
|
api_url: str = "",
|
||||||
|
whitelist_uid: str = "",
|
||||||
|
whitelist_ukey: str = "",
|
||||||
|
) -> ProxyManager:
|
||||||
|
"""获取代理管理器实例。"""
|
||||||
|
return ProxyManager(api_url, whitelist_uid=whitelist_uid, whitelist_ukey=whitelist_ukey)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""代理 API 响应解析。"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
解析代理 API 响应,支持 JSON 格式与旧版纯文本格式。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(proxy_urls, whitelist_ip)
|
||||||
|
- proxy_urls: 解析到的所有代理地址列表(http://ip:port)
|
||||||
|
- whitelist_ip: 需要添加到白名单的 IP,无错误时为 None
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
code = data.get("code")
|
||||||
|
msg = data.get("msg", "") or ""
|
||||||
|
if code != 0 and ("白名单" in msg or "添加白名单" in msg):
|
||||||
|
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', msg)
|
||||||
|
if ip_match:
|
||||||
|
return [], ip_match.group(1)
|
||||||
|
|
||||||
|
data_field = data.get("data")
|
||||||
|
proxies: list[str] = []
|
||||||
|
if isinstance(data_field, list):
|
||||||
|
for item in data_field:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
ip = item.get("IP") or item.get("ip")
|
||||||
|
port = item.get("Port") or item.get("port")
|
||||||
|
if ip and port:
|
||||||
|
proxies.append(f"http://{ip}:{port}")
|
||||||
|
if proxies:
|
||||||
|
return proxies, None
|
||||||
|
|
||||||
|
if code == 0 and not proxies:
|
||||||
|
return [], None
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if '添加白名单' in text or '白名单' in text:
|
||||||
|
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text)
|
||||||
|
if ip_match:
|
||||||
|
return [], ip_match.group(1)
|
||||||
|
|
||||||
|
matches = re.findall(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text)
|
||||||
|
proxies = [f"http://{ip}:{port}" for ip, port in matches]
|
||||||
|
if proxies:
|
||||||
|
return proxies, None
|
||||||
|
|
||||||
|
return [], None
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""代理获取、白名单同步、可用性验证的统一流程。"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Callable, Optional, Protocol
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from .proxy_parser import parse_proxy_response
|
||||||
|
from .proxy_verifier import verify_proxies_concurrent
|
||||||
|
from .proxy_whitelist import DouyuWhitelistSyncer
|
||||||
|
|
||||||
|
LogFunc = Callable[[str, str], None]
|
||||||
|
|
||||||
|
|
||||||
|
class WhitelistSyncer(Protocol):
|
||||||
|
"""代理解析流程需要的白名单能力。"""
|
||||||
|
|
||||||
|
def sync_ip(self, ip: str) -> tuple[bool, str]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_local_exit_ip(self) -> Optional[str]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyResolver:
|
||||||
|
"""从代理 API 获取并验证代理,按需同步白名单。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_url: str,
|
||||||
|
whitelist_syncer: Optional[WhitelistSyncer] = None,
|
||||||
|
log_func: Optional[LogFunc] = None,
|
||||||
|
sync_local_exit_ip: bool = False,
|
||||||
|
sync_whitelist_once: bool = True,
|
||||||
|
):
|
||||||
|
self.api_url = api_url
|
||||||
|
self.whitelist_syncer = whitelist_syncer
|
||||||
|
self.log_func = log_func
|
||||||
|
self.sync_local_exit_ip = sync_local_exit_ip
|
||||||
|
self.sync_whitelist_once = sync_whitelist_once
|
||||||
|
self._last_synced_ip: Optional[str] = None
|
||||||
|
self._has_synced_whitelist = False
|
||||||
|
|
||||||
|
def _log(self, level: str, message: str) -> None:
|
||||||
|
if self.log_func:
|
||||||
|
self.log_func(level, message)
|
||||||
|
return
|
||||||
|
log_method = getattr(
|
||||||
|
logger,
|
||||||
|
level if level in ('debug', 'info', 'warning', 'error', 'success') else 'info',
|
||||||
|
logger.info,
|
||||||
|
)
|
||||||
|
log_method(message)
|
||||||
|
|
||||||
|
def _sync_ip(self, ip: str) -> tuple[bool, str]:
|
||||||
|
if not self.whitelist_syncer:
|
||||||
|
return False, '未配置白名单 UID/UKEY'
|
||||||
|
ok, sync_msg = self.whitelist_syncer.sync_ip(ip)
|
||||||
|
if ok:
|
||||||
|
self._last_synced_ip = ip
|
||||||
|
self._has_synced_whitelist = True
|
||||||
|
return ok, sync_msg
|
||||||
|
|
||||||
|
def _sync_local_exit_ip_if_needed(self, attempt: int) -> None:
|
||||||
|
if not self.sync_local_exit_ip or not self.whitelist_syncer:
|
||||||
|
return
|
||||||
|
|
||||||
|
local_ip = self.whitelist_syncer.get_local_exit_ip()
|
||||||
|
if local_ip and local_ip != self._last_synced_ip:
|
||||||
|
self._log('info', f"[尝试 {attempt}] 检测出口IP: {local_ip},同步白名单...")
|
||||||
|
ok, sync_msg = self._sync_ip(local_ip)
|
||||||
|
if ok:
|
||||||
|
self._log('info', f"白名单同步成功: {sync_msg}")
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
self._log('warning', f"白名单同步失败: {sync_msg}")
|
||||||
|
elif local_ip == self._last_synced_ip:
|
||||||
|
self._log('debug', f"[尝试 {attempt}] 出口IP未变: {local_ip}")
|
||||||
|
|
||||||
|
def fetch_verified(
|
||||||
|
self,
|
||||||
|
max_attempts: int = 4,
|
||||||
|
return_all: bool = False,
|
||||||
|
) -> tuple[Optional[str | list[str]], str]:
|
||||||
|
"""
|
||||||
|
获取并验证代理。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_attempts: 最大尝试次数
|
||||||
|
return_all: True 返回所有可用代理,False 返回第一个可用代理
|
||||||
|
"""
|
||||||
|
last_error = ""
|
||||||
|
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
if attempt > 1:
|
||||||
|
delay = min(attempt - 1, 2)
|
||||||
|
if self.log_func:
|
||||||
|
self._log('info', f'等待 {delay}s 后重试...')
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
self._sync_local_exit_ip_if_needed(attempt)
|
||||||
|
self._log('info', f'代理预检 {attempt}/{max_attempts}: 正在获取代理')
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(self.api_url, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
text = response.text.strip()
|
||||||
|
|
||||||
|
proxy_urls, whitelist_ip = parse_proxy_response(text)
|
||||||
|
|
||||||
|
if proxy_urls:
|
||||||
|
self._log('info', f'获取到 {len(proxy_urls)} 个代理,并发验证')
|
||||||
|
available, msg = verify_proxies_concurrent(proxy_urls, return_all=return_all)
|
||||||
|
if available:
|
||||||
|
if not return_all:
|
||||||
|
self._log('success', f'代理预检成功: {available}')
|
||||||
|
return available, msg
|
||||||
|
last_error = msg
|
||||||
|
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if whitelist_ip and self.whitelist_syncer:
|
||||||
|
if self.sync_whitelist_once and self._has_synced_whitelist:
|
||||||
|
last_error = f'白名单已同步但代理API仍返回白名单错误: {whitelist_ip}'
|
||||||
|
self._log('warning', last_error)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._log('warning', f'代理需要白名单IP: {whitelist_ip},自动同步...')
|
||||||
|
ok, sync_msg = self._sync_ip(whitelist_ip)
|
||||||
|
self._log('success' if ok else 'error', f'白名单同步: {sync_msg}')
|
||||||
|
if ok:
|
||||||
|
self._log('info', '白名单已更新,等待2秒后重试...')
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
return None, f'白名单同步失败: {sync_msg}'
|
||||||
|
|
||||||
|
last_error = '代理API响应无法解析'
|
||||||
|
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}")
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = f'代理API请求失败: {exc}'
|
||||||
|
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}")
|
||||||
|
|
||||||
|
return None, f'代理预检失败({max_attempts}次尝试均失败): {last_error}'
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_working_proxy(
|
||||||
|
api_url: str,
|
||||||
|
whitelist_uid: str = "",
|
||||||
|
whitelist_ukey: str = "",
|
||||||
|
max_attempts: int = 4,
|
||||||
|
log_func: Optional[LogFunc] = None,
|
||||||
|
) -> tuple[Optional[str], str]:
|
||||||
|
"""
|
||||||
|
从代理 API 获取可用代理,自动处理白名单同步。
|
||||||
|
|
||||||
|
保留旧函数签名,供 Web 层代理测试继续使用。
|
||||||
|
"""
|
||||||
|
syncer = (
|
||||||
|
DouyuWhitelistSyncer(whitelist_uid, whitelist_ukey)
|
||||||
|
if whitelist_uid and whitelist_ukey
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
resolver = ProxyResolver(
|
||||||
|
api_url=api_url,
|
||||||
|
whitelist_syncer=syncer,
|
||||||
|
log_func=log_func,
|
||||||
|
sync_local_exit_ip=False,
|
||||||
|
sync_whitelist_once=True,
|
||||||
|
)
|
||||||
|
proxy, msg = resolver.fetch_verified(max_attempts=max_attempts, return_all=False)
|
||||||
|
if isinstance(proxy, list):
|
||||||
|
return (proxy[0] if proxy else None), msg
|
||||||
|
return proxy, msg
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""代理可用性验证。"""
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from typing import Optional
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
def verify_proxy_url(proxy_url: str, timeout: tuple = (5, 8)) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
验证代理是否可用,只验证斗鱼主站(登录的最终目标)。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(是否可用, 消息)
|
||||||
|
"""
|
||||||
|
from utils.http_logger import log_http
|
||||||
|
|
||||||
|
proxies = {'http': proxy_url, 'https': proxy_url}
|
||||||
|
started = _time.monotonic()
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
'https://www.douyu.com',
|
||||||
|
proxies=proxies,
|
||||||
|
timeout=timeout,
|
||||||
|
headers={'User-Agent': 'Mozilla/5.0'},
|
||||||
|
)
|
||||||
|
elapsed = _time.monotonic() - started
|
||||||
|
response.raise_for_status()
|
||||||
|
log_http(
|
||||||
|
category="proxy_verify", method="GET", url="https://www.douyu.com",
|
||||||
|
status_code=response.status_code, response_body=f"代理可用: {proxy_url}",
|
||||||
|
duration=elapsed, proxy=proxy_url, tag="proxy_verify",
|
||||||
|
)
|
||||||
|
return True, '代理可用 → 斗鱼主站'
|
||||||
|
except Exception as exc:
|
||||||
|
elapsed = _time.monotonic() - started
|
||||||
|
err_msg = str(exc)
|
||||||
|
if 'Tunnel connection failed' in err_msg or '503' in err_msg:
|
||||||
|
detail = '代理拒绝连接(白名单可能未生效)'
|
||||||
|
elif 'timed out' in err_msg.lower():
|
||||||
|
detail = '连接超时'
|
||||||
|
else:
|
||||||
|
detail = type(exc).__name__
|
||||||
|
logger.debug(f"代理验证失败 [{proxy_url}]: {detail}")
|
||||||
|
log_http(
|
||||||
|
category="proxy_verify", method="GET", url="https://www.douyu.com",
|
||||||
|
duration=elapsed, error=f"[{proxy_url}] {detail}: {err_msg[:200]}",
|
||||||
|
proxy=proxy_url, tag="proxy_verify",
|
||||||
|
)
|
||||||
|
return False, detail
|
||||||
|
|
||||||
|
|
||||||
|
def verify_proxies_concurrent(
|
||||||
|
proxy_urls: list[str],
|
||||||
|
timeout: tuple = (5, 8),
|
||||||
|
max_workers: int = 5,
|
||||||
|
return_all: bool = False,
|
||||||
|
) -> tuple[Optional[str | list[str]], str]:
|
||||||
|
"""
|
||||||
|
并发验证多个代理 URL。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
return_all=False: (可用代理 URL 或 None, 消息)
|
||||||
|
return_all=True: (可用代理 URL 列表或 None, 消息)
|
||||||
|
"""
|
||||||
|
if not proxy_urls:
|
||||||
|
return None, '无代理可验证'
|
||||||
|
|
||||||
|
if len(proxy_urls) == 1:
|
||||||
|
ok, msg = verify_proxy_url(proxy_urls[0], timeout)
|
||||||
|
if ok:
|
||||||
|
return (proxy_urls if return_all else proxy_urls[0]), msg
|
||||||
|
return None, msg
|
||||||
|
|
||||||
|
if return_all:
|
||||||
|
available: list[str] = []
|
||||||
|
with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor:
|
||||||
|
future_map = {
|
||||||
|
executor.submit(verify_proxy_url, proxy, timeout): proxy
|
||||||
|
for proxy in proxy_urls
|
||||||
|
}
|
||||||
|
for future in as_completed(future_map):
|
||||||
|
try:
|
||||||
|
ok, _ = future.result()
|
||||||
|
if ok:
|
||||||
|
available.append(future_map[future])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if available:
|
||||||
|
logger.success(f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理")
|
||||||
|
return available, f'找到 {len(available)} 个可用代理'
|
||||||
|
return None, f'共 {len(proxy_urls)} 个代理均不可用'
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor:
|
||||||
|
future_map = {
|
||||||
|
executor.submit(verify_proxy_url, proxy, timeout): proxy
|
||||||
|
for proxy in proxy_urls
|
||||||
|
}
|
||||||
|
for future in as_completed(future_map):
|
||||||
|
proxy_url = future_map[future]
|
||||||
|
try:
|
||||||
|
ok, msg = future.result()
|
||||||
|
if ok:
|
||||||
|
logger.success(f"并发验证找到可用代理: {proxy_url}")
|
||||||
|
for item in future_map:
|
||||||
|
if item != future:
|
||||||
|
item.cancel()
|
||||||
|
return proxy_url, msg
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None, f'共 {len(proxy_urls)} 个代理均不可用'
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""代理模块使用的白名单适配器。"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .whitelist import WhitelistManager, get_local_exit_ip
|
||||||
|
|
||||||
|
|
||||||
|
class DouyuWhitelistSyncer:
|
||||||
|
"""把白名单 API 隔离成代理解析流程可调用的适配器。"""
|
||||||
|
|
||||||
|
def __init__(self, uid: str, ukey: str):
|
||||||
|
self.uid = uid
|
||||||
|
self.ukey = ukey
|
||||||
|
|
||||||
|
def sync_ip(self, ip: str) -> tuple[bool, str]:
|
||||||
|
manager = WhitelistManager(self.uid, self.ukey)
|
||||||
|
return manager.sync_ip(ip)
|
||||||
|
|
||||||
|
def get_local_exit_ip(self) -> Optional[str]:
|
||||||
|
return get_local_exit_ip()
|
||||||
@@ -18,6 +18,7 @@ dependencies = [
|
|||||||
"bcrypt>=4.0.0",
|
"bcrypt>=4.0.0",
|
||||||
"pydantic>=2.0.0",
|
"pydantic>=2.0.0",
|
||||||
"python-multipart>=0.0.9",
|
"python-multipart>=0.0.9",
|
||||||
|
"alembic>=1.18.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -2,6 +2,89 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = "==3.12.*"
|
requires-python = "==3.12.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alembic"
|
||||||
|
version = "1.18.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mako" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-doc"
|
||||||
|
version = "0.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.14.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.6.17"
|
version = "2026.6.17"
|
||||||
@@ -11,6 +94,29 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.7"
|
version = "3.4.7"
|
||||||
@@ -36,6 +142,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -45,29 +163,148 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptography"
|
||||||
|
version = "49.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "douyu-login-py"
|
name = "douyu-login-py"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "alembic" },
|
||||||
|
{ name = "bcrypt" },
|
||||||
|
{ name = "fastapi" },
|
||||||
{ name = "loguru" },
|
{ name = "loguru" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "opencv-python-headless" },
|
{ name = "opencv-python-headless" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "pycryptodome" },
|
{ name = "pycryptodome" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pydantic" },
|
||||||
{ name = "requests" },
|
{ name = "python-jose", extra = ["cryptography"] },
|
||||||
|
{ name = "python-multipart" },
|
||||||
|
{ name = "requests", extra = ["socks"] },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "alembic", specifier = ">=1.18.4" },
|
||||||
|
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||||
|
{ name = "fastapi", specifier = ">=0.110.0" },
|
||||||
{ name = "loguru", specifier = ">=0.7.0" },
|
{ name = "loguru", specifier = ">=0.7.0" },
|
||||||
{ name = "numpy", specifier = ">=1.24.0" },
|
{ name = "numpy", specifier = ">=1.24.0" },
|
||||||
{ name = "opencv-python-headless", specifier = ">=4.8.0" },
|
{ name = "opencv-python-headless", specifier = ">=4.8.0" },
|
||||||
{ name = "pillow", specifier = ">=10.0.0" },
|
{ name = "pillow", specifier = ">=10.0.0" },
|
||||||
{ name = "pycryptodome", specifier = ">=3.19.0" },
|
{ name = "pycryptodome", specifier = ">=3.19.0" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0" },
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||||
{ name = "requests", specifier = ">=2.31.0" },
|
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||||
|
{ name = "requests", extras = ["socks"], specifier = ">=2.31.0" },
|
||||||
|
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ecdsa"
|
||||||
|
version = "0.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi"
|
||||||
|
version = "0.138.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-doc" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/58/ff455d9fe47c60abadb34b9e05a304b1f05f5ab8000ac01565156b6f5e43/fastapi-0.138.0.tar.gz", hash = "sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061", size = 419240, upload-time = "2026-06-20T01:18:05.259Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/ff/8496d9847a5fedae775eb49460722d3efaa80487854273e9647ae876218c/fastapi-0.138.0-py3-none-any.whl", hash = "sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0", size = 126779, upload-time = "2026-06-20T01:18:03.483Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "greenlet"
|
||||||
|
version = "3.5.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/dd/8b/befc3cb36965f397d87e86fb3b00e3ec0dc67c1ecb0986d7f54ee528f018/greenlet-3.5.2.tar.gz", hash = "sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c", size = 199243, upload-time = "2026-06-17T20:19:01.317Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/7a/6bc2a7835731387ed303b9390ce68a116ab053df05450a59181239200454/greenlet-3.5.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:76dae33e97b52743a19210931ee3e78a88fe1438bc2fc4ee5e7512d289bfad4f", size = 288351, upload-time = "2026-06-17T17:36:17.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/1b/bd98062fcef6d0e9d0873ab6f2d029772e6ea342972ae43275bd6177900f/greenlet-3.5.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30252d191d6959df1d040b559a38fc017139606c5ecc2ad00416557c0355d742", size = 604273, upload-time = "2026-06-17T18:07:20.296Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/e6/fe392c522bf45d976abe7db2793f6ef4e87b053ebb869deeaae46aeb54da/greenlet-3.5.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1adc23c50f22b0f5979521909a8360ab4a3d3bef8b641ce633a04cf1b1c967ea", size = 616536, upload-time = "2026-06-17T18:29:43.205Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/4a/399ff81fa93a19d6a9df394cef0355f082dbc19ad41aba9593cd0ad444e2/greenlet-3.5.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f052fff492c52fdfa99bd3b3c1389a53de37dae76a0562741417f0d018f02b3", size = 613749, upload-time = "2026-06-17T17:39:28.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/75/f519593f12ad43d08e28c03a95cfe2eeae011707dbc9dab0c4a263ce90f9/greenlet-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:120b77c2a18ebf629c3a7886f68c6d01e065654844ad468f15bb93ace66f2094", size = 1573725, upload-time = "2026-06-17T18:22:12.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/bc/bc1ea4b0754c6c51bbf9d94677b0b1f7fbda8cbb404e44a896854fc0a940/greenlet-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a850f6224088ef7dcc70f1a545cb6b3d119c35d6dca63b925b9f35da0635cdad", size = 1638132, upload-time = "2026-06-17T17:40:06.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/c0/f0f5a34247df60de285f75f22e57f14027f4b3c43820981854b5b643ca6d/greenlet-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:89da99ee8345b458ea2f16831dad31c88ddcdec454b48704d569a0b8fb28f146", size = 239393, upload-time = "2026-06-17T17:33:47.09Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/17/a8544e165445f30aea67a8d9cf2786d2bb0eb1b0e0d224b4d9bd80e2d587/greenlet-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:ca92411942154023c65851e6077d8ca0d00f19de5fa80bb2c6f196ff6c920ba9", size = 237723, upload-time = "2026-06-17T17:36:47.776Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httptools"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -92,6 +329,37 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mako"
|
||||||
|
version = "1.3.12"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numpy"
|
name = "numpy"
|
||||||
version = "2.4.6"
|
version = "2.4.6"
|
||||||
@@ -148,6 +416,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyasn1"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pycryptodome"
|
name = "pycryptodome"
|
||||||
version = "3.23.0"
|
version = "3.23.0"
|
||||||
@@ -167,6 +453,97 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.13.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.46.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pysocks"
|
||||||
|
version = "1.7.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-jose"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "ecdsa" },
|
||||||
|
{ name = "pyasn1" },
|
||||||
|
{ name = "rsa" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
cryptography = [
|
||||||
|
{ name = "cryptography" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-multipart"
|
||||||
|
version = "0.0.32"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
@@ -200,6 +577,86 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
socks = [
|
||||||
|
{ name = "pysocks" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rsa"
|
||||||
|
version = "4.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyasn1" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlalchemy"
|
||||||
|
version = "2.0.51"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
@@ -209,6 +666,87 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.49.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
standard = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "httptools" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||||
|
{ name = "watchfiles" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvloop"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchfiles"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "win32-setctime"
|
name = "win32-setctime"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|||||||
+17
-42
@@ -5,12 +5,16 @@ from pathlib import Path
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
|
||||||
DB_PATH = Path(__file__).parent.parent.parent / "data" / "web.db"
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DB_PATH = PROJECT_ROOT / "data" / "web.db"
|
||||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DB_PATH}")
|
||||||
|
|
||||||
|
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
f"sqlite:///{DB_PATH}",
|
DATABASE_URL,
|
||||||
connect_args={"check_same_thread": False},
|
connect_args=connect_args,
|
||||||
echo=False,
|
echo=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,49 +32,20 @@ def get_db():
|
|||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""建表 + 写入初始数据。"""
|
"""执行数据库迁移 + 写入初始数据。"""
|
||||||
Base.metadata.create_all(bind=engine)
|
run_migrations()
|
||||||
_migrate()
|
|
||||||
_seed()
|
_seed()
|
||||||
|
|
||||||
|
|
||||||
def _migrate():
|
def run_migrations():
|
||||||
"""数据库迁移:为已有表添加新列。"""
|
"""运行 Alembic 迁移到最新版本。"""
|
||||||
from sqlalchemy import text
|
from alembic import command
|
||||||
with engine.connect() as conn:
|
from alembic.config import Config
|
||||||
# 检查 accounts.tag 列是否存在
|
|
||||||
result = conn.execute(text("PRAGMA table_info(accounts)"))
|
|
||||||
columns = [row[1] for row in result]
|
|
||||||
if 'tag' not in columns:
|
|
||||||
conn.execute(text("ALTER TABLE accounts ADD COLUMN tag VARCHAR(64) DEFAULT ''"))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# 检查 users.custom_permissions 列是否存在
|
config = Config(str(PROJECT_ROOT / "alembic.ini"))
|
||||||
result = conn.execute(text("PRAGMA table_info(users)"))
|
config.set_main_option("script_location", str(PROJECT_ROOT / "web" / "backend" / "migrations"))
|
||||||
columns = [row[1] for row in result]
|
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||||
if 'custom_permissions' not in columns:
|
command.upgrade(config, "head")
|
||||||
conn.execute(text("ALTER TABLE users ADD COLUMN custom_permissions JSON DEFAULT NULL"))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# 检查 accounts.email_imap_ssl 列是否存在
|
|
||||||
result = conn.execute(text("PRAGMA table_info(accounts)"))
|
|
||||||
columns = [row[1] for row in result]
|
|
||||||
if 'email_imap_ssl' not in columns:
|
|
||||||
# 旧数据端口993的默认True(SSL),其他端口默认False
|
|
||||||
conn.execute(text("ALTER TABLE accounts ADD COLUMN email_imap_ssl BOOLEAN DEFAULT 1"))
|
|
||||||
conn.commit()
|
|
||||||
# 修正旧数据中使用 111.229.206.54 的账号:端口改为143,SSL改为False
|
|
||||||
conn.execute(text(
|
|
||||||
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
|
|
||||||
"WHERE email_imap_server = '111.229.206.54'"
|
|
||||||
))
|
|
||||||
conn.commit()
|
|
||||||
# 修正旧数据中使用 mail.bdhg.xyz 的账号:993端口不通,改用143非SSL
|
|
||||||
conn.execute(text(
|
|
||||||
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
|
|
||||||
"WHERE email_imap_server = 'mail.bdhg.xyz'"
|
|
||||||
))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _seed():
|
def _seed():
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 数据库迁移
|
||||||
|
|
||||||
|
本目录由 Alembic 管理 Web 后台数据库结构。
|
||||||
|
|
||||||
|
常用命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run alembic upgrade head
|
||||||
|
uv run alembic revision -m "描述"
|
||||||
|
```
|
||||||
|
|
||||||
|
应用启动时会自动执行 `upgrade head`,本地开发通常不需要手动运行迁移命令。
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Alembic 迁移环境。"""
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[3]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
from web.backend.database import Base, DATABASE_URL # noqa: E402
|
||||||
|
from web.backend import models # noqa: F401,E402
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""离线模式生成 SQL。"""
|
||||||
|
context.configure(
|
||||||
|
url=DATABASE_URL,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""在线模式直接执行迁移。"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""初始化 Web 后台数据库结构
|
||||||
|
|
||||||
|
Revision ID: 20260623_0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-06-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "20260623_0001"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(bind, table_name: str) -> bool:
|
||||||
|
return sa.inspect(bind).has_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(bind, table_name: str) -> set[str]:
|
||||||
|
if not _has_table(bind, table_name):
|
||||||
|
return set()
|
||||||
|
return {column["name"] for column in sa.inspect(bind).get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _indexes(bind, table_name: str) -> set[str]:
|
||||||
|
if not _has_table(bind, table_name):
|
||||||
|
return set()
|
||||||
|
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> bool:
|
||||||
|
if column.name in _columns(bind, table_name):
|
||||||
|
return False
|
||||||
|
op.add_column(table_name, column)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None:
|
||||||
|
if name not in _indexes(bind, table_name):
|
||||||
|
op.create_index(name, table_name, columns, unique=unique)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
if not _has_table(bind, "users"):
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("username", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(length=256), nullable=False),
|
||||||
|
sa.Column("role", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("remark", sa.String(length=256), nullable=True),
|
||||||
|
sa.Column("custom_permissions", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_add_column_if_missing(bind, "users", sa.Column("custom_permissions", sa.JSON(), nullable=True))
|
||||||
|
_create_index_if_missing(bind, "ix_users_username", "users", ["username"], unique=True)
|
||||||
|
|
||||||
|
if not _has_table(bind, "accounts"):
|
||||||
|
op.create_table(
|
||||||
|
"accounts",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("username", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("password", sa.String(length=256), nullable=False),
|
||||||
|
sa.Column("email", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("email_password", sa.String(length=256), nullable=False),
|
||||||
|
sa.Column("email_imap_server", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("email_imap_port", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("email_imap_ssl", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("assigned_to", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("tag", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("remark", sa.String(length=256), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_to"], ["users.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_add_column_if_missing(bind, "accounts", sa.Column("tag", sa.String(length=64), server_default=""))
|
||||||
|
added_ssl = _add_column_if_missing(bind, "accounts", sa.Column("email_imap_ssl", sa.Boolean(), server_default=sa.text("1")))
|
||||||
|
if added_ssl:
|
||||||
|
op.execute(
|
||||||
|
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
|
||||||
|
"WHERE email_imap_server = '111.229.206.54'"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
|
||||||
|
"WHERE email_imap_server = 'mail.bdhg.xyz'"
|
||||||
|
)
|
||||||
|
_create_index_if_missing(bind, "ix_accounts_assigned_to", "accounts", ["assigned_to"])
|
||||||
|
|
||||||
|
if not _has_table(bind, "proxy_config"):
|
||||||
|
op.create_table(
|
||||||
|
"proxy_config",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("api_url", sa.String(length=512), nullable=True),
|
||||||
|
sa.Column("http", sa.String(length=256), nullable=True),
|
||||||
|
sa.Column("https", sa.String(length=256), nullable=True),
|
||||||
|
sa.Column("whitelist_enabled", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("whitelist_uid", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("whitelist_ukey", sa.String(length=128), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _has_table(bind, "login_tasks"):
|
||||||
|
op.create_table(
|
||||||
|
"login_tasks",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("batch_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("account_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("cookie", sa.Text(), nullable=True),
|
||||||
|
sa.Column("message", sa.String(length=512), nullable=True),
|
||||||
|
sa.Column("created_by", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_create_index_if_missing(bind, "ix_login_tasks_batch_id", "login_tasks", ["batch_id"])
|
||||||
|
|
||||||
|
if not _has_table(bind, "audit_logs"):
|
||||||
|
op.create_table(
|
||||||
|
"audit_logs",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("username", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("action", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("target", sa.String(length=256), nullable=True),
|
||||||
|
sa.Column("detail", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _has_table(bind, "audit_logs"):
|
||||||
|
op.drop_table("audit_logs")
|
||||||
|
if _has_table(bind, "login_tasks"):
|
||||||
|
op.drop_index("ix_login_tasks_batch_id", table_name="login_tasks")
|
||||||
|
op.drop_table("login_tasks")
|
||||||
|
if _has_table(bind, "proxy_config"):
|
||||||
|
op.drop_table("proxy_config")
|
||||||
|
if _has_table(bind, "accounts"):
|
||||||
|
op.drop_index("ix_accounts_assigned_to", table_name="accounts")
|
||||||
|
op.drop_table("accounts")
|
||||||
|
if _has_table(bind, "users"):
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.drop_table("users")
|
||||||
@@ -3,11 +3,9 @@
|
|||||||
import csv
|
import csv
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.orm import Session, joinedload
|
|
||||||
|
|
||||||
from ..models import Account, AuditLog, LoginTask
|
from ..models import Account, LoginTask
|
||||||
from ..permissions import user_has_permission
|
|
||||||
|
|
||||||
|
|
||||||
EMAIL_PATTERN = re.compile(r'^[^\s@|]+@[^\s@|]+\.[^\s@|]+$')
|
EMAIL_PATTERN = re.compile(r'^[^\s@|]+@[^\s@|]+\.[^\s@|]+$')
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import threading
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests as req_lib
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response
|
from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response
|
||||||
from core.douyu.whitelist import WhitelistManager, get_exit_ip_via_proxy
|
|
||||||
from ..models import ProxyConfig as ProxyConfigModel, AuditLog
|
from ..models import ProxyConfig as ProxyConfigModel, AuditLog
|
||||||
|
|
||||||
|
|
||||||
@@ -146,6 +144,8 @@ class ProxyService:
|
|||||||
loop: asyncio.AbstractEventLoop,
|
loop: asyncio.AbstractEventLoop,
|
||||||
):
|
):
|
||||||
"""在线程中执行白名单测试。"""
|
"""在线程中执行白名单测试。"""
|
||||||
|
import requests as req_lib
|
||||||
|
from core.douyu.whitelist import WhitelistManager
|
||||||
|
|
||||||
def push(level, message):
|
def push(level, message):
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import api from './client';
|
||||||
|
import type {
|
||||||
|
AccountItem,
|
||||||
|
AssignmentsSummary,
|
||||||
|
MessageCountResponse,
|
||||||
|
MessageDeletedResponse,
|
||||||
|
MessageResponse,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export const accountApi = {
|
||||||
|
list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) =>
|
||||||
|
api.get<AccountItem[], AccountItem[]>('/accounts', { params }),
|
||||||
|
import: (text: string) => api.post<MessageCountResponse, MessageCountResponse>('/accounts/import', { text }),
|
||||||
|
assign: (id: number, assigned_to: number | null) =>
|
||||||
|
api.put<MessageResponse, MessageResponse>(`/accounts/${id}/assign`, { assigned_to }),
|
||||||
|
batchAssign: (account_ids: number[], assigned_to: number | null) =>
|
||||||
|
api.post<MessageCountResponse, MessageCountResponse>('/accounts/batch-assign', { account_ids, assigned_to }),
|
||||||
|
assignmentsSummary: () =>
|
||||||
|
api.get<AssignmentsSummary, AssignmentsSummary>('/accounts/assignments/summary'),
|
||||||
|
setTag: (id: number, tag: string) =>
|
||||||
|
api.put<MessageResponse, MessageResponse>(`/accounts/${id}/tag`, { tag }),
|
||||||
|
batchTag: (account_ids: number[], tag: string) =>
|
||||||
|
api.put<MessageCountResponse, MessageCountResponse>('/accounts/batch-tag', { account_ids, tag }),
|
||||||
|
listTags: () => api.get<string[], string[]>('/accounts/tags/list'),
|
||||||
|
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/accounts/${id}`),
|
||||||
|
batchDelete: (account_ids: number[]) =>
|
||||||
|
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/accounts/batch/delete', { params: { account_ids: account_ids.join(',') } }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import api from './client';
|
||||||
|
import type { CurrentUser, LoginResult, MessageResponse } from './types';
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
api.post<LoginResult, LoginResult>('/auth/login', { username, password }),
|
||||||
|
|
||||||
|
me: () => api.get<CurrentUser, CurrentUser>('/auth/me'),
|
||||||
|
|
||||||
|
logout: () => api.post<MessageResponse, MessageResponse>('/auth/logout'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
withCredentials: true, // 携带 httpOnly cookie
|
||||||
|
});
|
||||||
|
|
||||||
|
// 响应拦截:统一错误处理
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response.data,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
const msg = error.response?.data?.detail || error.message || '请求失败';
|
||||||
|
return Promise.reject(new Error(msg));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import api from './client';
|
||||||
|
import type { CookieItem, MessageResponse } from './types';
|
||||||
|
|
||||||
|
export const cookieApi = {
|
||||||
|
list: () => api.get<CookieItem[], CookieItem[]>('/cookies'),
|
||||||
|
exportCsv: (format?: string) => api.get<Blob, Blob>('/cookies/export', { responseType: 'blob', params: format ? { format } : {} }),
|
||||||
|
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/cookies/${id}`),
|
||||||
|
};
|
||||||
@@ -1,22 +1 @@
|
|||||||
import axios from 'axios';
|
export { default } from './client';
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: '/api',
|
|
||||||
timeout: 30000,
|
|
||||||
withCredentials: true, // 携带 httpOnly cookie
|
|
||||||
});
|
|
||||||
|
|
||||||
// 响应拦截:统一错误处理
|
|
||||||
api.interceptors.response.use(
|
|
||||||
(response) => response.data,
|
|
||||||
(error) => {
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
const msg = error.response?.data?.detail || error.message || '请求失败';
|
|
||||||
return Promise.reject(new Error(msg));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default api;
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import api from './client';
|
||||||
|
import type { BatchLoginResult, LoginTaskItem, MessageDeletedResponse, MessageResponse } from './types';
|
||||||
|
|
||||||
|
export const loginApi = {
|
||||||
|
createBatch: (account_ids: number[], max_geetest_retries?: number, concurrency?: number, max_proxy_retries?: number) =>
|
||||||
|
api.post<BatchLoginResult, BatchLoginResult>('/login/batch', { account_ids, max_geetest_retries, concurrency, max_proxy_retries }),
|
||||||
|
listTasks: (batch_id?: string) =>
|
||||||
|
api.get<LoginTaskItem[], LoginTaskItem[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
|
||||||
|
stop: (batch_id: string) => api.post<MessageResponse, MessageResponse>(`/login/stop/${batch_id}`),
|
||||||
|
deleteTask: (id: number) => api.delete<MessageResponse, MessageResponse>(`/login/tasks/${id}`),
|
||||||
|
deleteTasks: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>(`/login/tasks`, { params: { task_ids: ids.join(',') } }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import api from './client';
|
||||||
|
import type { HttpLogClearResult, HttpLogListResult } from './types';
|
||||||
|
|
||||||
|
export const logApi = {
|
||||||
|
listHttp: (params?: { limit?: number; offset?: number; category?: string; level?: string; keyword?: string }) =>
|
||||||
|
api.get<HttpLogListResult, HttpLogListResult>('/logs/http', { params }),
|
||||||
|
clearHttp: () => api.delete<HttpLogClearResult, HttpLogClearResult>('/logs/http'),
|
||||||
|
};
|
||||||
@@ -1,228 +1,8 @@
|
|||||||
import api from './index';
|
export * from './types';
|
||||||
|
export { accountApi } from './accounts';
|
||||||
// ==================== 通用响应类型 ====================
|
export { authApi } from './auth';
|
||||||
|
export { cookieApi } from './cookies';
|
||||||
interface MessageResponse {
|
export { logApi } from './logs';
|
||||||
message: string;
|
export { loginApi } from './login';
|
||||||
success: boolean;
|
export { proxyApi } from './proxy';
|
||||||
}
|
export { userApi } from './users';
|
||||||
|
|
||||||
interface MessageCountResponse extends MessageResponse {
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageDeletedResponse extends MessageResponse {
|
|
||||||
deleted: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Auth ====================
|
|
||||||
|
|
||||||
export interface LoginResult {
|
|
||||||
access_token: string;
|
|
||||||
token_type: string;
|
|
||||||
role: string;
|
|
||||||
username: string;
|
|
||||||
permissions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CurrentUser {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
role: string;
|
|
||||||
role_label: string;
|
|
||||||
is_active: boolean;
|
|
||||||
remark: string | null;
|
|
||||||
permissions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserInfo {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
role: string;
|
|
||||||
is_active: boolean;
|
|
||||||
remark: string;
|
|
||||||
permissions: string[];
|
|
||||||
custom_permissions?: string[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Account ====================
|
|
||||||
|
|
||||||
export interface AccountItem {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
password?: string | null;
|
|
||||||
email?: string | null;
|
|
||||||
email_password?: string | null;
|
|
||||||
tag: string;
|
|
||||||
assigned_to: number | null;
|
|
||||||
assigned_username: string | null;
|
|
||||||
remark: string;
|
|
||||||
created_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssignmentsSummary {
|
|
||||||
support_users: SupportUserItem[];
|
|
||||||
unassigned_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SupportUserItem {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
assigned_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Login Task ====================
|
|
||||||
|
|
||||||
export interface LoginTaskItem {
|
|
||||||
id: number;
|
|
||||||
batch_id: string;
|
|
||||||
account_id: number;
|
|
||||||
account_username: string;
|
|
||||||
status: string;
|
|
||||||
cookie: string;
|
|
||||||
message: string;
|
|
||||||
created_by: number;
|
|
||||||
created_at: string | null;
|
|
||||||
finished_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BatchLoginResult {
|
|
||||||
batch_id: string;
|
|
||||||
count: number;
|
|
||||||
success: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Cookie ====================
|
|
||||||
|
|
||||||
export interface CookieItem {
|
|
||||||
id: number;
|
|
||||||
batch_id: string;
|
|
||||||
account_id: number;
|
|
||||||
account_username: string;
|
|
||||||
assigned_to: number | null;
|
|
||||||
assigned_username: string | null;
|
|
||||||
created_at: string | null;
|
|
||||||
cookie: string;
|
|
||||||
cookie_preview: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Proxy ====================
|
|
||||||
|
|
||||||
export interface ProxyConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
api_url: string;
|
|
||||||
http: string;
|
|
||||||
https: string;
|
|
||||||
whitelist_enabled: boolean;
|
|
||||||
whitelist_uid: string;
|
|
||||||
whitelist_ukey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProxyTestResult {
|
|
||||||
test_id: string;
|
|
||||||
success: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Log ====================
|
|
||||||
|
|
||||||
export interface HttpLogEntry {
|
|
||||||
timestamp: string;
|
|
||||||
ts: number;
|
|
||||||
category: string;
|
|
||||||
tag: string;
|
|
||||||
method: string;
|
|
||||||
url: string;
|
|
||||||
proxy: string | null;
|
|
||||||
request: { headers: Record<string, string>; body: string };
|
|
||||||
response: { status_code: number | null; headers: Record<string, string>; body: string };
|
|
||||||
duration_ms: number | null;
|
|
||||||
error: string | null;
|
|
||||||
level: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HttpLogListResult {
|
|
||||||
items: HttpLogEntry[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HttpLogClearResult {
|
|
||||||
success: boolean;
|
|
||||||
cleared: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Permissions ====================
|
|
||||||
|
|
||||||
export interface PermissionsListResult {
|
|
||||||
permissions: Record<string, string>;
|
|
||||||
role_permissions: Record<string, string[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== API 定义 ====================
|
|
||||||
|
|
||||||
export const authApi = {
|
|
||||||
login: (username: string, password: string) =>
|
|
||||||
api.post<any, LoginResult>('/auth/login', { username, password }),
|
|
||||||
|
|
||||||
me: () => api.get<any, CurrentUser>('/auth/me'),
|
|
||||||
|
|
||||||
logout: () => api.post<any, MessageResponse>('/auth/logout'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const userApi = {
|
|
||||||
list: () => api.get<any, UserInfo[]>('/users'),
|
|
||||||
create: (data: { username: string; password: string; role: string; remark?: string }) =>
|
|
||||||
api.post<any, UserInfo>('/users', data),
|
|
||||||
update: (id: number, data: { password?: string; role?: string; is_active?: boolean; remark?: string; custom_permissions?: string[] | null }) =>
|
|
||||||
api.put<any, UserInfo>(`/users/${id}`, data),
|
|
||||||
delete: (id: number) => api.delete<any, MessageResponse>(`/users/${id}`),
|
|
||||||
listPermissions: () => api.get<any, PermissionsListResult>('/users/permissions/list'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const accountApi = {
|
|
||||||
list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) =>
|
|
||||||
api.get<any, AccountItem[]>('/accounts', { params }),
|
|
||||||
import: (text: string) => api.post<any, MessageCountResponse>('/accounts/import', { text }),
|
|
||||||
assign: (id: number, assigned_to: number | null) =>
|
|
||||||
api.put<any, MessageResponse>(`/accounts/${id}/assign`, { assigned_to }),
|
|
||||||
batchAssign: (account_ids: number[], assigned_to: number | null) =>
|
|
||||||
api.post<any, MessageCountResponse>('/accounts/batch-assign', { account_ids, assigned_to }),
|
|
||||||
assignmentsSummary: () =>
|
|
||||||
api.get<any, AssignmentsSummary>('/accounts/assignments/summary'),
|
|
||||||
setTag: (id: number, tag: string) =>
|
|
||||||
api.put<any, MessageResponse>(`/accounts/${id}/tag`, { tag }),
|
|
||||||
batchTag: (account_ids: number[], tag: string) =>
|
|
||||||
api.put<any, MessageCountResponse>('/accounts/batch-tag', { account_ids, tag }),
|
|
||||||
listTags: () => api.get<any, string[]>('/accounts/tags/list'),
|
|
||||||
delete: (id: number) => api.delete<any, MessageResponse>(`/accounts/${id}`),
|
|
||||||
batchDelete: (account_ids: number[]) =>
|
|
||||||
api.delete<any, MessageDeletedResponse>('/accounts/batch/delete', { params: { account_ids: account_ids.join(',') } }),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loginApi = {
|
|
||||||
createBatch: (account_ids: number[], max_geetest_retries?: number, concurrency?: number, max_proxy_retries?: number) =>
|
|
||||||
api.post<any, BatchLoginResult>('/login/batch', { account_ids, max_geetest_retries, concurrency, max_proxy_retries }),
|
|
||||||
listTasks: (batch_id?: string) =>
|
|
||||||
api.get<any, LoginTaskItem[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
|
|
||||||
stop: (batch_id: string) => api.post<any, MessageResponse>(`/login/stop/${batch_id}`),
|
|
||||||
deleteTask: (id: number) => api.delete<any, MessageResponse>(`/login/tasks/${id}`),
|
|
||||||
deleteTasks: (ids: number[]) => api.delete<any, MessageDeletedResponse>(`/login/tasks`, { params: { task_ids: ids.join(',') } }),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const cookieApi = {
|
|
||||||
list: () => api.get<any, CookieItem[]>('/cookies'),
|
|
||||||
exportCsv: (format?: string) => api.get('/cookies/export', { responseType: 'blob', params: format ? { format } : {} }),
|
|
||||||
delete: (id: number) => api.delete<any, MessageResponse>(`/cookies/${id}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const proxyApi = {
|
|
||||||
get: () => api.get<any, ProxyConfig>('/proxy'),
|
|
||||||
update: (data: ProxyConfig) => api.put<any, ProxyConfig>('/proxy', data),
|
|
||||||
test: () => api.post<any, ProxyTestResult>('/proxy/test'),
|
|
||||||
testWhitelist: () => api.post<any, ProxyTestResult>('/proxy/whitelist/test'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const logApi = {
|
|
||||||
listHttp: (params?: { limit?: number; offset?: number; category?: string; level?: string; keyword?: string }) =>
|
|
||||||
api.get<any, HttpLogListResult>('/logs/http', { params }),
|
|
||||||
clearHttp: () => api.delete<any, HttpLogClearResult>('/logs/http'),
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import api from './client';
|
||||||
|
import type { ProxyConfig, ProxyTestResult } from './types';
|
||||||
|
|
||||||
|
export const proxyApi = {
|
||||||
|
get: () => api.get<ProxyConfig, ProxyConfig>('/proxy'),
|
||||||
|
update: (data: ProxyConfig) => api.put<ProxyConfig, ProxyConfig>('/proxy', data),
|
||||||
|
test: () => api.post<ProxyTestResult, ProxyTestResult>('/proxy/test'),
|
||||||
|
testWhitelist: () => api.post<ProxyTestResult, ProxyTestResult>('/proxy/whitelist/test'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// ==================== 通用响应类型 ====================
|
||||||
|
|
||||||
|
export interface MessageResponse {
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageCountResponse extends MessageResponse {
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageDeletedResponse extends MessageResponse {
|
||||||
|
deleted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Auth ====================
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
role: string;
|
||||||
|
username: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
role_label: string;
|
||||||
|
is_active: boolean;
|
||||||
|
remark: string | null;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
is_active: boolean;
|
||||||
|
remark: string;
|
||||||
|
permissions: string[];
|
||||||
|
custom_permissions?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Account ====================
|
||||||
|
|
||||||
|
export interface AccountItem {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
password?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
email_password?: string | null;
|
||||||
|
tag: string;
|
||||||
|
assigned_to: number | null;
|
||||||
|
assigned_username: string | null;
|
||||||
|
remark: string;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignmentsSummary {
|
||||||
|
support_users: SupportUserItem[];
|
||||||
|
unassigned_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportUserItem {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
assigned_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Login Task ====================
|
||||||
|
|
||||||
|
export interface LoginTaskItem {
|
||||||
|
id: number;
|
||||||
|
batch_id: string;
|
||||||
|
account_id: number;
|
||||||
|
account_username: string;
|
||||||
|
status: string;
|
||||||
|
cookie: string;
|
||||||
|
message: string;
|
||||||
|
created_by: number;
|
||||||
|
created_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchLoginResult {
|
||||||
|
batch_id: string;
|
||||||
|
count: number;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Cookie ====================
|
||||||
|
|
||||||
|
export interface CookieItem {
|
||||||
|
id: number;
|
||||||
|
batch_id: string;
|
||||||
|
account_id: number;
|
||||||
|
account_username: string;
|
||||||
|
assigned_to: number | null;
|
||||||
|
assigned_username: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
cookie: string;
|
||||||
|
cookie_preview: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Proxy ====================
|
||||||
|
|
||||||
|
export interface ProxyConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
api_url: string;
|
||||||
|
http: string;
|
||||||
|
https: string;
|
||||||
|
whitelist_enabled: boolean;
|
||||||
|
whitelist_uid: string;
|
||||||
|
whitelist_ukey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyTestResult {
|
||||||
|
test_id: string;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Log ====================
|
||||||
|
|
||||||
|
export interface HttpLogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
ts: number;
|
||||||
|
category: string;
|
||||||
|
tag: string;
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
proxy: string | null;
|
||||||
|
request: { headers: Record<string, string>; body: string };
|
||||||
|
response: { status_code: number | null; headers: Record<string, string>; body: string };
|
||||||
|
duration_ms: number | null;
|
||||||
|
error: string | null;
|
||||||
|
level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpLogListResult {
|
||||||
|
items: HttpLogEntry[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpLogClearResult {
|
||||||
|
success: boolean;
|
||||||
|
cleared: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Permissions ====================
|
||||||
|
|
||||||
|
export interface PermissionsListResult {
|
||||||
|
permissions: Record<string, string>;
|
||||||
|
role_permissions: Record<string, string[]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import api from './client';
|
||||||
|
import type { MessageResponse, PermissionsListResult, UserInfo } from './types';
|
||||||
|
|
||||||
|
export const userApi = {
|
||||||
|
list: () => api.get<UserInfo[], UserInfo[]>('/users'),
|
||||||
|
create: (data: { username: string; password: string; role: string; remark?: string }) =>
|
||||||
|
api.post<UserInfo, UserInfo>('/users', data),
|
||||||
|
update: (id: number, data: { password?: string; role?: string; is_active?: boolean; remark?: string; custom_permissions?: string[] | null }) =>
|
||||||
|
api.put<UserInfo, UserInfo>(`/users/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/users/${id}`),
|
||||||
|
listPermissions: () => api.get<PermissionsListResult, PermissionsListResult>('/users/permissions/list'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
|
import { Card, Spin, Tag, theme } from 'antd';
|
||||||
|
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
|
import type { RealtimeLog } from '../hooks/useWebSocketLogs';
|
||||||
|
|
||||||
|
interface RealtimeLogPanelProps {
|
||||||
|
logs: RealtimeLog[];
|
||||||
|
connected?: boolean;
|
||||||
|
title?: string;
|
||||||
|
emptyText?: ReactNode;
|
||||||
|
height?: number | string;
|
||||||
|
mode?: 'inline' | 'card';
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultVisible?: boolean;
|
||||||
|
spinWhenEmpty?: boolean;
|
||||||
|
style?: CSSProperties;
|
||||||
|
bodyStyle?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RealtimeLogPanel({
|
||||||
|
logs,
|
||||||
|
connected = false,
|
||||||
|
title = '实时日志',
|
||||||
|
emptyText = '暂无日志',
|
||||||
|
height = '20vh',
|
||||||
|
mode = 'inline',
|
||||||
|
collapsible = false,
|
||||||
|
defaultVisible = true,
|
||||||
|
spinWhenEmpty = false,
|
||||||
|
style,
|
||||||
|
bodyStyle,
|
||||||
|
}: RealtimeLogPanelProps) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const [visible, setVisible] = useState(defaultVisible);
|
||||||
|
const endRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [logs, visible]);
|
||||||
|
|
||||||
|
const logColors: Record<string, string> = {
|
||||||
|
error: token.colorError,
|
||||||
|
success: token.colorSuccess,
|
||||||
|
warning: token.colorWarning,
|
||||||
|
info: token.colorText,
|
||||||
|
};
|
||||||
|
|
||||||
|
const logBody = (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
overflow: 'auto',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
padding: mode === 'card' ? '8px 16px' : 4,
|
||||||
|
backgroundColor: mode === 'card' ? undefined : token.colorBgLayout,
|
||||||
|
borderRadius: mode === 'card' ? undefined : 4,
|
||||||
|
...bodyStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
spinWhenEmpty && connected ? (
|
||||||
|
<Spin spinning size="small" />
|
||||||
|
) : (
|
||||||
|
<span style={{ color: token.colorTextTertiary }}>{emptyText}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
logs.map((log, index) => (
|
||||||
|
<div
|
||||||
|
key={`${index}-${log.message}`}
|
||||||
|
style={{ color: logColors[log.level] || token.colorText, lineHeight: '20px' }}
|
||||||
|
>
|
||||||
|
{log.message}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mode === 'card') {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={title}
|
||||||
|
size="small"
|
||||||
|
style={{ flex: 1, overflow: 'hidden', ...style }}
|
||||||
|
styles={{ body: { height: '100%', overflow: 'hidden', padding: 0 } }}
|
||||||
|
>
|
||||||
|
{logBody}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flexShrink: 0, borderTop: `1px solid ${token.colorBorderSecondary}`, ...style }}>
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', alignItems: 'center', cursor: collapsible ? 'pointer' : 'default', padding: '4px 0', userSelect: 'none' }}
|
||||||
|
onClick={() => collapsible && setVisible((value) => !value)}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 500, fontSize: 13 }}>{title}</span>
|
||||||
|
{collapsible && (
|
||||||
|
visible ? <UpOutlined style={{ marginLeft: 6, fontSize: 10 }} /> : <DownOutlined style={{ marginLeft: 6, fontSize: 10 }} />
|
||||||
|
)}
|
||||||
|
{logs.length > 0 && <span style={{ marginLeft: 8, fontSize: 12, color: token.colorTextTertiary }}>{logs.length} 条</span>}
|
||||||
|
{connected && <Tag color="processing" style={{ marginLeft: 8 }}>连接中</Tag>}
|
||||||
|
</div>
|
||||||
|
{visible && logBody}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { getUser, type AuthUser } from '../store/auth';
|
||||||
|
|
||||||
|
export function usePermissions(userOverride?: AuthUser | null) {
|
||||||
|
const user = userOverride === undefined ? getUser() : userOverride;
|
||||||
|
const permissions = useMemo(() => user?.permissions ?? [], [user]);
|
||||||
|
|
||||||
|
const permissionSet = useMemo(() => new Set(permissions), [permissions]);
|
||||||
|
|
||||||
|
const can = useCallback((permission: string) => permissionSet.has(permission), [permissionSet]);
|
||||||
|
|
||||||
|
const canAny = useCallback(
|
||||||
|
(items: string[]) => items.some((permission) => permissionSet.has(permission)),
|
||||||
|
[permissionSet],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
can,
|
||||||
|
canAny,
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export interface RealtimeLog {
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectOptions {
|
||||||
|
clear?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
onError?: () => void;
|
||||||
|
onResult?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWebSocketUrl(pathOrUrl: string): string {
|
||||||
|
if (pathOrUrl.startsWith('ws://') || pathOrUrl.startsWith('wss://')) {
|
||||||
|
return pathOrUrl;
|
||||||
|
}
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const path = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
|
||||||
|
return `${protocol}://${window.location.host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebSocketLogs() {
|
||||||
|
const [logs, setLogs] = useState<RealtimeLog[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const callbacksRef = useRef<ConnectOptions>({});
|
||||||
|
const suppressCloseRef = useRef(false);
|
||||||
|
|
||||||
|
const clearLogs = useCallback(() => {
|
||||||
|
setLogs([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback((notify = false) => {
|
||||||
|
if (!wsRef.current) {
|
||||||
|
setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suppressCloseRef.current = !notify;
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
setConnected(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connect = useCallback((pathOrUrl: string, options: ConnectOptions = {}) => {
|
||||||
|
close(false);
|
||||||
|
suppressCloseRef.current = false;
|
||||||
|
callbacksRef.current = options;
|
||||||
|
if (options.clear ?? true) {
|
||||||
|
setLogs([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(toWebSocketUrl(pathOrUrl));
|
||||||
|
wsRef.current = ws;
|
||||||
|
setConnected(true);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data) as RealtimeLog;
|
||||||
|
if (msg.level === 'heartbeat') return;
|
||||||
|
if (msg.level === 'result') {
|
||||||
|
callbacksRef.current.onResult?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLogs((prev) => [...prev, msg]);
|
||||||
|
} catch {
|
||||||
|
setLogs((prev) => [...prev, { level: 'info', message: String(event.data) }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
const isCurrent = wsRef.current === ws;
|
||||||
|
const shouldNotify = isCurrent && !suppressCloseRef.current;
|
||||||
|
if (isCurrent) {
|
||||||
|
wsRef.current = null;
|
||||||
|
setConnected(false);
|
||||||
|
suppressCloseRef.current = false;
|
||||||
|
}
|
||||||
|
if (shouldNotify) {
|
||||||
|
callbacksRef.current.onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setConnected(false);
|
||||||
|
callbacksRef.current.onError?.();
|
||||||
|
};
|
||||||
|
}, [close]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
close(false);
|
||||||
|
}, [close]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs,
|
||||||
|
connected,
|
||||||
|
clearLogs,
|
||||||
|
connect,
|
||||||
|
close,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
SunOutlined, MoonOutlined, DesktopOutlined, FileTextOutlined,
|
SunOutlined, MoonOutlined, DesktopOutlined, FileTextOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||||
import { getUser, clearAuth, hasPerm, type AuthUser } from '../store/auth';
|
import { getUser, clearAuth, type AuthUser } from '../store/auth';
|
||||||
import { authApi } from '../api/modules';
|
import { authApi } from '../api/modules';
|
||||||
import { useTheme, type ThemeMode } from '../store/theme';
|
import { useTheme, type ThemeMode } from '../store/theme';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
const { Sider, Content } = Layout;
|
const { Sider, Content } = Layout;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -32,6 +33,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
|||||||
const [user] = useState<AuthUser | null>(getUser());
|
const [user] = useState<AuthUser | null>(getUser());
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const { mode, isDark, setMode } = useTheme();
|
const { mode, isDark, setMode } = useTheme();
|
||||||
|
const { can, canAny } = usePermissions(user);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) navigate('/login');
|
if (!user) navigate('/login');
|
||||||
@@ -45,37 +47,37 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
|||||||
menuItems.push({ key: '/', label: '概览', icon: <DashboardOutlined /> });
|
menuItems.push({ key: '/', label: '概览', icon: <DashboardOutlined /> });
|
||||||
|
|
||||||
// 账号管理
|
// 账号管理
|
||||||
if (hasPerm(user, 'account:view_all') || hasPerm(user, 'account:view_assigned')) {
|
if (canAny(['account:view_all', 'account:view_assigned'])) {
|
||||||
menuItems.push({ key: '/accounts', label: '账号管理', icon: <UserOutlined /> });
|
menuItems.push({ key: '/accounts', label: '账号管理', icon: <UserOutlined /> });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分配管理
|
// 分配管理
|
||||||
if (hasPerm(user, 'account:assign')) {
|
if (can('account:assign')) {
|
||||||
menuItems.push({ key: '/assignments', label: '分配管理', icon: <SwapOutlined /> });
|
menuItems.push({ key: '/assignments', label: '分配管理', icon: <SwapOutlined /> });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录任务
|
// 登录任务
|
||||||
if (hasPerm(user, 'login:batch') || hasPerm(user, 'login:view_all')) {
|
if (canAny(['login:batch', 'login:view_all'])) {
|
||||||
menuItems.push({ key: '/login-tasks', label: '登录任务', icon: <ApiOutlined /> });
|
menuItems.push({ key: '/login-tasks', label: '登录任务', icon: <ApiOutlined /> });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cookie 管理
|
// Cookie 管理
|
||||||
if (hasPerm(user, 'cookie:view')) {
|
if (can('cookie:view')) {
|
||||||
menuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
|
menuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 代理配置
|
// 代理配置
|
||||||
if (hasPerm(user, 'proxy:manage')) {
|
if (can('proxy:manage')) {
|
||||||
menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
|
menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求日志(运营和管理员可见)
|
// 请求日志(运营和管理员可见)
|
||||||
if (hasPerm(user, 'audit:view') || hasPerm(user, 'login:batch')) {
|
if (canAny(['audit:view', 'login:batch'])) {
|
||||||
menuItems.push({ key: '/http-logs', label: '请求日志', icon: <FileTextOutlined /> });
|
menuItems.push({ key: '/http-logs', label: '请求日志', icon: <FileTextOutlined /> });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户管理
|
// 用户管理
|
||||||
if (hasPerm(user, 'user:view')) {
|
if (can('user:view')) {
|
||||||
menuItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> });
|
menuItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +92,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
|||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
await authApi.logout();
|
await authApi.logout();
|
||||||
} catch {}
|
} catch {
|
||||||
|
// 忽略服务端登出失败,仍继续清理本地登录态。
|
||||||
|
}
|
||||||
clearAuth();
|
clearAuth();
|
||||||
onLogout?.();
|
onLogout?.();
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import {
|
|||||||
Table, Button, Modal, Input, Select, message, Popconfirm, Typography, Tag, Space,
|
Table, Button, Modal, Input, Select, message, Popconfirm, Typography, Tag, Space,
|
||||||
Row, Col, Card, Statistic,
|
Row, Col, Card, Statistic,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
import type { TableProps } from 'antd';
|
||||||
import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined } from '@ant-design/icons';
|
import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined } from '@ant-design/icons';
|
||||||
import { accountApi, userApi, type AccountItem, type UserInfo } from '../api/modules';
|
import { accountApi, userApi, type AccountItem, type UserInfo } from '../api/modules';
|
||||||
import { getUser, hasPerm } from '../store/auth';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { getErrorMessage } from '../utils/error';
|
import { getErrorMessage } from '../utils/error';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -27,12 +28,12 @@ export default function AccountsPage() {
|
|||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
const [batchTagInput, setBatchTagInput] = useState('');
|
const [batchTagInput, setBatchTagInput] = useState('');
|
||||||
const [batchTagVisible, setBatchTagVisible] = useState(false);
|
const [batchTagVisible, setBatchTagVisible] = useState(false);
|
||||||
const user = getUser();
|
const { can } = usePermissions();
|
||||||
|
|
||||||
const canViewAll = hasPerm(user, 'account:view_all');
|
const canViewAll = can('account:view_all');
|
||||||
const canImport = hasPerm(user, 'account:import');
|
const canImport = can('account:import');
|
||||||
const canAssign = hasPerm(user, 'account:assign');
|
const canAssign = can('account:assign');
|
||||||
const canDelete = hasPerm(user, 'account:delete');
|
const canDelete = can('account:delete');
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -52,14 +53,18 @@ export default function AccountsPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await userApi.list();
|
const data = await userApi.list();
|
||||||
setUsers(data.filter((u) => u.role === 'support'));
|
setUsers(data.filter((u) => u.role === 'support'));
|
||||||
} catch {}
|
} catch {
|
||||||
|
// 忽略客服列表加载失败,账号列表仍可继续使用。
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadTags = async () => {
|
const loadTags = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await accountApi.listTags();
|
const data = await accountApi.listTags();
|
||||||
setTags(data);
|
setTags(data);
|
||||||
} catch {}
|
} catch {
|
||||||
|
// 忽略标签加载失败,页面会退化为无标签筛选。
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -167,7 +172,7 @@ export default function AccountsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns: TableProps<AccountItem>['columns'] = [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||||
{ title: '用户名', dataIndex: 'username' },
|
{ title: '用户名', dataIndex: 'username' },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function AssignmentsPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await accountApi.assignmentsSummary();
|
const data = await accountApi.assignmentsSummary();
|
||||||
setSupportUsers(data.support_users);
|
setSupportUsers(data.support_users);
|
||||||
return data.support_users as SupportUser[];
|
return data.support_users;
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e));
|
message.error(getErrorMessage(e));
|
||||||
return [];
|
return [];
|
||||||
@@ -54,7 +54,7 @@ export default function AssignmentsPage() {
|
|||||||
const users = await loadSummary();
|
const users = await loadSummary();
|
||||||
if (users.length > 0) {
|
if (users.length > 0) {
|
||||||
const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER);
|
const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER);
|
||||||
const savedUser = savedId ? users.find((u: SupportUserItemItem) => u.id === Number(savedId)) : null;
|
const savedUser = savedId ? users.find((u: SupportUserItem) => u.id === Number(savedId)) : null;
|
||||||
setSelectedUser(savedUser || users[0]);
|
setSelectedUser(savedUser || users[0]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -394,4 +394,4 @@ export default function AssignmentsPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Table, Button, Card, Row, Col, Statistic, message, Tag, Popconfirm, Space, Typography, Input, theme, Dropdown } from 'antd';
|
import { Table, Button, Card, Row, Col, Statistic, message, Tag, Popconfirm, Space, Typography, Input, theme, Dropdown } from 'antd';
|
||||||
import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined } from '@ant-design/icons';
|
import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
import { cookieApi, type CookieItem } from '../api/modules';
|
import { cookieApi, type CookieItem } from '../api/modules';
|
||||||
import { getUser, hasPerm } from '../store/auth';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { formatTime } from '../utils/time';
|
import { formatTime } from '../utils/time';
|
||||||
import { getErrorMessage } from '../utils/error';
|
import { getErrorMessage } from '../utils/error';
|
||||||
|
|
||||||
@@ -14,10 +14,10 @@ export default function CookiePage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const user = getUser();
|
const { can } = usePermissions();
|
||||||
|
|
||||||
const canView = hasPerm(user, 'cookie:view');
|
const canView = can('cookie:view');
|
||||||
const canExport = hasPerm(user, 'cookie:export');
|
const canExport = can('cookie:export');
|
||||||
|
|
||||||
const loadCookies = async () => {
|
const loadCookies = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
ReloadOutlined, DeleteOutlined, SearchOutlined, EyeOutlined,
|
ReloadOutlined, DeleteOutlined, SearchOutlined, EyeOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { logApi, type HttpLogEntry } from '../api/modules';
|
import { logApi, type HttpLogEntry } from '../api/modules';
|
||||||
import { hasPerm, getUser } from '../store/auth';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { getErrorMessage } from '../utils/error';
|
import { getErrorMessage } from '../utils/error';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
@@ -35,7 +35,7 @@ export default function HttpLogsPage() {
|
|||||||
const [level, setLevel] = useState<string | undefined>(undefined);
|
const [level, setLevel] = useState<string | undefined>(undefined);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [detailEntry, setDetailEntry] = useState<HttpLogEntry | null>(null);
|
const [detailEntry, setDetailEntry] = useState<HttpLogEntry | null>(null);
|
||||||
const user = getUser();
|
const { canAny } = usePermissions();
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -78,7 +78,7 @@ export default function HttpLogsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canManage = user && (hasPerm(user, 'audit:view') || hasPerm(user, 'login:batch'));
|
const canManage = canAny(['audit:view', 'login:batch']);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Table, Button, Select, message, Tag, Space, Spin, InputNumber, Tooltip, Popconfirm, theme,
|
Table, Button, Select, message, Tag, Space, InputNumber, Tooltip, Popconfirm, theme,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined, DownOutlined, UpOutlined, DeleteOutlined, SwapOutlined } from '@ant-design/icons';
|
import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined, DeleteOutlined, SwapOutlined } from '@ant-design/icons';
|
||||||
import { accountApi, loginApi, type AccountItem, type LoginTaskItem } from '../api/modules';
|
import { accountApi, loginApi, type AccountItem, type LoginTaskItem } from '../api/modules';
|
||||||
import { getUser, hasPerm } from '../store/auth';
|
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
|
||||||
import { formatTime } from '../utils/time';
|
import { formatTime } from '../utils/time';
|
||||||
import { getErrorMessage } from '../utils/error';
|
import { getErrorMessage } from '../utils/error';
|
||||||
|
|
||||||
@@ -35,20 +37,16 @@ export default function LoginTasksPage() {
|
|||||||
const [tasks, setTasks] = useState<LoginTaskItem[]>([]);
|
const [tasks, setTasks] = useState<LoginTaskItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [batchId, setBatchId] = useState<string | null>(null);
|
const [batchId, setBatchId] = useState<string | null>(null);
|
||||||
const [logs, setLogs] = useState<{ level: string; message: string }[]>([]);
|
|
||||||
const [wsConnected, setWsConnected] = useState(false);
|
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [concurrency, setConcurrency] = useState(3);
|
const [concurrency, setConcurrency] = useState(3);
|
||||||
const [maxProxyRetries, setMaxProxyRetries] = useState(10);
|
const [maxProxyRetries, setMaxProxyRetries] = useState(10);
|
||||||
const [logVisible, setLogVisible] = useState(true);
|
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
|
||||||
const logEndRef = useRef<HTMLDivElement | null>(null);
|
const { can } = usePermissions();
|
||||||
const user = getUser();
|
|
||||||
|
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
const canBatch = hasPerm(user, 'login:batch');
|
const canBatch = can('login:batch');
|
||||||
|
|
||||||
// 从账号中提取所有标签
|
// 从账号中提取所有标签
|
||||||
const allTags = useMemo(() => {
|
const allTags = useMemo(() => {
|
||||||
@@ -113,20 +111,15 @@ export default function LoginTasksPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await loginApi.listTasks(batchId || undefined);
|
const data = await loginApi.listTasks(batchId || undefined);
|
||||||
setTasks(data);
|
setTasks(data);
|
||||||
} catch {}
|
} catch {
|
||||||
|
// 忽略轮询失败,下一次定时刷新会继续尝试。
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([loadAccounts(), loadTasks()]);
|
Promise.all([loadAccounts(), loadTasks()]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 日志自动滚动到底部
|
|
||||||
useEffect(() => {
|
|
||||||
if (logVisible && logEndRef.current) {
|
|
||||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}, [logs, logVisible]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(loadTasks, 3000);
|
const timer = setInterval(loadTasks, 3000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
@@ -139,31 +132,15 @@ export default function LoginTasksPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLogs([]);
|
|
||||||
try {
|
try {
|
||||||
const result = await loginApi.createBatch(accountIds, 5, concurrency, maxProxyRetries);
|
const result = await loginApi.createBatch(accountIds, 5, concurrency, maxProxyRetries);
|
||||||
setBatchId(result.batch_id);
|
setBatchId(result.batch_id);
|
||||||
message.success(`已创建登录任务,共 ${result.count} 个账号`);
|
message.success(`已创建登录任务,共 ${result.count} 个账号`);
|
||||||
|
|
||||||
// 连接 WebSocket
|
connectLogs(`/api/login/ws/login/${result.batch_id}`, {
|
||||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/api/login/ws/login/${result.batch_id}`;
|
onClose: () => setBatchId(null),
|
||||||
const ws = new WebSocket(wsUrl);
|
onResult: () => setBatchId(null),
|
||||||
wsRef.current = ws;
|
});
|
||||||
setWsConnected(true);
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.level === 'heartbeat') return;
|
|
||||||
if (msg.level === 'result') return;
|
|
||||||
setLogs((prev) => [...prev, msg]);
|
|
||||||
};
|
|
||||||
ws.onclose = () => {
|
|
||||||
wsRef.current = null;
|
|
||||||
setWsConnected(false);
|
|
||||||
setBatchId(null);
|
|
||||||
};
|
|
||||||
ws.onerror = () => {
|
|
||||||
setWsConnected(false);
|
|
||||||
};
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e));
|
message.error(getErrorMessage(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -432,50 +409,13 @@ export default function LoginTasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 实时日志 - 底部可折叠 */}
|
{/* 实时日志 - 底部可折叠 */}
|
||||||
<div style={{ flexShrink: 0, borderTop: `1px solid ${token.colorBorderSecondary}`, marginTop: 4 }}>
|
<RealtimeLogPanel
|
||||||
<div
|
logs={logs}
|
||||||
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', padding: '4px 0', userSelect: 'none' }}
|
connected={wsConnected}
|
||||||
onClick={() => setLogVisible((v) => !v)}
|
collapsible
|
||||||
>
|
spinWhenEmpty
|
||||||
<span style={{ fontWeight: 500, fontSize: 13 }}>实时日志</span>
|
style={{ marginTop: 4 }}
|
||||||
{logVisible ? <UpOutlined style={{ marginLeft: 6, fontSize: 10 }} /> : <DownOutlined style={{ marginLeft: 6, fontSize: 10 }} />}
|
/>
|
||||||
{logs.length > 0 && <span style={{ marginLeft: 8, fontSize: 12, color: token.colorTextTertiary }}>{logs.length} 条</span>}
|
|
||||||
{wsConnected && <Tag color="processing" style={{ marginLeft: 8 }}>连接中</Tag>}
|
|
||||||
</div>
|
|
||||||
{logVisible && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '20vh',
|
|
||||||
overflow: 'auto',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 12,
|
|
||||||
padding: 4,
|
|
||||||
backgroundColor: token.colorBgLayout,
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<Spin spinning={wsConnected} size="small" />
|
|
||||||
) : (
|
|
||||||
logs.map((log, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
log.level === 'error' ? token.colorError :
|
|
||||||
log.level === 'success' ? token.colorSuccess :
|
|
||||||
log.level === 'warning' ? token.colorWarning :
|
|
||||||
token.colorText,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{log.message}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<div ref={logEndRef} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Form, Input, Switch, Button, Card, message, Row, Col, theme } from 'antd';
|
import { Form, Input, Switch, Button, Card, message, Row, Col } from 'antd';
|
||||||
import { proxyApi, type ProxyConfig } from '../api/modules';
|
import { proxyApi, type ProxyConfig } from '../api/modules';
|
||||||
|
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
||||||
|
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
|
||||||
import { getErrorMessage } from '../utils/error';
|
import { getErrorMessage } from '../utils/error';
|
||||||
|
|
||||||
const WS_BASE = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}`;
|
|
||||||
|
|
||||||
export default function ProxyPage() {
|
export default function ProxyPage() {
|
||||||
const { token } = theme.useToken();
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testingWl, setTestingWl] = useState(false);
|
const [testingWl, setTestingWl] = useState(false);
|
||||||
const [configLoaded, setConfigLoaded] = useState(false);
|
const [configLoaded, setConfigLoaded] = useState(false);
|
||||||
const [logs, setLogs] = useState<{ level: string; message: string }[]>([]);
|
const { logs, connect: connectLogs, close: closeLogs } = useWebSocketLogs();
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -37,30 +35,21 @@ export default function ProxyPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfig();
|
loadConfig();
|
||||||
return () => {
|
return () => {
|
||||||
wsRef.current?.close();
|
closeLogs();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const appendLog = (level: string, msg: string) => {
|
|
||||||
setLogs((prev) => [...prev, { level, message: msg }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectWs = (testId: string) => {
|
const connectWs = (testId: string) => {
|
||||||
wsRef.current?.close();
|
connectLogs(`/api/proxy/ws/test/${testId}`, {
|
||||||
setLogs([]);
|
onClose: () => {
|
||||||
const ws = new WebSocket(`${WS_BASE}/api/proxy/ws/test/${testId}`);
|
setTesting(false);
|
||||||
wsRef.current = ws;
|
setTestingWl(false);
|
||||||
ws.onmessage = (event) => {
|
},
|
||||||
const msg = JSON.parse(event.data);
|
onResult: () => {
|
||||||
if (msg.level === 'heartbeat') return;
|
setTesting(false);
|
||||||
if (msg.level === 'result') return;
|
setTestingWl(false);
|
||||||
appendLog(msg.level, msg.message);
|
},
|
||||||
};
|
});
|
||||||
ws.onclose = () => {
|
|
||||||
wsRef.current = null;
|
|
||||||
setTesting(false);
|
|
||||||
setTestingWl(false);
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -78,7 +67,6 @@ export default function ProxyPage() {
|
|||||||
|
|
||||||
const handleTestProxy = async () => {
|
const handleTestProxy = async () => {
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
setLogs([]);
|
|
||||||
try {
|
try {
|
||||||
const result = await proxyApi.test();
|
const result = await proxyApi.test();
|
||||||
if (result.test_id) connectWs(result.test_id);
|
if (result.test_id) connectWs(result.test_id);
|
||||||
@@ -90,7 +78,6 @@ export default function ProxyPage() {
|
|||||||
|
|
||||||
const handleTestWhitelist = async () => {
|
const handleTestWhitelist = async () => {
|
||||||
setTestingWl(true);
|
setTestingWl(true);
|
||||||
setLogs([]);
|
|
||||||
try {
|
try {
|
||||||
const result = await proxyApi.testWhitelist();
|
const result = await proxyApi.testWhitelist();
|
||||||
if (result.test_id) connectWs(result.test_id);
|
if (result.test_id) connectWs(result.test_id);
|
||||||
@@ -101,13 +88,6 @@ export default function ProxyPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const logColors: Record<string, string> = {
|
|
||||||
error: token.colorError,
|
|
||||||
success: token.colorSuccess,
|
|
||||||
warning: token.colorWarning,
|
|
||||||
info: token.colorText,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 8 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
||||||
@@ -166,22 +146,15 @@ export default function ProxyPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Card
|
<RealtimeLogPanel
|
||||||
|
mode="card"
|
||||||
|
logs={logs}
|
||||||
title="实时日志"
|
title="实时日志"
|
||||||
size="small"
|
emptyText={'点击"测试代理"或"测试白名单"查看日志'}
|
||||||
style={{ flex: 1, overflow: 'hidden' }}
|
height="100%"
|
||||||
styles={{ body: { height: '100%', overflow: 'auto', fontFamily: 'monospace', fontSize: 12, padding: '8px 16px' } }}
|
bodyStyle={{ minHeight: 160 }}
|
||||||
>
|
style={{ flex: 1 }}
|
||||||
{logs.length === 0 ? (
|
/>
|
||||||
<span style={{ color: token.colorTextTertiary }}>点击"测试代理"或"测试白名单"查看日志</span>
|
|
||||||
) : (
|
|
||||||
logs.map((log, i) => (
|
|
||||||
<div key={i} style={{ color: logColors[log.level] || token.colorText, lineHeight: '20px' }}>
|
|
||||||
{log.message}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||||
import { userApi, type UserInfo } from '../api/modules';
|
import { userApi, type UserInfo } from '../api/modules';
|
||||||
import { getUser, hasPerm } from '../store/auth';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { getErrorMessage } from '../utils/error';
|
import { getErrorMessage } from '../utils/error';
|
||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
@@ -50,8 +50,9 @@ export default function UsersPage() {
|
|||||||
const [selectedPerms, setSelectedPerms] = useState<string[]>([]);
|
const [selectedPerms, setSelectedPerms] = useState<string[]>([]);
|
||||||
const [useCustom, setUseCustom] = useState(false);
|
const [useCustom, setUseCustom] = useState(false);
|
||||||
const [permLoading, setPermLoading] = useState(false);
|
const [permLoading, setPermLoading] = useState(false);
|
||||||
|
const { can } = usePermissions();
|
||||||
|
|
||||||
const canAssignPerm = hasPerm(getUser(), 'user:assign_permissions');
|
const canAssignPerm = can('user:assign_permissions');
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user