"""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']*id=["\']tableServicesList["\']', page, re.I) if not start: raise WHMCSError("未在面板页面找到服务列表(页面结构可能已变化)") end = page.find("", start.end()) table = page[start.start(): end + 8 if end != -1 else len(page)] services = [] for row in re.findall(r"]*>(.*?)", 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]+)[^"\']*["\'][^>]*>(.*?)', row, re.S | re.I, ) if not status: continue domain_match = re.search(r'data-domain=["\']([^"\']*)["\']', row) product_match = re.search(r"(.*?)", row, re.S | re.I) due_match = re.search( r']*class=["\'][^"\']*w-hidden[^"\']*["\'][^>]*>(\d{4}-\d{2}-\d{2})', 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']*class=["\'][^"\']*text-muted[^"\']*["\'][^>]*>(.*?)', 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