mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat: 新增 Anyrouter/Cubence/YesCode 架构及余额监控增强
- 新增三个 provider 架构:Anyrouter、Cubence、YesCode - 前端新增对应的认证配置模板 - 余额监控增强:支持窗口限额显示(进度条+倒计时)、签到状态、错误信息展示 - 架构基类新增 prepare_verify_config 异步预处理方法 - API 返回 ops_architecture_id 字段用于前端展示
This commit is contained in:
@@ -2,12 +2,18 @@
|
||||
Provider 操作模块
|
||||
"""
|
||||
|
||||
from src.services.provider_ops.actions.anyrouter_balance import AnyrouterBalanceAction
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.actions.base import ProviderAction
|
||||
from src.services.provider_ops.actions.checkin import CheckinAction
|
||||
from src.services.provider_ops.actions.cubence_balance import CubenceBalanceAction
|
||||
from src.services.provider_ops.actions.yescode_balance import YesCodeBalanceAction
|
||||
|
||||
__all__ = [
|
||||
"ProviderAction",
|
||||
"BalanceAction",
|
||||
"CheckinAction",
|
||||
"AnyrouterBalanceAction",
|
||||
"CubenceBalanceAction",
|
||||
"YesCodeBalanceAction",
|
||||
]
|
||||
|
||||
107
src/services/provider_ops/actions/anyrouter_balance.py
Normal file
107
src/services/provider_ops/actions/anyrouter_balance.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Anyrouter 余额查询操作(含自动签到)
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus
|
||||
|
||||
|
||||
class AnyrouterBalanceAction(BalanceAction):
|
||||
"""
|
||||
Anyrouter 专用余额查询
|
||||
|
||||
特点:
|
||||
- 查询余额前自动触发签到
|
||||
- 签到结果附加到余额信息的 extra 字段
|
||||
- Cookie 失效时返回友好的错误提示
|
||||
"""
|
||||
|
||||
display_name = "查询余额(含自动签到)"
|
||||
description = "查询账户余额,同时自动签到"
|
||||
|
||||
def _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: Optional[Dict[str, Any]] = None
|
||||
) -> ActionResult:
|
||||
"""处理 HTTP 错误响应(Anyrouter 专用)"""
|
||||
status_code = response.status_code
|
||||
|
||||
# Anyrouter 使用 Cookie 认证,提供更友好的错误提示
|
||||
if status_code == 401:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "Cookie 已失效,请重新配置", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 403:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "Cookie 已失效或无权限", raw_response=raw_data
|
||||
)
|
||||
|
||||
# 其他错误使用基类处理
|
||||
return super()._handle_http_error(response, raw_data)
|
||||
|
||||
async def execute(self, client) -> ActionResult:
|
||||
"""执行余额查询(含自动签到)"""
|
||||
# 先尝试签到
|
||||
checkin_success, checkin_message = await self._auto_checkin(client)
|
||||
|
||||
# 执行余额查询
|
||||
result = await super().execute(client)
|
||||
|
||||
# 将签到结果附加到 extra 字段
|
||||
if result.data and hasattr(result.data, "extra"):
|
||||
if result.data.extra is None:
|
||||
result.data.extra = {}
|
||||
result.data.extra["checkin_success"] = checkin_success
|
||||
result.data.extra["checkin_message"] = checkin_message
|
||||
|
||||
return result
|
||||
|
||||
async def _auto_checkin(self, client) -> Tuple[Optional[bool], str]:
|
||||
"""
|
||||
自动签到
|
||||
|
||||
Returns:
|
||||
(success, message) 元组:
|
||||
- success: True=签到成功, False=签到失败, None=已签到/跳过
|
||||
- message: 签到消息
|
||||
"""
|
||||
checkin_endpoint = self.config.get("checkin_endpoint", "/api/user/sign_in")
|
||||
|
||||
try:
|
||||
response = await client.post(checkin_endpoint)
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
data = response.json()
|
||||
success = data.get("success", False)
|
||||
message = data.get("message", "")
|
||||
|
||||
if success:
|
||||
logger.debug(f"Anyrouter 自动签到成功: {message}")
|
||||
return True, message or "签到成功"
|
||||
else:
|
||||
# 检查是否是"已签到"
|
||||
is_already = (
|
||||
any(ind in message for ind in ["已签到", "已签", "今日已"])
|
||||
or "already" in message.lower()
|
||||
)
|
||||
if is_already:
|
||||
logger.debug(f"Anyrouter 今日已签到: {message}")
|
||||
return None, message or "今日已签到"
|
||||
else:
|
||||
logger.debug(f"Anyrouter 签到失败: {message}")
|
||||
return False, message or "签到失败"
|
||||
except Exception as e:
|
||||
logger.debug(f"Anyrouter 签到响应解析失败: {e}")
|
||||
return False, "响应解析失败"
|
||||
else:
|
||||
logger.debug(f"Anyrouter 签到请求失败: HTTP {response.status_code}")
|
||||
return False, f"HTTP {response.status_code}"
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Anyrouter 自动签到异常: {e}")
|
||||
return False, str(e)
|
||||
95
src/services/provider_ops/actions/cubence_balance.py
Normal file
95
src/services/provider_ops/actions/cubence_balance.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Cubence 余额查询操作
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
|
||||
|
||||
class CubenceBalanceAction(BalanceAction):
|
||||
"""
|
||||
Cubence 专用余额查询
|
||||
|
||||
特点:
|
||||
- 余额单位直接是美元
|
||||
- 支持窗口限额查询(5小时/每周)
|
||||
- Cookie 失效时返回友好的错误提示
|
||||
"""
|
||||
|
||||
display_name = "查询余额(含窗口限额)"
|
||||
description = "查询账户余额和窗口限额信息"
|
||||
|
||||
def _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: Optional[Dict[str, Any]] = None
|
||||
) -> ActionResult:
|
||||
"""处理 HTTP 错误响应(Cubence 专用)"""
|
||||
status_code = response.status_code
|
||||
|
||||
# Cubence 使用 Cookie 认证,提供更友好的错误提示
|
||||
if status_code == 401:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "Cookie 已失效,请重新配置", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 403:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "Cookie 已失效或无权限", raw_response=raw_data
|
||||
)
|
||||
|
||||
# 其他错误使用基类处理
|
||||
return super()._handle_http_error(response, raw_data)
|
||||
|
||||
def _parse_balance(self, data: Any, mapping: Dict[str, str]) -> BalanceInfo:
|
||||
"""解析 Cubence 余额信息(覆盖基类方法)"""
|
||||
# Cubence 响应格式:data.balance 和 data.subscription_limits
|
||||
response_data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
balance_data = response_data.get("balance", {})
|
||||
subscription_limits = response_data.get("subscription_limits", {})
|
||||
|
||||
# 余额信息(单位直接是美元)
|
||||
total_available = balance_data.get("total_balance_dollar")
|
||||
normal_balance = balance_data.get("normal_balance_dollar")
|
||||
subscription_balance = balance_data.get("subscription_balance_dollar")
|
||||
charity_balance = balance_data.get("charity_balance_dollar")
|
||||
|
||||
# 窗口限额信息
|
||||
extra: Dict[str, Any] = {}
|
||||
|
||||
# 5小时窗口限额
|
||||
five_hour = subscription_limits.get("five_hour", {})
|
||||
if five_hour:
|
||||
extra["five_hour_limit"] = {
|
||||
"limit": five_hour.get("limit"),
|
||||
"used": five_hour.get("used"),
|
||||
"remaining": five_hour.get("remaining"),
|
||||
"resets_at": five_hour.get("resets_at"),
|
||||
}
|
||||
|
||||
# 每周窗口限额
|
||||
weekly = subscription_limits.get("weekly", {})
|
||||
if weekly:
|
||||
extra["weekly_limit"] = {
|
||||
"limit": weekly.get("limit"),
|
||||
"used": weekly.get("used"),
|
||||
"remaining": weekly.get("remaining"),
|
||||
"resets_at": weekly.get("resets_at"),
|
||||
}
|
||||
|
||||
# 余额组成
|
||||
if normal_balance is not None:
|
||||
extra["normal_balance"] = normal_balance
|
||||
if subscription_balance is not None:
|
||||
extra["subscription_balance"] = subscription_balance
|
||||
if charity_balance is not None:
|
||||
extra["charity_balance"] = charity_balance
|
||||
|
||||
return BalanceInfo(
|
||||
total_granted=None, # Cubence 不提供总额度
|
||||
total_used=None,
|
||||
total_available=total_available,
|
||||
currency=self.config.get("currency", "USD"),
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
226
src/services/provider_ops/actions/yescode_balance.py
Normal file
226
src/services/provider_ops/actions/yescode_balance.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
YesCode 余额查询操作
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
|
||||
|
||||
async def fetch_yescode_combined_data(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取 YesCode 合并数据(balance + profile)
|
||||
|
||||
使用传入的 client 并发调用两个接口:
|
||||
- /api/v1/user/balance: 准确的 weekly_spent_balance
|
||||
- /api/v1/auth/profile: 用户信息、重置时间
|
||||
|
||||
Args:
|
||||
client: 已配置好认证的 HTTP 客户端
|
||||
base_url: API 基础地址
|
||||
|
||||
Returns:
|
||||
合并后的数据字典
|
||||
"""
|
||||
base_url = base_url.rstrip("/")
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
# 并发调用两个接口
|
||||
balance_task = client.get(f"{base_url}/api/v1/user/balance")
|
||||
profile_task = client.get(f"{base_url}/api/v1/auth/profile")
|
||||
|
||||
balance_resp, profile_resp = await asyncio.gather(
|
||||
balance_task, profile_task, return_exceptions=True
|
||||
)
|
||||
|
||||
# 解析 balance 接口
|
||||
balance_data: Dict[str, Any] = {}
|
||||
if isinstance(balance_resp, httpx.Response) and balance_resp.status_code == 200:
|
||||
try:
|
||||
balance_data = balance_resp.json()
|
||||
result["_balance_data"] = balance_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 解析 profile 接口
|
||||
profile_data: Dict[str, Any] = {}
|
||||
if isinstance(profile_resp, httpx.Response) and profile_resp.status_code == 200:
|
||||
try:
|
||||
profile_data = profile_resp.json()
|
||||
result["_profile_data"] = profile_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 合并数据:balance 接口优先(更准确的余额数据)
|
||||
result["pay_as_you_go_balance"] = balance_data.get(
|
||||
"pay_as_you_go_balance", profile_data.get("pay_as_you_go_balance", 0)
|
||||
)
|
||||
result["subscription_balance"] = balance_data.get(
|
||||
"subscription_balance", profile_data.get("subscription_balance", 0)
|
||||
)
|
||||
result["weekly_limit"] = balance_data.get("weekly_limit") or (
|
||||
profile_data.get("subscription_plan") or {}
|
||||
).get("weekly_limit")
|
||||
result["weekly_spent_balance"] = balance_data.get(
|
||||
"weekly_spent_balance", profile_data.get("current_week_spend", 0)
|
||||
)
|
||||
|
||||
# 用户信息(仅 profile 有)
|
||||
result["username"] = profile_data.get("username")
|
||||
result["email"] = profile_data.get("email")
|
||||
|
||||
# 重置时间(仅 profile 有)
|
||||
result["last_week_reset"] = profile_data.get("last_week_reset")
|
||||
result["last_daily_balance_add"] = profile_data.get("last_daily_balance_add")
|
||||
|
||||
# subscription_plan(仅 profile 有)
|
||||
result["subscription_plan"] = profile_data.get("subscription_plan")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_yescode_balance_extra(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
解析 YesCode 余额额外信息
|
||||
|
||||
Args:
|
||||
data: 合并后的数据(来自 fetch_yescode_combined_data 或单独接口)
|
||||
|
||||
Returns:
|
||||
统一格式的 extra 字典
|
||||
"""
|
||||
extra: Dict[str, Any] = {}
|
||||
|
||||
pay_as_you_go = data.get("pay_as_you_go_balance", 0)
|
||||
subscription = data.get("subscription_balance", 0)
|
||||
|
||||
extra["pay_as_you_go_balance"] = pay_as_you_go
|
||||
|
||||
# 每日额度上限
|
||||
plan = data.get("subscription_plan") or {}
|
||||
daily_balance = plan.get("daily_balance", subscription)
|
||||
|
||||
# 周限额
|
||||
weekly_limit = data.get("weekly_limit") or plan.get("weekly_limit")
|
||||
weekly_spent = data.get("weekly_spent_balance", 0)
|
||||
|
||||
# 映射为统一字段
|
||||
extra["daily_limit"] = daily_balance
|
||||
if weekly_limit is not None:
|
||||
extra["weekly_limit"] = weekly_limit
|
||||
extra["weekly_spent"] = weekly_spent
|
||||
|
||||
# 计算重置时间
|
||||
last_week_reset = data.get("last_week_reset")
|
||||
if last_week_reset:
|
||||
try:
|
||||
if isinstance(last_week_reset, str):
|
||||
reset_dt = datetime.fromisoformat(last_week_reset.replace("Z", "+00:00"))
|
||||
next_reset = reset_dt + timedelta(days=7)
|
||||
extra["weekly_resets_at"] = int(next_reset.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last_daily_add = data.get("last_daily_balance_add")
|
||||
if last_daily_add:
|
||||
try:
|
||||
if isinstance(last_daily_add, str):
|
||||
add_dt = datetime.fromisoformat(last_daily_add.replace("Z", "+00:00"))
|
||||
next_daily = add_dt + timedelta(days=1)
|
||||
extra["daily_resets_at"] = int(next_daily.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 计算实际可用余额
|
||||
if weekly_limit is not None:
|
||||
weekly_remaining = max(0, weekly_limit - weekly_spent)
|
||||
subscription_available = min(subscription, weekly_remaining)
|
||||
extra["daily_spent"] = daily_balance - min(daily_balance, subscription_available)
|
||||
else:
|
||||
subscription_available = subscription
|
||||
extra["daily_spent"] = max(0, daily_balance - subscription)
|
||||
|
||||
extra["_subscription_available"] = subscription_available
|
||||
extra["_total_available"] = pay_as_you_go + subscription_available
|
||||
|
||||
return extra
|
||||
|
||||
|
||||
class YesCodeBalanceAction(BalanceAction):
|
||||
"""
|
||||
YesCode 专用余额查询
|
||||
|
||||
特点:
|
||||
- 余额单位直接是美元
|
||||
- 支持每周限额查询
|
||||
- 同时调用 balance 和 profile 接口获取完整数据
|
||||
- Cookie 失效时返回友好的错误提示
|
||||
"""
|
||||
|
||||
display_name = "查询余额(含每周限额)"
|
||||
description = "查询账户余额和每周限额信息"
|
||||
|
||||
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""执行余额查询(复用 client 调用两个接口获取完整数据)"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
base_url = str(client.base_url).rstrip("/")
|
||||
|
||||
try:
|
||||
# 复用传入的 client 获取合并数据
|
||||
combined_data = await fetch_yescode_combined_data(client, base_url)
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 检查是否至少有一个接口成功
|
||||
if "_balance_data" not in combined_data and "_profile_data" not in combined_data:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED,
|
||||
"Cookie 已失效,请重新配置",
|
||||
)
|
||||
|
||||
# 使用公共函数解析余额
|
||||
extra = parse_yescode_balance_extra(combined_data)
|
||||
|
||||
total_available = extra.pop("_total_available", 0)
|
||||
extra.pop("_subscription_available", None)
|
||||
|
||||
balance = BalanceInfo(
|
||||
total_granted=None,
|
||||
total_used=None,
|
||||
total_available=total_available,
|
||||
currency=self.config.get("currency", "USD"),
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
|
||||
return self._make_success_result(
|
||||
data=balance,
|
||||
response_time_ms=response_time_ms,
|
||||
raw_response=combined_data,
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR,
|
||||
"请求超时",
|
||||
retry_after_seconds=30,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR,
|
||||
f"网络错误: {str(e)}",
|
||||
retry_after_seconds=30,
|
||||
)
|
||||
except Exception as e:
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
f"未知错误: {str(e)}",
|
||||
)
|
||||
@@ -7,15 +7,21 @@ from src.services.provider_ops.architectures.base import (
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.architectures.anyrouter import AnyrouterArchitecture
|
||||
from src.services.provider_ops.architectures.cubence import CubenceArchitecture
|
||||
from src.services.provider_ops.architectures.generic_api import GenericApiArchitecture
|
||||
from src.services.provider_ops.architectures.new_api import NewApiArchitecture
|
||||
from src.services.provider_ops.architectures.one_api import OneApiArchitecture
|
||||
from src.services.provider_ops.architectures.yescode import YesCodeArchitecture
|
||||
|
||||
__all__ = [
|
||||
"ProviderArchitecture",
|
||||
"ProviderConnector",
|
||||
"VerifyResult",
|
||||
"AnyrouterArchitecture",
|
||||
"CubenceArchitecture",
|
||||
"GenericApiArchitecture",
|
||||
"NewApiArchitecture",
|
||||
"OneApiArchitecture",
|
||||
"YesCodeArchitecture",
|
||||
]
|
||||
|
||||
444
src/services/provider_ops/architectures/anyrouter.py
Normal file
444
src/services/provider_ops/architectures/anyrouter.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
Anyrouter 架构
|
||||
|
||||
针对 Anyrouter 中转站的预设配置,自动处理 acw_sc__v2 反爬 Cookie。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions import AnyrouterBalanceAction, ProviderAction
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
|
||||
# acw_sc__v2 算法常量
|
||||
_XOR_KEY = "3000176000856006061501533003690027800375"
|
||||
_UNSBOX_TABLE = [
|
||||
0xF,
|
||||
0x23,
|
||||
0x1D,
|
||||
0x18,
|
||||
0x21,
|
||||
0x10,
|
||||
0x1,
|
||||
0x26,
|
||||
0xA,
|
||||
0x9,
|
||||
0x13,
|
||||
0x1F,
|
||||
0x28,
|
||||
0x1B,
|
||||
0x16,
|
||||
0x17,
|
||||
0x19,
|
||||
0xD,
|
||||
0x6,
|
||||
0xB,
|
||||
0x27,
|
||||
0x12,
|
||||
0x14,
|
||||
0x8,
|
||||
0xE,
|
||||
0x15,
|
||||
0x20,
|
||||
0x1A,
|
||||
0x2,
|
||||
0x1E,
|
||||
0x7,
|
||||
0x4,
|
||||
0x11,
|
||||
0x5,
|
||||
0x3,
|
||||
0x1C,
|
||||
0x22,
|
||||
0x25,
|
||||
0xC,
|
||||
0x24,
|
||||
]
|
||||
|
||||
|
||||
def _compute_acw_sc_v2(arg1: str) -> str:
|
||||
"""
|
||||
计算 acw_sc__v2 Cookie 值
|
||||
|
||||
Args:
|
||||
arg1: 从 HTML 中提取的 40 位十六进制字符串
|
||||
|
||||
Returns:
|
||||
计算后的 Cookie 值
|
||||
"""
|
||||
# Step 1: unsbox - 根据置换表重排字符
|
||||
unsboxed = "".join(arg1[i - 1] for i in _UNSBOX_TABLE)
|
||||
|
||||
# Step 2: hexXor - 与密钥逐字节异或
|
||||
result = ""
|
||||
for i in range(0, 40, 2):
|
||||
a = int(unsboxed[i : i + 2], 16)
|
||||
b = int(_XOR_KEY[i : i + 2], 16)
|
||||
xored = format(a ^ b, "02x")
|
||||
result += xored
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _extract_session_from_cookie(cookie_string: str) -> str:
|
||||
"""
|
||||
从完整的 Cookie 字符串中提取 session 值
|
||||
|
||||
支持两种输入格式:
|
||||
1. 完整 Cookie: "session=xxx; acw_tc=xxx; ..."
|
||||
2. 仅 session 值: "MTc2ODc4..."
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串或 session 值
|
||||
|
||||
Returns:
|
||||
session cookie 的值
|
||||
"""
|
||||
# 如果包含 "session=",说明是完整 Cookie 字符串
|
||||
if "session=" in cookie_string:
|
||||
# 解析 Cookie 字符串
|
||||
for part in cookie_string.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("session="):
|
||||
return part[8:] # 去掉 "session=" 前缀
|
||||
# 否则认为直接是 session 值
|
||||
return cookie_string.strip()
|
||||
|
||||
|
||||
def _parse_session_user_id(cookie_input: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
从 session cookie 中解析用户 ID 和用户名
|
||||
|
||||
Anyrouter 的 session cookie 结构:
|
||||
base64(timestamp|gob_base64|signature)
|
||||
|
||||
gob 数据中包含:
|
||||
- id: 内部数字 ID
|
||||
- username: 用户名 (如 linuxdo_129083)
|
||||
- role, status, group 等
|
||||
|
||||
Args:
|
||||
cookie_input: Cookie 字符串或 session 值
|
||||
|
||||
Returns:
|
||||
(user_id, username) 元组,解析失败则返回 (None, None)
|
||||
"""
|
||||
try:
|
||||
# 先提取 session 值
|
||||
session_cookie = _extract_session_from_cookie(cookie_input)
|
||||
# 1. URL-safe base64 解码外层
|
||||
padding = 4 - len(session_cookie) % 4
|
||||
if padding != 4:
|
||||
session_cookie += "=" * padding
|
||||
|
||||
decoded = base64.urlsafe_b64decode(session_cookie)
|
||||
text = decoded.decode("utf-8", errors="replace")
|
||||
|
||||
# 2. 分割: timestamp|gob_base64|signature
|
||||
parts = text.split("|")
|
||||
if len(parts) < 2:
|
||||
return None, None
|
||||
|
||||
# 3. 解码 gob 数据 (第二层 base64)
|
||||
gob_b64 = parts[1]
|
||||
padding2 = 4 - len(gob_b64) % 4
|
||||
if padding2 != 4:
|
||||
gob_b64 += "=" * padding2
|
||||
|
||||
gob_data = base64.urlsafe_b64decode(gob_b64)
|
||||
|
||||
# 4. 从 gob 数据中提取用户名
|
||||
gob_text = gob_data.decode("utf-8", errors="ignore")
|
||||
|
||||
# 查找 linuxdo_xxx 模式 (LinuxDo OAuth)
|
||||
linuxdo_match = re.search(r"linuxdo_(\d+)", gob_text)
|
||||
if linuxdo_match:
|
||||
user_id = linuxdo_match.group(1)
|
||||
username = linuxdo_match.group(0)
|
||||
return user_id, username
|
||||
|
||||
# 查找其他 OAuth 格式 (github_xxx, google_xxx 等)
|
||||
oauth_match = re.search(r"(github|google|discord|twitter)_(\d+)", gob_text, re.IGNORECASE)
|
||||
if oauth_match:
|
||||
user_id = oauth_match.group(2)
|
||||
username = oauth_match.group(0)
|
||||
return user_id, username
|
||||
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.debug(f"解析 Anyrouter session cookie 失败: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
async def _get_acw_cookie(base_url: str, timeout: float = 10) -> Optional[str]:
|
||||
"""
|
||||
获取 acw_sc__v2 Cookie
|
||||
|
||||
首先请求目标 URL,如果返回包含 arg1 的反爬页面,则计算 Cookie 值。
|
||||
|
||||
Args:
|
||||
base_url: 目标站点 URL
|
||||
timeout: 请求超时时间
|
||||
|
||||
Returns:
|
||||
Cookie 字符串 (acw_sc__v2=xxx),如果不需要或获取失败则返回 None
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(
|
||||
base_url,
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# 尝试从响应中提取 arg1
|
||||
match = re.search(r"var\s+arg1\s*=\s*'([0-9a-fA-F]{40})'", resp.text)
|
||||
if not match:
|
||||
# 没有反爬页面,不需要 Cookie
|
||||
return None
|
||||
|
||||
cookie_value = _compute_acw_sc_v2(match.group(1))
|
||||
return f"acw_sc__v2={cookie_value}"
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"获取 acw_sc__v2 Cookie 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class AnyrouterConnector(ProviderConnector):
|
||||
"""
|
||||
Anyrouter 专用连接器
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(session)
|
||||
- 自动补充 acw_sc__v2 反爬 Cookie
|
||||
- 自动解析 user_id 用于 New-Api-User header
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.COOKIE
|
||||
display_name = "Anyrouter Cookie"
|
||||
|
||||
def __init__(self, base_url: str, config: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(base_url, config)
|
||||
self._session_cookie: Optional[str] = None
|
||||
self._acw_cookie: Optional[str] = None
|
||||
self._user_id: Optional[str] = None
|
||||
|
||||
async def connect(self, credentials: Dict[str, Any]) -> bool:
|
||||
"""建立连接"""
|
||||
session_cookie = credentials.get("session_cookie")
|
||||
if not session_cookie:
|
||||
self._set_error("Session Cookie 不能为空")
|
||||
return False
|
||||
|
||||
# 提取纯 session 值(支持完整 Cookie 字符串或仅 session 值)
|
||||
self._session_cookie = _extract_session_from_cookie(session_cookie)
|
||||
|
||||
# 解析 user_id
|
||||
self._user_id, _ = _parse_session_user_id(session_cookie)
|
||||
|
||||
# 尝试获取反爬 Cookie
|
||||
self._acw_cookie = await _get_acw_cookie(self.base_url)
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接"""
|
||||
self._session_cookie = None
|
||||
self._acw_cookie = None
|
||||
self._user_id = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查是否已认证"""
|
||||
return self._session_cookie is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""为请求应用认证信息"""
|
||||
cookies = []
|
||||
|
||||
# 添加反爬 Cookie
|
||||
if self._acw_cookie:
|
||||
cookies.append(self._acw_cookie)
|
||||
|
||||
# 添加 session Cookie
|
||||
if self._session_cookie:
|
||||
cookies.append(f"session={self._session_cookie}")
|
||||
|
||||
if cookies:
|
||||
request.headers["Cookie"] = "; ".join(cookies)
|
||||
|
||||
# 添加 New-Api-User header
|
||||
if self._user_id:
|
||||
request.headers["New-Api-User"] = self._user_id
|
||||
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> Dict[str, Any]:
|
||||
"""获取凭据配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_cookie": {
|
||||
"type": "string",
|
||||
"title": "Session Cookie",
|
||||
"description": "从浏览器复制的 session Cookie 值",
|
||||
},
|
||||
},
|
||||
"required": ["session_cookie"],
|
||||
}
|
||||
|
||||
|
||||
class AnyrouterArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
Anyrouter 架构预设
|
||||
|
||||
针对 Anyrouter 中转站优化的预设配置。
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(session)
|
||||
- 自动处理 acw_sc__v2 反爬 Cookie
|
||||
- 验证端点: /api/user/self
|
||||
- quota 单位是 1/500000 美元
|
||||
"""
|
||||
|
||||
architecture_id = "anyrouter"
|
||||
display_name = "Anyrouter"
|
||||
description = "Anyrouter 中转站预设配置,使用 Cookie 认证"
|
||||
|
||||
supported_connectors: List[Type[ProviderConnector]] = [
|
||||
AnyrouterConnector,
|
||||
]
|
||||
|
||||
supported_actions: List[Type[ProviderAction]] = [
|
||||
AnyrouterBalanceAction,
|
||||
]
|
||||
|
||||
default_action_configs: Dict[ProviderActionType, Dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/user/self",
|
||||
"method": "GET",
|
||||
"quota_divisor": 500000, # 与 New API 相同
|
||||
"checkin_endpoint": "/api/user/sign_in", # 自动签到端点
|
||||
"response_mapping": {
|
||||
"total_granted": "data.quota",
|
||||
"total_used": "data.used_quota",
|
||||
"total_available": "data.quota",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> Dict[str, Any]:
|
||||
"""Anyrouter 使用 session_cookie 认证"""
|
||||
return AnyrouterConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""验证端点"""
|
||||
return "/api/user/self"
|
||||
|
||||
async def prepare_verify_config(
|
||||
self,
|
||||
base_url: str,
|
||||
config: Dict[str, Any],
|
||||
credentials: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
验证前获取 acw_sc__v2 Cookie
|
||||
|
||||
Args:
|
||||
base_url: API 基础地址
|
||||
config: 连接器配置
|
||||
credentials: 凭据信息
|
||||
|
||||
Returns:
|
||||
包含 acw_cookie 的配置
|
||||
"""
|
||||
acw_cookie = await _get_acw_cookie(base_url)
|
||||
if acw_cookie:
|
||||
return {"acw_cookie": acw_cookie}
|
||||
return {}
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
credentials: Dict[str, Any],
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
构建 Anyrouter 的验证请求 Headers
|
||||
|
||||
使用 Cookie 认证,不使用 Authorization。
|
||||
同时添加 New-Api-User header。
|
||||
"""
|
||||
headers: Dict[str, str] = {}
|
||||
|
||||
cookies = []
|
||||
|
||||
# 添加反爬 Cookie
|
||||
acw_cookie = config.get("acw_cookie")
|
||||
if acw_cookie:
|
||||
cookies.append(acw_cookie)
|
||||
|
||||
# 添加 session Cookie
|
||||
cookie_input = credentials.get("session_cookie")
|
||||
if cookie_input:
|
||||
# 提取 session 值(支持完整 Cookie 字符串或仅 session 值)
|
||||
session_value = _extract_session_from_cookie(cookie_input)
|
||||
cookies.append(f"session={session_value}")
|
||||
|
||||
# 从 session 解析 user_id 并添加 New-Api-User header
|
||||
user_id, _ = _parse_session_user_id(cookie_input)
|
||||
if user_id:
|
||||
headers["New-Api-User"] = user_id
|
||||
|
||||
if cookies:
|
||||
headers["Cookie"] = "; ".join(cookies)
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: Dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 Anyrouter 验证响应"""
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message="Cookie 已失效或无权限")
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
# Anyrouter 响应格式: {"success": true, "data": {...}}
|
||||
if not data.get("success"):
|
||||
message = data.get("message", "验证失败")
|
||||
return VerifyResult(success=False, message=message)
|
||||
|
||||
user_data = data.get("data", {})
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=user_data.get("username"),
|
||||
display_name=user_data.get("display_name") or user_data.get("username"),
|
||||
email=user_data.get("email"),
|
||||
quota=user_data.get("quota"),
|
||||
used_quota=user_data.get("used_quota"),
|
||||
request_count=user_data.get("request_count"),
|
||||
extra=None,
|
||||
)
|
||||
@@ -310,6 +310,28 @@ class ProviderArchitecture(ABC):
|
||||
"""
|
||||
return "/api/user/self"
|
||||
|
||||
async def prepare_verify_config(
|
||||
self,
|
||||
base_url: str,
|
||||
config: Dict[str, Any],
|
||||
credentials: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
验证前的异步预处理
|
||||
|
||||
子类可重写以执行异步操作(如获取动态 Cookie)。
|
||||
返回的配置会传递给 build_verify_headers。
|
||||
|
||||
Args:
|
||||
base_url: API 基础地址
|
||||
config: 连接器配置
|
||||
credentials: 凭据信息
|
||||
|
||||
Returns:
|
||||
处理后的配置(会与原 config 合并)
|
||||
"""
|
||||
return {}
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
|
||||
224
src/services/provider_ops/architectures/cubence.py
Normal file
224
src/services/provider_ops/architectures/cubence.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Cubence 架构
|
||||
|
||||
针对 Cubence 中转站的预设配置。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions import ProviderAction
|
||||
from src.services.provider_ops.actions.cubence_balance import CubenceBalanceAction
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
|
||||
|
||||
def _extract_token_from_cookie(cookie_string: str) -> str:
|
||||
"""
|
||||
从完整的 Cookie 字符串中提取 token 值
|
||||
|
||||
支持两种输入格式:
|
||||
1. 完整 Cookie: "token=xxx; other=yyy; ..."
|
||||
2. 仅 token 值: "eyJhbGciOiJI..."
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串或 token 值
|
||||
|
||||
Returns:
|
||||
token cookie 的值
|
||||
"""
|
||||
# 如果包含 "token=",说明是完整 Cookie 字符串
|
||||
if "token=" in cookie_string:
|
||||
# 解析 Cookie 字符串
|
||||
for part in cookie_string.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("token="):
|
||||
return part[6:] # 去掉 "token=" 前缀
|
||||
# 否则认为直接是 token 值
|
||||
return cookie_string.strip()
|
||||
|
||||
|
||||
class CubenceConnector(ProviderConnector):
|
||||
"""
|
||||
Cubence 专用连接器
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(token JWT)
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.COOKIE
|
||||
display_name = "Cubence Cookie"
|
||||
|
||||
def __init__(self, base_url: str, config: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(base_url, config)
|
||||
self._token_cookie: Optional[str] = None
|
||||
|
||||
async def connect(self, credentials: Dict[str, Any]) -> bool:
|
||||
"""建立连接"""
|
||||
token_cookie = credentials.get("token_cookie")
|
||||
if not token_cookie:
|
||||
self._set_error("Token Cookie 不能为空")
|
||||
return False
|
||||
|
||||
# 提取纯 token 值
|
||||
self._token_cookie = _extract_token_from_cookie(token_cookie)
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接"""
|
||||
self._token_cookie = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查是否已认证"""
|
||||
return self._token_cookie is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""为请求应用认证信息"""
|
||||
if self._token_cookie:
|
||||
request.headers["Cookie"] = f"token={self._token_cookie}"
|
||||
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> Dict[str, Any]:
|
||||
"""获取凭据配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token_cookie": {
|
||||
"type": "string",
|
||||
"title": "Token Cookie",
|
||||
"description": "从浏览器复制的 token Cookie 值(JWT 格式)",
|
||||
},
|
||||
},
|
||||
"required": ["token_cookie"],
|
||||
}
|
||||
|
||||
|
||||
class CubenceArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
Cubence 架构预设
|
||||
|
||||
针对 Cubence 中转站优化的预设配置。
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(token JWT)
|
||||
- 验证端点: /api/v1/dashboard/overview
|
||||
- 余额单位直接是美元
|
||||
- 支持窗口限额(5小时/每周)
|
||||
"""
|
||||
|
||||
architecture_id = "cubence"
|
||||
display_name = "Cubence"
|
||||
description = "Cubence 中转站预设配置,使用 Cookie 认证"
|
||||
|
||||
supported_connectors: List[Type[ProviderConnector]] = [
|
||||
CubenceConnector,
|
||||
]
|
||||
|
||||
supported_actions: List[Type[ProviderAction]] = [
|
||||
CubenceBalanceAction,
|
||||
]
|
||||
|
||||
default_action_configs: Dict[ProviderActionType, Dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/v1/dashboard/overview",
|
||||
"method": "GET",
|
||||
"response_mapping": {
|
||||
"total_available": "data.balance.total_balance_dollar",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> Dict[str, Any]:
|
||||
"""Cubence 使用 token_cookie 认证"""
|
||||
return CubenceConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""验证端点"""
|
||||
return "/api/v1/dashboard/overview"
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
credentials: Dict[str, Any],
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
构建 Cubence 的验证请求 Headers
|
||||
|
||||
使用 Cookie 认证,不使用 Authorization。
|
||||
"""
|
||||
headers: Dict[str, str] = {}
|
||||
|
||||
# 添加 token Cookie
|
||||
cookie_input = credentials.get("token_cookie")
|
||||
if cookie_input:
|
||||
token_value = _extract_token_from_cookie(cookie_input)
|
||||
headers["Cookie"] = f"token={token_value}"
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: Dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 Cubence 验证响应"""
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message="Cookie 已失效或无权限")
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
# Cubence 响应格式: {"success": true, "data": {...}}
|
||||
if not data.get("success"):
|
||||
message = data.get("message", "验证失败")
|
||||
return VerifyResult(success=False, message=message)
|
||||
|
||||
user_data = data.get("data", {})
|
||||
user_info = user_data.get("user", {})
|
||||
balance_info = user_data.get("balance", {})
|
||||
subscription_limits = user_data.get("subscription_limits", {})
|
||||
|
||||
# 构建 extra 信息,包含窗口限额
|
||||
extra: Dict[str, Any] = {
|
||||
"role": user_info.get("role"),
|
||||
"invite_code": user_info.get("invite_code"),
|
||||
}
|
||||
|
||||
# 5小时窗口限额
|
||||
five_hour = subscription_limits.get("five_hour", {})
|
||||
if five_hour:
|
||||
extra["five_hour_limit"] = {
|
||||
"limit": five_hour.get("limit"),
|
||||
"used": five_hour.get("used"),
|
||||
"remaining": five_hour.get("remaining"),
|
||||
"resets_at": five_hour.get("resets_at"),
|
||||
}
|
||||
|
||||
# 每周窗口限额
|
||||
weekly = subscription_limits.get("weekly", {})
|
||||
if weekly:
|
||||
extra["weekly_limit"] = {
|
||||
"limit": weekly.get("limit"),
|
||||
"used": weekly.get("used"),
|
||||
"remaining": weekly.get("remaining"),
|
||||
"resets_at": weekly.get("resets_at"),
|
||||
}
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=user_info.get("username"),
|
||||
display_name=user_info.get("username"),
|
||||
quota=balance_info.get("total_balance_dollar"),
|
||||
extra=extra,
|
||||
)
|
||||
289
src/services/provider_ops/architectures/yescode.py
Normal file
289
src/services/provider_ops/architectures/yescode.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
YesCode 架构
|
||||
|
||||
针对 YesCode 中转站的预设配置。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions import ProviderAction
|
||||
from src.services.provider_ops.actions.yescode_balance import (
|
||||
YesCodeBalanceAction,
|
||||
fetch_yescode_combined_data,
|
||||
parse_yescode_balance_extra,
|
||||
)
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
|
||||
|
||||
def _extract_cookies(cookie_string: str) -> Dict[str, str]:
|
||||
"""
|
||||
从完整的 Cookie 字符串中提取 yescode_auth 和 yescode_csrf
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串
|
||||
|
||||
Returns:
|
||||
包含 yescode_auth 和 yescode_csrf 的字典
|
||||
"""
|
||||
result: Dict[str, str] = {}
|
||||
for part in cookie_string.split(";"):
|
||||
part = part.strip()
|
||||
if "=" in part:
|
||||
key, value = part.split("=", 1)
|
||||
key = key.strip()
|
||||
if key in ("yescode_auth", "yescode_csrf"):
|
||||
result[key] = value.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _build_cookie_header(cookie_string: str) -> str:
|
||||
"""
|
||||
从输入的 Cookie 字符串构建请求用的 Cookie header
|
||||
|
||||
支持两种输入格式:
|
||||
1. 完整 Cookie: "yescode_auth=xxx; yescode_csrf=yyy"
|
||||
2. 仅 auth 值: "eyJhbGciOiJI..."
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串或 auth 值
|
||||
|
||||
Returns:
|
||||
Cookie header 值
|
||||
"""
|
||||
# 如果包含 "yescode_auth=",说明是完整 Cookie 字符串
|
||||
if "yescode_auth=" in cookie_string:
|
||||
cookies = _extract_cookies(cookie_string)
|
||||
parts = []
|
||||
if "yescode_auth" in cookies:
|
||||
parts.append(f"yescode_auth={cookies['yescode_auth']}")
|
||||
if "yescode_csrf" in cookies:
|
||||
parts.append(f"yescode_csrf={cookies['yescode_csrf']}")
|
||||
return "; ".join(parts)
|
||||
# 否则认为直接是 auth 值
|
||||
return f"yescode_auth={cookie_string.strip()}"
|
||||
|
||||
|
||||
class YesCodeConnector(ProviderConnector):
|
||||
"""
|
||||
YesCode 专用连接器
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(yescode_auth JWT + yescode_csrf)
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.COOKIE
|
||||
display_name = "YesCode Cookie"
|
||||
|
||||
def __init__(self, base_url: str, config: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(base_url, config)
|
||||
self._auth_cookie: Optional[str] = None
|
||||
|
||||
async def connect(self, credentials: Dict[str, Any]) -> bool:
|
||||
"""建立连接"""
|
||||
auth_cookie = credentials.get("auth_cookie")
|
||||
if not auth_cookie:
|
||||
self._set_error("Auth Cookie 不能为空")
|
||||
return False
|
||||
|
||||
# 构建 Cookie header
|
||||
self._auth_cookie = _build_cookie_header(auth_cookie)
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接"""
|
||||
self._auth_cookie = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查是否已认证"""
|
||||
return self._auth_cookie is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""为请求应用认证信息"""
|
||||
if self._auth_cookie:
|
||||
request.headers["Cookie"] = self._auth_cookie
|
||||
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> Dict[str, Any]:
|
||||
"""获取凭据配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth_cookie": {
|
||||
"type": "string",
|
||||
"title": "Auth Cookie",
|
||||
"description": "从浏览器复制的 Cookie(包含 yescode_auth 和 yescode_csrf)",
|
||||
},
|
||||
},
|
||||
"required": ["auth_cookie"],
|
||||
}
|
||||
|
||||
|
||||
class YesCodeArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
YesCode 架构预设
|
||||
|
||||
针对 YesCode 中转站优化的预设配置。
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(yescode_auth JWT + yescode_csrf)
|
||||
- 验证端点: /api/v1/user/balance
|
||||
- 余额单位直接是美元
|
||||
- 支持每周限额查询
|
||||
"""
|
||||
|
||||
architecture_id = "yescode"
|
||||
display_name = "YesCode"
|
||||
description = "YesCode 中转站预设配置,使用 Cookie 认证"
|
||||
|
||||
supported_connectors: List[Type[ProviderConnector]] = [
|
||||
YesCodeConnector,
|
||||
]
|
||||
|
||||
supported_actions: List[Type[ProviderAction]] = [
|
||||
YesCodeBalanceAction,
|
||||
]
|
||||
|
||||
default_action_configs: Dict[ProviderActionType, Dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/v1/user/balance",
|
||||
"method": "GET",
|
||||
"response_mapping": {
|
||||
"total_available": "total_balance",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> Dict[str, Any]:
|
||||
"""YesCode 使用 auth_cookie 认证"""
|
||||
return YesCodeConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""验证端点 - 使用 profile 接口获取完整信息"""
|
||||
return "/api/v1/auth/profile"
|
||||
|
||||
async def prepare_verify_config(
|
||||
self,
|
||||
base_url: str,
|
||||
config: Dict[str, Any],
|
||||
credentials: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
预获取合并数据(balance + profile)
|
||||
|
||||
验证时并发调用两个接口获取完整数据。
|
||||
"""
|
||||
extra_config: Dict[str, Any] = {}
|
||||
|
||||
cookie_input = credentials.get("auth_cookie")
|
||||
if not cookie_input:
|
||||
return extra_config
|
||||
|
||||
cookie_header = _build_cookie_header(cookie_input)
|
||||
|
||||
try:
|
||||
# 创建临时 client 获取合并数据
|
||||
async with httpx.AsyncClient(
|
||||
headers={"Cookie": cookie_header},
|
||||
timeout=10.0,
|
||||
) as client:
|
||||
combined_data = await fetch_yescode_combined_data(client, base_url)
|
||||
extra_config["_combined_data"] = combined_data
|
||||
except Exception:
|
||||
# 如果调用失败,不影响验证流程(会回退到单独调用 profile)
|
||||
pass
|
||||
|
||||
return extra_config
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
credentials: Dict[str, Any],
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
构建 YesCode 的验证请求 Headers
|
||||
|
||||
使用 Cookie 认证,不使用 Authorization。
|
||||
"""
|
||||
headers: Dict[str, str] = {}
|
||||
|
||||
# 添加 Cookie
|
||||
cookie_input = credentials.get("auth_cookie")
|
||||
if cookie_input:
|
||||
headers["Cookie"] = _build_cookie_header(cookie_input)
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: Dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 YesCode 验证响应(使用预获取的合并数据)"""
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message="Cookie 已失效或无权限")
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
# 优先使用预获取的合并数据(包含 balance + profile)
|
||||
combined_data = data.get("_combined_data")
|
||||
if combined_data:
|
||||
# 检查是否有有效数据
|
||||
if "_profile_data" not in combined_data and "_balance_data" not in combined_data:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
|
||||
# 使用公共函数解析余额
|
||||
extra = parse_yescode_balance_extra(combined_data)
|
||||
|
||||
total_available = extra.pop("_total_available", 0)
|
||||
extra.pop("_subscription_available", None)
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=combined_data.get("username"),
|
||||
display_name=combined_data.get("username"),
|
||||
email=combined_data.get("email"),
|
||||
quota=total_available,
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
|
||||
# 回退:仅使用 profile 数据(旧逻辑,当 prepare_verify_config 失败时)
|
||||
if "username" not in data:
|
||||
return VerifyResult(success=False, message="响应格式无效")
|
||||
|
||||
# 构造兼容格式供公共函数使用
|
||||
compat_data = {
|
||||
"pay_as_you_go_balance": data.get("pay_as_you_go_balance", 0),
|
||||
"subscription_balance": data.get("subscription_balance", 0),
|
||||
"weekly_spent_balance": data.get("current_week_spend", 0),
|
||||
"subscription_plan": data.get("subscription_plan"),
|
||||
"last_week_reset": data.get("last_week_reset"),
|
||||
"last_daily_balance_add": data.get("last_daily_balance_add"),
|
||||
}
|
||||
|
||||
extra = parse_yescode_balance_extra(compat_data)
|
||||
|
||||
total_available = extra.pop("_total_available", 0)
|
||||
extra.pop("_subscription_available", None)
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=data.get("username"),
|
||||
display_name=data.get("username"),
|
||||
email=data.get("email"),
|
||||
quota=total_available,
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
@@ -9,10 +9,13 @@ from typing import Dict, List, Optional, Type
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.architectures import (
|
||||
AnyrouterArchitecture,
|
||||
CubenceArchitecture,
|
||||
GenericApiArchitecture,
|
||||
NewApiArchitecture,
|
||||
OneApiArchitecture,
|
||||
ProviderArchitecture,
|
||||
YesCodeArchitecture,
|
||||
)
|
||||
|
||||
|
||||
@@ -47,9 +50,12 @@ class ArchitectureRegistry:
|
||||
def _register_builtin_architectures(self) -> None:
|
||||
"""注册内置架构"""
|
||||
builtin = [
|
||||
AnyrouterArchitecture,
|
||||
CubenceArchitecture,
|
||||
GenericApiArchitecture,
|
||||
NewApiArchitecture,
|
||||
OneApiArchitecture,
|
||||
YesCodeArchitecture,
|
||||
]
|
||||
|
||||
for arch_cls in builtin:
|
||||
|
||||
@@ -45,7 +45,7 @@ class ProviderOpsService:
|
||||
"""
|
||||
|
||||
# 凭据中需要加密的字段
|
||||
SENSITIVE_FIELDS = {"api_key", "password", "session_token", "cookie_string", "cookies"}
|
||||
SENSITIVE_FIELDS = {"api_key", "password", "session_token", "session_cookie", "token_cookie", "auth_cookie", "cookie_string", "cookies"}
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
@@ -412,13 +412,19 @@ class ProviderOpsService:
|
||||
|
||||
await CacheService.set(cache_key, cache_data, BALANCE_CACHE_TTL)
|
||||
|
||||
async def _cache_balance_from_verify(self, provider_id: str, quota_usd: float) -> None:
|
||||
async def _cache_balance_from_verify(
|
||||
self,
|
||||
provider_id: str,
|
||||
quota_usd: float,
|
||||
extra: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
从验证结果缓存余额
|
||||
|
||||
Args:
|
||||
provider_id: Provider ID
|
||||
quota_usd: 已转换为美元的余额值
|
||||
extra: 额外信息(如窗口限额)
|
||||
"""
|
||||
cache_key = f"provider_ops:balance:{provider_id}"
|
||||
|
||||
@@ -431,7 +437,7 @@ class ProviderOpsService:
|
||||
"total_used": None,
|
||||
"total_available": quota_usd,
|
||||
"currency": "USD",
|
||||
"extra": {},
|
||||
"extra": extra or {},
|
||||
},
|
||||
"executed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"response_time_ms": None,
|
||||
@@ -609,7 +615,10 @@ class ProviderOpsService:
|
||||
|
||||
if saved_config:
|
||||
saved_credentials = self._decrypt_credentials(saved_config.connector_credentials)
|
||||
sensitive_fields = ["api_key", "password", "session_token", "cookie_string", "cookies"]
|
||||
sensitive_fields = [
|
||||
"api_key", "password", "session_token", "cookie_string", "cookies",
|
||||
"token_cookie", "auth_cookie", # Cookie 认证字段
|
||||
]
|
||||
|
||||
for field in sensitive_fields:
|
||||
# 如果请求中该字段为空或只包含星号(脱敏值),使用已保存的值
|
||||
@@ -704,7 +713,12 @@ class ProviderOpsService:
|
||||
|
||||
# 使用架构的方法构建请求
|
||||
verify_endpoint = f"{base_url}{architecture.get_verify_endpoint()}"
|
||||
headers = architecture.build_verify_headers(config, credentials)
|
||||
|
||||
# 执行异步预处理(如获取动态 Cookie)
|
||||
extra_config = await architecture.prepare_verify_config(base_url, config, credentials)
|
||||
merged_config = {**config, **extra_config}
|
||||
|
||||
headers = architecture.build_verify_headers(merged_config, credentials)
|
||||
|
||||
logger.debug(
|
||||
f"验证认证: architecture={architecture_id}, "
|
||||
@@ -721,6 +735,12 @@ class ProviderOpsService:
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
# 将预处理获取的额外数据合并到响应中
|
||||
if "_combined_data" in merged_config:
|
||||
data["_combined_data"] = merged_config["_combined_data"]
|
||||
elif "_balance_data" in merged_config:
|
||||
data["_balance_data"] = merged_config["_balance_data"]
|
||||
|
||||
# 使用架构的方法解析响应
|
||||
result = architecture.parse_verify_response(response.status_code, data)
|
||||
result_dict = result.to_dict()
|
||||
@@ -734,7 +754,8 @@ class ProviderOpsService:
|
||||
quota_divisor = balance_config.get("quota_divisor", 1)
|
||||
# 转换为美元值后缓存
|
||||
quota_usd = result.quota / quota_divisor
|
||||
await self._cache_balance_from_verify(provider_id, quota_usd)
|
||||
# 传入 extra 信息(如窗口限额)
|
||||
await self._cache_balance_from_verify(provider_id, quota_usd, result.extra)
|
||||
|
||||
return result_dict
|
||||
|
||||
|
||||
Reference in New Issue
Block a user