容错策略:核对失败/无可用通道时标记「未核对」并继续按本地数据提醒, 避免因核对链路故障漏掉真正到期的资产;已停止资产仅在 auto_renew=true 时提醒(停止实例不等于停止扣费)。新增 WHMCS 面板只读适配器, 用于 TotHost 这类没有开放 API 的平台。
249 lines
10 KiB
Python
249 lines
10 KiB
Python
"""WHMCS 账单面板适配器
|
||
|
||
适用于 TotHost 等使用 WHMCS 客户区、但没有开放 API 的平台。
|
||
通过账号关联的登录凭据进入客户区,读取「我的产品/服务」列表:
|
||
服务 ID、主机名、下次到期日、状态。
|
||
|
||
只做只读核对,不执行开通/续费/停机等写操作。
|
||
"""
|
||
|
||
import html as html_lib
|
||
import re
|
||
from datetime import date
|
||
from typing import Optional
|
||
from urllib.parse import urlparse
|
||
|
||
import httpx
|
||
|
||
from app.adapters.base import BaseAdapter, NormalizedVPS, cloud_error_text
|
||
from app.adapters.registry import register
|
||
|
||
|
||
class WHMCSError(Exception):
|
||
"""面板登录 / 页面解析失败(消息面向使用者,不含密码)"""
|
||
|
||
|
||
#: WHMCS 客户区状态 CSS 类后缀 -> 本项目资产状态
|
||
_STATUS_MAP = {
|
||
"active": "active",
|
||
"suspended": "stopped",
|
||
"inactive": "stopped",
|
||
"terminated": "cancelled",
|
||
"cancelled": "cancelled",
|
||
"fraud": "cancelled",
|
||
"pending": "unknown",
|
||
}
|
||
|
||
_IPV4_RE = re.compile(r"^(?:\d{1,3}\.){3}\d{1,3}$")
|
||
|
||
#: 登录成功标志:客户区一定会渲染登出入口,但主题会按语言输出不同文案
|
||
_LOGGED_IN_RE = re.compile(
|
||
r"logout|>\s*(?:退出登录|退出|登出|登出帳號|注销|登録解除|ログアウト)\s*<", re.I
|
||
)
|
||
|
||
|
||
@register("whmcs-panel")
|
||
class WHMCSAdapter(BaseAdapter):
|
||
"""WHMCS 客户区只读适配器
|
||
|
||
配置由账号凭据注入:
|
||
- base_url: 平台 console_url(如 https://billing.example.com/clientarea.php)
|
||
- username / password: accounts.credential_id 关联的登录凭据
|
||
"""
|
||
|
||
#: 凭据从 credentials 表注入,不在平台 API 配置里重复填写
|
||
required_config: list = []
|
||
|
||
def __init__(self, config: Optional[dict] = None):
|
||
super().__init__(config)
|
||
raw = str(self.config.get("base_url") or "").strip()
|
||
if not raw:
|
||
raise WHMCSError("缺少面板地址(base_url),请先在平台资料中填写管理面板 URL")
|
||
if "://" not in raw:
|
||
raw = "https://" + raw
|
||
parsed = urlparse(raw)
|
||
if not parsed.netloc:
|
||
raise WHMCSError(f"面板地址无效:{raw}")
|
||
self.origin = f"{parsed.scheme}://{parsed.netloc}"
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# 面板交互
|
||
# ------------------------------------------------------------------ #
|
||
def _client(self) -> httpx.Client:
|
||
return httpx.Client(
|
||
base_url=self.origin,
|
||
follow_redirects=True,
|
||
timeout=25,
|
||
headers={"User-Agent": "Mozilla/5.0 (compatible; vps-manager)"},
|
||
)
|
||
|
||
def _login(self, client: httpx.Client) -> None:
|
||
username = str(self.config.get("username") or "").strip()
|
||
password = str(self.config.get("password") or "")
|
||
if not username or not password:
|
||
raise WHMCSError("缺少面板登录凭据(请在账号上关联带用户名和密码的登录凭据)")
|
||
|
||
page = client.get("/login")
|
||
if page.status_code >= 400:
|
||
raise WHMCSError(f"打开登录页失败:HTTP {page.status_code}")
|
||
token = self._extract_token(page.text)
|
||
if not token:
|
||
raise WHMCSError("登录页缺少 CSRF token,面板结构可能已变化")
|
||
|
||
resp = client.post(
|
||
"/login",
|
||
data={"username": username, "password": password, "token": token},
|
||
)
|
||
body = resp.text or ""
|
||
if not _LOGGED_IN_RE.search(body):
|
||
if re.search(r"two[- ]?factor|\b2fa\b|第二因素|兩步|双重验证|雙重驗證", body, re.I):
|
||
raise WHMCSError("面板启用了二次验证(2FA),无法自动登录")
|
||
raise WHMCSError("面板登录失败:账号或密码错误,或需要验证码")
|
||
|
||
@staticmethod
|
||
def _extract_token(page: str) -> Optional[str]:
|
||
"""提取登录表单的 CSRF token
|
||
|
||
优先在 action/id 含 login 的表单内查找——页面上可能同时存在工单、
|
||
改密等多个带 token 的表单,取错会导致提交被拒。
|
||
"""
|
||
patterns = (
|
||
r'<input[^>]*name=["\']token["\'][^>]*value=["\']([^"\']+)["\']',
|
||
r'<input[^>]*value=["\']([^"\']+)["\'][^>]*name=["\']token["\']',
|
||
)
|
||
forms = re.findall(r"<form\b[^>]*>.*?</form>", page, re.I | re.S)
|
||
login_forms = [
|
||
form for form in forms
|
||
if re.search(r'(?:action|id|class)=["\'][^"\']*(?:login|dologin)', form, re.I)
|
||
]
|
||
for form in login_forms or forms:
|
||
for pattern in patterns:
|
||
found = re.search(pattern, form, re.I | re.S)
|
||
if found:
|
||
return found.group(1)
|
||
for pattern in patterns:
|
||
found = re.search(pattern, page, re.I | re.S)
|
||
if found:
|
||
return found.group(1)
|
||
return None
|
||
|
||
def fetch_services(self, include_terminated: bool = True) -> list:
|
||
"""登录面板并返回服务列表(默认包含已删除,供续费核对判断服务是否还在)"""
|
||
with self._client() as client:
|
||
self._login(client)
|
||
resp = client.get("/clientarea.php", params={"action": "products"})
|
||
if resp.status_code >= 400:
|
||
raise WHMCSError(f"打开服务列表失败:HTTP {resp.status_code}")
|
||
services = self.parse_services(resp.text)
|
||
if not include_terminated:
|
||
services = [s for s in services if s["status"] != "cancelled"]
|
||
return services
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# 页面解析
|
||
# ------------------------------------------------------------------ #
|
||
@classmethod
|
||
def parse_services(cls, page: str) -> list:
|
||
"""从 WHMCS 服务列表页解析服务行
|
||
|
||
依赖客户区主题输出:
|
||
- 行属性 data-element-id / data-domain
|
||
- 到期日 span.w-hidden 中的 ISO 日期
|
||
- 状态 span 的 status-{class} 类名(与界面语言无关)
|
||
"""
|
||
start = re.search(r'<table[^>]*id=["\']tableServicesList["\']', page, re.I)
|
||
if not start:
|
||
raise WHMCSError("未在面板页面找到服务列表(页面结构可能已变化)")
|
||
end = page.find("</table>", start.end())
|
||
table = page[start.start(): end + 8 if end != -1 else len(page)]
|
||
|
||
services = []
|
||
for row in re.findall(r"<tr\b[^>]*>(.*?)</tr>", table, re.S | re.I):
|
||
service_id = re.search(r'data-element-id=["\'](\d+)["\']', row)
|
||
if not service_id:
|
||
continue
|
||
status = re.search(
|
||
r'class=["\'][^"\']*status\s+status-([a-z]+)[^"\']*["\'][^>]*>(.*?)</span>',
|
||
row,
|
||
re.S | re.I,
|
||
)
|
||
if not status:
|
||
continue
|
||
|
||
domain_match = re.search(r'data-domain=["\']([^"\']*)["\']', row)
|
||
product_match = re.search(r"<strong>(.*?)</strong>", row, re.S | re.I)
|
||
due_match = re.search(
|
||
r'<span[^>]*class=["\'][^"\']*w-hidden[^"\']*["\'][^>]*>(\d{4}-\d{2}-\d{2})</span>',
|
||
row,
|
||
re.I,
|
||
)
|
||
if not due_match:
|
||
due_match = re.search(r"(\d{4}-\d{2}-\d{2})", row)
|
||
|
||
domain = html_lib.unescape(domain_match.group(1)).strip() if domain_match else ""
|
||
product = cls._text(product_match.group(1)) if product_match else ""
|
||
status_class = status.group(1).lower()
|
||
status_text = cls._text(status.group(2))
|
||
expiry = None
|
||
if due_match:
|
||
try:
|
||
expiry = date.fromisoformat(due_match.group(1))
|
||
except ValueError:
|
||
expiry = None
|
||
|
||
amount = re.search(r'data-order=["\']([\d.]+)["\']', row)
|
||
cycle = re.search(r'<small[^>]*class=["\'][^"\']*text-muted[^"\']*["\'][^>]*>(.*?)</small>', row, re.I)
|
||
|
||
services.append({
|
||
"id": service_id.group(1),
|
||
"product": product,
|
||
"domain": domain,
|
||
"label": f"{product} {domain}".strip(),
|
||
"expiry_date": expiry,
|
||
"status": _STATUS_MAP.get(status_class, "unknown"),
|
||
"status_class": status_class,
|
||
"status_text": status_text,
|
||
"amount": float(amount.group(1)) if amount else None,
|
||
"cycle": cls._text(cycle.group(1)) if cycle else "",
|
||
})
|
||
return services
|
||
|
||
@staticmethod
|
||
def _text(fragment: str) -> str:
|
||
return re.sub(r"\s+", " ", html_lib.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip()
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# BaseAdapter 接口
|
||
# ------------------------------------------------------------------ #
|
||
def test_connection(self) -> dict:
|
||
try:
|
||
services = self.fetch_services(include_terminated=True)
|
||
return {
|
||
"ok": True,
|
||
"message": f"面板登录成功,读取到 {len(services)} 个服务",
|
||
}
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "message": f"面板核对失败:{cloud_error_text(e)}"}
|
||
|
||
def list_vps(self) -> list:
|
||
result = []
|
||
for service in self.fetch_services(include_terminated=False):
|
||
domain = service["domain"]
|
||
result.append(
|
||
NormalizedVPS(
|
||
external_id=str(service["id"]),
|
||
name=service["label"] or str(service["id"]),
|
||
ip_address=domain if _IPV4_RE.match(domain) else "",
|
||
status=service["status"],
|
||
expiry_date=service["expiry_date"],
|
||
raw={
|
||
"hostname": domain,
|
||
"product": service["product"],
|
||
"status_text": service["status_text"],
|
||
"amount": service["amount"],
|
||
"cycle": service["cycle"],
|
||
},
|
||
)
|
||
)
|
||
return result
|