feat(renewal): 提醒前核对平台真实状态,消除手工数据导致的误报

容错策略:核对失败/无可用通道时标记「未核对」并继续按本地数据提醒,
避免因核对链路故障漏掉真正到期的资产;已停止资产仅在 auto_renew=true
时提醒(停止实例不等于停止扣费)。新增 WHMCS 面板只读适配器,
用于 TotHost 这类没有开放 API 的平台。
This commit is contained in:
gouki
2026-10-25 23:05:39 +00:00
parent de8a3caedd
commit abd2fa8206
12 changed files with 1229 additions and 27 deletions
+248
View File
@@ -0,0 +1,248 @@
"""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