"""新网(Xinnet)代理 API 适配器 API 文档:新网「域名代理 API」(api.xinnet.com/domain/api.gb) 所需配置:{"client": "代理号", "password": "API 密码", "domains": "a.com,b.com"} 限制(必须如实告知,不能假装能一键同步): - 只有新网代理账号能调用,且必须在代理后台登记调用来源 IP,API 每年需年审; - 接口没有「列出全部域名」的方法,必须由用户提供要核对的域名清单; - 新网零售客户区登录带腾讯验证码,无法用面板抓取替代。 覆盖:按配置的域名逐个查询到期日(GetProductKey / keyname=ExpireDate)。 响应不是 JSON,而是 URL 编码的 key=value 串,成功时形如 ``name=example.com&key=2028-03-01``,失败时形如 ``ret=0&err=client-noexist``。 """ import hashlib import re from urllib.parse import parse_qsl import httpx from app.adapters.base import BaseAdapter, NormalizedDomain, parse_iso_date from app.adapters.registry import register _DOMAIN_SPLIT_RE = re.compile(r"[,,;;\s]+") @register("xinnet-api") class XinnetAdapter(BaseAdapter): required_config = ["client", "password", "domains"] #: 代理号和域名清单不是密钥,前端按明文输入 plain_config = ["client", "domains"] BASE = "https://api.xinnet.com/domain/api.gb" def _sign(self, method: str, parts: list) -> str: raw = method + str(self.config.get("client") or "") + str( self.config.get("password") or "" ) + "".join(parts) return hashlib.md5(raw.encode("utf-8")).hexdigest() def _post(self, method: str, params: dict, sign_parts: list) -> dict: payload = { "client": self.config.get("client") or "", **params, "checksum": self._sign(method, sign_parts), } with httpx.Client(timeout=30) as client: resp = client.post( self.BASE, params={"method": method, "charset": "utf-8"}, data=payload, ) resp.raise_for_status() data = dict(parse_qsl(resp.text.strip(), keep_blank_values=True)) ret = data.get("ret") if ret is not None and str(ret) != "100": reason = data.get("err") or data.get("info") or "未知错误" raise RuntimeError(f"新网 {method} 失败(ret={ret}):{reason}") return data @staticmethod def _enc(domain: str) -> str: return "G" if any(ord(ch) > 127 for ch in domain) else "E" def _domains(self) -> list: raw = str(self.config.get("domains") or "") seen = set() result = [] for item in _DOMAIN_SPLIT_RE.split(raw): domain = item.strip().lower() if domain and domain not in seen: seen.add(domain) result.append(domain) return result def _expiry(self, domain: str) -> str | None: enc = self._enc(domain) data = self._post( "GetProductKey", {"name": domain, "enc": enc, "keyname": "ExpireDate"}, [domain, enc], ) return data.get("key") or data.get("ExpireDate") def test_connection(self) -> dict: domains = self._domains() if not domains: return {"ok": False, "message": "请填写要核对的新网域名(逗号分隔)"} try: expiry = self._expiry(domains[0]) if not expiry: return {"ok": False, "message": f"接口已连通,但未返回 {domains[0]} 的到期日"} return { "ok": True, "message": f"连接成功,{domains[0]} 到期 {expiry}(共 {len(domains)} 个域名)", } except httpx.HTTPStatusError as e: return { "ok": False, "message": f"HTTP {e.response.status_code}:请确认代理号、API 密码与来源 IP 白名单", } except Exception as e: # noqa: BLE001 return {"ok": False, "message": str(e)} def list_domains(self) -> list: result = [] for domain in self._domains(): expiry_text = self._expiry(domain) if not expiry_text: raise RuntimeError(f"新网未返回 {domain} 的到期日,请确认该域名属于此代理账号") result.append( NormalizedDomain( external_id=domain, domain_name=domain, registrar="xinnet", expiry_date=parse_iso_date(expiry_text), status="active", raw={"domain": domain, "expiry": expiry_text}, ) ) return result