Files
vps-manager/app/adapters/whmcs.py
T
gouki abd2fa8206 feat(renewal): 提醒前核对平台真实状态,消除手工数据导致的误报
容错策略:核对失败/无可用通道时标记「未核对」并继续按本地数据提醒,
避免因核对链路故障漏掉真正到期的资产;已停止资产仅在 auto_renew=true
时提醒(停止实例不等于停止扣费)。新增 WHMCS 面板只读适配器,
用于 TotHost 这类没有开放 API 的平台。
2026-10-25 23:05:39 +00:00

249 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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