isms_client.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/09/09
  5. """爱接码 i-sms.app 接码平台客户端(改编自官方 Python SDK 示例)。
  6. 用途:账号池自动补货时,通过接码平台拿手机号 + 轮询收得卡短信验证码,实现「登录即注册」全自动。
  7. 来源:https://github.com/i-sms-app/i-sms-api-sdk-examples/tree/main/python (标准库 urllib,无第三方依赖)。
  8. 鉴权:请求头 X-API-KEY(api_key 建议从环境变量 ISMS_API_KEY 读,勿硬编码/提交公开仓库)。
  9. 调用流程:search_projects → get_number → get_sms(轮询) → release_number。
  10. """
  11. import os
  12. import json
  13. import time
  14. import urllib.parse
  15. import urllib.request
  16. import urllib.error
  17. ISMS_BASE = "https://www.i-sms.app" # 接码平台端点
  18. POLL_INTERVAL_SEC = 5 # 轮询验证码间隔(官方建议 ≥5s,过快触发 WAF)
  19. POLL_MAX_SEC = 90 # 轮询验证码最长等待(收不到就快换号,少浪费取号费;官方建议 60~180s)
  20. # 必带浏览器 UA:urllib 默认 UA(Python-urllib/x.y) 会被爱接码 WAF 拦成 403(2026/09/09 实测)
  21. _UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
  22. class ISmsClient:
  23. """爱接码接码平台 API 客户端:搜项目 / 取号 / 收验证码 / 释放号 / 查余额。"""
  24. def __init__(self, api_key: str = None, base_url: str = ISMS_BASE, timeout: int = 20):
  25. """初始化客户端。
  26. Args:
  27. api_key (str, optional): 接码平台 API Key;None 时从环境变量 ISMS_API_KEY 读。Defaults to None。
  28. base_url (str, optional): 接口端点。Defaults to ISMS_BASE。
  29. timeout (int, optional): 单次请求超时秒数。Defaults to 20。
  30. Raises:
  31. ValueError: api_key 缺失(既未传参也无 ISMS_API_KEY 环境变量)时抛出。
  32. """
  33. self.api_key = api_key or os.environ.get("ISMS_API_KEY")
  34. if not self.api_key:
  35. raise ValueError("缺少接码平台 api_key(传参或设环境变量 ISMS_API_KEY)")
  36. self.base_url = base_url.rstrip("/")
  37. self.timeout = timeout
  38. def search_projects(self, keyword: str) -> dict:
  39. """按关键词搜索接码项目(拿 project_id/name/token)。
  40. Args:
  41. keyword (str): 项目关键词(如 "得卡" / "DECA" / "decalive")。
  42. Returns:
  43. dict: 平台响应 JSON,成功时 data 为项目列表,每项含 project_id/name/token。
  44. """
  45. return self._get("/api/v2/projects", {"keyword": keyword})
  46. def get_number(self, project_id, project_name, project_token, quantity: int = 1,
  47. phone: str = None, province: str = None, carrier: str = None,
  48. ascription: int = None) -> dict:
  49. """获取一个(或多个)可用手机号。
  50. Args:
  51. project_id: 项目 ID(来自 search_projects)。
  52. project_name: 项目名(来自 search_projects,需与 id/token 匹配)。
  53. project_token: 项目 V2 安全 Token(来自 search_projects)。
  54. quantity (int, optional): 取号数量(1~10)。Defaults to 1。
  55. phone (str, optional): 指定手机号(一般留空由平台分配)。Defaults to None。
  56. province (str, optional): 省份代码(见 README,留空不限)。Defaults to None。
  57. carrier (str, optional): 运营商代码(CMCC/CUCC/CTCC…,留空不限)。Defaults to None。
  58. ascription (int, optional): 卡类型 1=虚拟卡 / 2=实体卡(强风控平台建议 2)。Defaults to None。
  59. Returns:
  60. dict: 平台响应 JSON,成功时 data[i] 含 number/orderId,顶层含 balance。
  61. """
  62. return self._get("/api/v2/get_number", {
  63. "project_id": project_id, "project_name": project_name,
  64. "project_token": project_token, "quantity": quantity, "phone": phone,
  65. "province": province, "carrier": carrier, "ascription": ascription})
  66. def get_sms(self, order_id=None, phone_number=None, project_id=None) -> dict:
  67. """获取某订单收到的短信验证码(单次查询,未到时 success=False)。
  68. Args:
  69. order_id (optional): 取号返回的订单 ID(首选)。Defaults to None。
  70. phone_number (optional): 手机号(备用定位)。Defaults to None。
  71. project_id (optional): 项目 ID(备用定位)。Defaults to None。
  72. Returns:
  73. dict: 平台响应 JSON,成功时含 sms_code/sms_content。
  74. """
  75. return self._get("/api/v1/get_sms", {
  76. "order_id": order_id, "phone_number": phone_number, "project_id": project_id})
  77. def poll_sms(self, order_id, interval: int = POLL_INTERVAL_SEC, max_sec: int = POLL_MAX_SEC,
  78. log=None) -> str | None:
  79. """按固定间隔轮询验证码直到拿到或超时(对 get_sms 的封装)。
  80. Args:
  81. order_id: 取号返回的订单 ID。
  82. interval (int, optional): 轮询间隔秒。Defaults to POLL_INTERVAL_SEC(5)。
  83. max_sec (int, optional): 最长等待秒。Defaults to POLL_MAX_SEC(180)。
  84. log (optional): 日志对象。Defaults to None。
  85. Returns:
  86. str | None: 验证码字符串;超时未收到返回 None。
  87. """
  88. deadline = time.time() + max_sec
  89. while time.time() < deadline:
  90. resp = self.get_sms(order_id=order_id)
  91. if resp.get("success") and resp.get("sms_code"):
  92. return str(resp["sms_code"])
  93. time.sleep(interval)
  94. if log:
  95. log.warning(f"[接码] 订单 {order_id} 轮询 {max_sec}s 未收到验证码")
  96. return None
  97. def release_number(self, order_id=None, phone_number=None, project_id=None) -> dict:
  98. """释放不再使用的号码(用完及时调,降资源占用)。
  99. Args:
  100. order_id (optional): 订单 ID(首选)。Defaults to None。
  101. phone_number (optional): 手机号。Defaults to None。
  102. project_id (optional): 项目 ID。Defaults to None。
  103. Returns:
  104. dict: 平台响应 JSON。
  105. """
  106. return self._get("/api/v1/release_number", {
  107. "order_id": order_id, "phone_number": phone_number, "project_id": project_id})
  108. def get_user_info(self) -> dict:
  109. """查询账户信息与余额。
  110. Returns:
  111. dict: 平台响应 JSON(含余额,用于补货前判断额度是否充足)。
  112. """
  113. return self._get("/api/v1/user/info")
  114. def _get(self, path: str, params: dict = None) -> dict:
  115. """内部:发 GET 请求并解析 JSON(HTTP 错误也返回带 http_status 的 JSON,不抛异常)。
  116. Args:
  117. path (str): 接口路径。
  118. params (dict, optional): query 参数(None/"" 值自动剔除)。Defaults to None。
  119. Returns:
  120. dict: 响应 JSON;HTTP 错误时返回 {success:False, ..., http_status:code}。
  121. """
  122. query = {k: v for k, v in (params or {}).items() if v is not None and v != ""}
  123. url = f"{self.base_url}{path}"
  124. if query:
  125. url = f"{url}?{urllib.parse.urlencode(query)}"
  126. req = urllib.request.Request(url, headers={
  127. "X-API-KEY": self.api_key, "User-Agent": _UA, "Accept": "application/json"})
  128. try:
  129. with urllib.request.urlopen(req, timeout=self.timeout) as resp:
  130. return json.loads(resp.read().decode("utf-8"))
  131. except urllib.error.HTTPError as exc:
  132. body = exc.read().decode("utf-8")
  133. try:
  134. payload = json.loads(body)
  135. except json.JSONDecodeError:
  136. payload = {"success": False, "message": body}
  137. payload["http_status"] = exc.code
  138. return payload