"""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']*name=["\']token["\'][^>]*value=["\']([^"\']+)["\']', r']*value=["\']([^"\']+)["\'][^>]*name=["\']token["\']', ) forms = re.findall(r"
", 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'