usa_plugin.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import time
  2. import json
  3. import random
  4. import re
  5. import os
  6. import uuid
  7. import shutil
  8. import socket
  9. from datetime import date, datetime
  10. from typing import List, Dict, Optional, Any, Callable
  11. from urllib.parse import urljoin, urlparse, urlencode, parse_qs
  12. from concurrent.futures import ThreadPoolExecutor
  13. from DrissionPage import ChromiumPage, ChromiumOptions
  14. import configure
  15. from vs_plg import IVSPlg
  16. from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
  17. from toolkit.mihomo_tunnel import MihomoTunnel
  18. from utils.mouse import HumanMouse
  19. from utils.keyboard import HumanKeyboard
  20. from utils.fingerprint_utils import FingerprintGenerator
  21. from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  22. class BrowserResponse:
  23. """模拟 requests.Response"""
  24. def __init__(self, result_dict):
  25. result_dict = result_dict or {}
  26. self.status_code = result_dict.get('status', 0)
  27. self.text = result_dict.get('body', '')
  28. self.headers = result_dict.get('headers', {})
  29. self.url = result_dict.get('url', '')
  30. self._json = None
  31. def json(self):
  32. if self._json is None:
  33. if not self.text:
  34. return {}
  35. try:
  36. self._json = json.loads(self.text)
  37. except:
  38. self._json = {}
  39. return self._json
  40. class UsaPlugin(IVSPlg):
  41. LOCATIONS = {
  42. "SHANGHAI": {"name": "SHANGHAI", "id": "096bf614-b0db-ec11-a7b4-001dd80234f6"},
  43. "WUHAN": {"name": "WUHAN", "id": "7b6af614-b0db-ec11-a7b4-001dd80234f6"},
  44. "SHENYANG": {"name": "SHENYANG", "id": "0f6bf614-b0db-ec11-a7b4-001dd80234f6"},
  45. }
  46. def __init__(self, group_id: str):
  47. self.group_id = group_id
  48. self.config: Optional[VSPlgConfig] = None
  49. self.free_config: Dict[str, Any] = {}
  50. self.is_healthy = True
  51. self.logger = None
  52. self.mouse = None
  53. self.keyboard = None
  54. self.page: Optional[ChromiumPage] = None
  55. self.instance_id = uuid.uuid4().hex[:8]
  56. self.root_workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"{self.group_id}.{self.instance_id}"))
  57. self.user_data_path = os.path.join(self.root_workspace, "user_data")
  58. if not os.path.exists(self.root_workspace):
  59. os.makedirs(self.root_workspace)
  60. self.tunnel = None
  61. self.session_create_time: float = 0
  62. def _log(self, message):
  63. if self.logger:
  64. self.logger(f'[UsaPlugin] [{self.group_id}] [{self.instance_id}] {message}')
  65. else:
  66. print(f'[UsaPlugin] [{self.group_id}] [{self.instance_id}] {message}')
  67. def _random_sleep(self, min_sec=30, max_sec=60):
  68. """
  69. 核心防限速控制:模拟人类阅读和操作的长时间停顿,确保网络请求间隔在30-60秒
  70. """
  71. sleep_time = random.uniform(min_sec, max_sec)
  72. self._log(f"Anti-Rate-Limit: Sleeping for {sleep_time:.2f} seconds...")
  73. time.sleep(sleep_time)
  74. def _get_free_port(self):
  75. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  76. s.bind(('', 0))
  77. return s.getsockname()[1]
  78. def set_config(self, config: VSPlgConfig):
  79. """设置 API 的配置信息"""
  80. self.config = config
  81. self.free_config = config.free_config or {}
  82. def set_log(self, logger: Callable[[str], None]) -> None:
  83. """设置日志输出工具"""
  84. self.logger = logger
  85. def keep_alive(self):
  86. pass
  87. def health_check(self) -> bool:
  88. if not self.is_healthy:
  89. return False
  90. if self.page is None:
  91. return False
  92. try:
  93. if not self.page.run_js("return 1;"):
  94. return False
  95. except:
  96. return False
  97. if self.config.session_max_life > 0:
  98. current_time = time.time()
  99. elapsed_time = current_time - self.session_create_time
  100. if elapsed_time > self.config.session_max_life:
  101. self._log(f"Session expired.")
  102. return False
  103. return True
  104. def _save_screenshot(self, name_prefix):
  105. try:
  106. timestamp = int(time.time())
  107. filename = f"{self.instance_id}_{name_prefix}_{timestamp}.jpg"
  108. save_path = os.path.join("data", filename)
  109. os.makedirs("data", exist_ok=True)
  110. self.page.get_screenshot(path=save_path, full_page=False)
  111. self._log(f"Screenshot saved to {save_path}")
  112. except Exception as e:
  113. self._log(f"Failed to save screenshot: {e}")
  114. def create_session(self) -> None:
  115. """创建一个新的会话 (包含初始化浏览器、过CF验证和执行登录)"""
  116. self._log(f"Initializing Session (ID: {self.instance_id})...")
  117. def get_free_port():
  118. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  119. s.bind(('', 0))
  120. return s.getsockname()[1]
  121. co = ChromiumOptions()
  122. debug_port = get_free_port()
  123. self._log(f"Assigned Debug Port: {debug_port}")
  124. self._log(f"Account id={self.config.account.id}, proxy id={self.config.proxy.id}")
  125. co.set_local_port(debug_port)
  126. co.set_user_data_path(self.user_data_path)
  127. chrome_path = configure.CHROME_PATH
  128. if not chrome_path:
  129. chrome_path = os.getenv("CHROME_BIN")
  130. if chrome_path and os.path.exists(chrome_path):
  131. co.set_paths(browser_path=chrome_path)
  132. if self.config.proxy and self.config.proxy.ip:
  133. p = self.config.proxy
  134. if p.username and p.password:
  135. self._log(f"Starting Proxy Tunnel for {p.ip}...")
  136. exit_node = {
  137. "name": "ExitNode",
  138. "type": p.proto,
  139. "server": p.ip,
  140. "port": p.port,
  141. "username": p.username,
  142. "password": p.password
  143. }
  144. relay_node = None
  145. if configure.MIHOMO_RELAY_NODES:
  146. relay_node = random.choice(configure.MIHOMO_RELAY_NODES)
  147. mihomo_path = configure.MIHOMO_BIN_PATH
  148. if not mihomo_path:
  149. mihomo_path = os.getenv("MIHOMO_BIN")
  150. if not mihomo_path:
  151. raise BizLogicError(message='Mihomo path is null, You need set mihomo bin path in configure or os env')
  152. self.tunnel = MihomoTunnel(mihomo_path, exit_node=exit_node, relay_node=relay_node)
  153. local_proxy = self.tunnel.start()
  154. self._log(f"Tunnel started at {local_proxy}")
  155. co.set_argument(f'--proxy-server={local_proxy}')
  156. else:
  157. proxy_str = f"{p.proto}://{p.ip}:{p.port}"
  158. co.set_argument(f'--proxy-server={proxy_str}')
  159. else:
  160. self._log("[WARN] No proxy configured!")
  161. specific_fp = FingerprintGenerator().generate(self.config.account.username)
  162. fp_seed = specific_fp.get("seed")
  163. fp_platform = specific_fp.get("platform")
  164. fp_brand = specific_fp.get("brand")
  165. self._log(f'browser fingerprint seed={fp_seed}')
  166. co.headless(False)
  167. co.set_argument('--no-sandbox')
  168. co.set_argument('--disable-dev-shm-usage')
  169. co.set_argument('--window-size=1920,1080')
  170. co.set_argument('--disable-blink-features=AutomationControlled')
  171. co.set_argument(f"--fingerprint={fp_seed}")
  172. co.set_argument(f"--fingerprint-platform={fp_platform}")
  173. co.set_argument(f"--fingerprint-brand={fp_brand}")
  174. self.page = ChromiumPage(co)
  175. # 获取基础 URL
  176. usa_url = self.free_config.get('usa_url', '')
  177. self._log(f"Navigating: {usa_url}")
  178. self.page.get(usa_url)
  179. # 初始访问页面,等待长延时,防止过快触发后续操作
  180. self._random_sleep(30, 45)
  181. if 'Attention Required! | Cloudflare' in self.page.title and 'Sorry, you have been blocked' in self.page.html:
  182. self._log(f'Block by cloudflare, try refresh...')
  183. self.page.refresh()
  184. self._random_sleep(30, 45) # 刷新动作属于高危操作,加长延时
  185. self.page.wait.doc_loaded()
  186. cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
  187. if not cf_bypasser.bypass(max_retry=6):
  188. raise BizLogicError("Cloudflare bypass timeout")
  189. # 绕过盾后,休眠一段时间再处理 waiting room
  190. self._random_sleep(15, 30)
  191. cf_bypasser.handle_waiting_room()
  192. self._log("Init humanize tools...")
  193. self.mouse = HumanMouse(self.page, debug=False)
  194. self.keyboard = HumanKeyboard(self.page)
  195. viewport_width = self.page.rect.viewport_size[0]
  196. viewport_height = self.page.rect.viewport_size[1]
  197. init_x = random.randint(10, viewport_width - 10)
  198. init_y = random.randint(10, viewport_height - 10)
  199. self.mouse.move(init_x, init_y)
  200. username = self.config.account.username
  201. password = self.config.account.password
  202. security = self.free_config.get('security', {})
  203. max_steps = 15 # 由于状态多,步数可以稍微调大一点
  204. stuck_counter = 0
  205. last_url = ""
  206. session_created = False
  207. has_submitted_login = False
  208. for step in range(max_steps):
  209. self.page.wait.doc_loaded()
  210. time.sleep(1) # 这个用于等待页面DOM渲染,保留短时,因为不是发请求
  211. current_url = self.page.url
  212. current_title = self.page.title.lower()
  213. current_html_content = self.page.html
  214. self._log(f"--- [Router Step {step+1}] Current URL: {current_url} ---")
  215. if current_url == last_url:
  216. stuck_counter += 1
  217. else:
  218. last_url = current_url
  219. stuck_counter = 0
  220. # --- [异常处理层] ---
  221. if stuck_counter >= 3:
  222. self._log("[WARN] Page stucked, try to refresh...")
  223. self.page.refresh()
  224. self._random_sleep(30, 60) # 刷新操作触发请求,长延时
  225. stuck_counter = 0
  226. continue
  227. server_error_indicators = ["502 Bad Gateway", "503 Service Temporarily Unavailable"]
  228. # 网络出现故障,直接重试
  229. if any(err in current_html_content for err in server_error_indicators):
  230. self._log(f"[WARN] Server network error, try to refresh (Step: {step})...")
  231. self.page.refresh()
  232. self._random_sleep(45, 60) # 遇到错误必须拉长延时防止被彻底拉黑
  233. continue
  234. cloudflare_blocked_indicators = [
  235. "Sorry, you have been blocked" in current_html_content,
  236. "You are being rate limited" in current_html_content,
  237. "Cloudflare Ray ID" in current_html_content
  238. ]
  239. if any(cloudflare_blocked_indicators):
  240. raise BizLogicError(message="Blocked by Cloudflare WAF. Need to change IP or browser fingerprint.")
  241. # 遇到五秒盾先绕盾
  242. if "just a moment" in current_title:
  243. cf_bypasser.bypass(max_retry=3)
  244. self._random_sleep(20, 40)
  245. continue
  246. if self.page.ele('#post_select', timeout=1):
  247. self._log("🎉 Successfully reached the Slot Search page (Target Page). Session created successfully!")
  248. self.session_create_time = time.time()
  249. session_created = True
  250. break
  251. # 状态 2: 密保问题页面
  252. elif self.page.ele('xpath://input[starts-with(@id, "kba") and contains(@id, "_response")]', timeout=1):
  253. self._log("[State] Security question verification detected. Filling in answers...")
  254. answer_eles = self.page.eles('xpath://input[starts-with(@id, "kba") and contains(@id, "_response")]')
  255. for ans_ele in answer_eles:
  256. ele_id = ans_ele.attr('id')
  257. match = re.search(r'kba(\d+)_response', ele_id)
  258. if match:
  259. q_num = match.group(1)
  260. config_key = f"{q_num}_quest"
  261. q_data = security.get(config_key)
  262. ans_text = q_data.get('a')
  263. ans_ele.input(ans_text)
  264. self._log(f"-> Find input {ele_id}, successfully filled in the answer for question {q_num}.")
  265. self.page.ele('#continue').click()
  266. self._log("Security answers submitted. Waiting for redirection...")
  267. # 提交密保问题,触发POST请求,长延时
  268. self._random_sleep(30, 60)
  269. continue
  270. # 状态 1: 登录页面
  271. elif self.page.ele('#signInName', timeout=1):
  272. self._log("[State] Login page detected. Submitting credentials...")
  273. username_input = self.page.ele('#signInName')
  274. username_input.clear()
  275. username_input.input(username)
  276. password_input = self.page.ele('#password')
  277. password_input.clear()
  278. password_input.input(password)
  279. self.page.ele('#continue').click()
  280. has_submitted_login = True
  281. self._log("Login form submitted. Waiting for the next step to load...")
  282. # 提交登录表单,触发POST请求,必须长延时
  283. self._random_sleep(30, 60)
  284. continue
  285. # 状态 3: 预约主页(控制台) -> 选择首签或改签
  286. elif self.page.ele('#atlas-sidebar', timeout=1):
  287. self._log("[State] At the main booking dashboard. Looking for navigation button...")
  288. reschedule_btn = self.page.ele('#reschedule_appointment', timeout=0.5)
  289. if reschedule_btn:
  290. self._log("-> Detected [Reschedule Appointment]. Currently in rescheduling mode, clicking to proceed...")
  291. reschedule_btn.click()
  292. # 点击导航按钮,触发页面跳转GET请求,长延时
  293. self._random_sleep(30, 50)
  294. else:
  295. schedule_btn = self.page.ele('xpath://ul[@id="atlas-sidebar"]//a[text()="安排预约" or text()="New Appointment" or text()="Schedule Appointment"]', timeout=0.5)
  296. if schedule_btn:
  297. self._log("-> Detected [Schedule Appointment]. Currently in first-time booking mode, clicking to proceed...")
  298. schedule_btn.click()
  299. # 点击导航按钮,触发页面跳转GET请求,长延时
  300. self._random_sleep(30, 50)
  301. else:
  302. self._log("-> [WARN] Sidebar found, but no 'Schedule' or 'Reschedule' button detected. The page may still be loading...")
  303. time.sleep(2) # 仅等待DOM渲染,不发请求,保持短时
  304. continue
  305. else:
  306. self._log("[State] In unknown or transitional state. No matching UI elements found. Waiting for next polling cycle...")
  307. time.sleep(2) # 仅等待DOM渲染,不发请求,保持短时
  308. if not session_created:
  309. raise BizLogicError(f"Failed to reach appointment-booking after {max_steps} navigation steps. Stuck at: {self.page.url}")
  310. def query(self, apt_type: AppointmentType) -> VSQueryResult:
  311. """查询可用的签证预约信息"""
  312. self._log("Querying available slots...")
  313. res = VSQueryResult()
  314. res.success = False
  315. # 1. 刷新页面以获取最新数据
  316. self.page.refresh()
  317. # 【关键修改】:刷新操作触发页面重载请求,必须执行长睡眠
  318. self._random_sleep(30, 60)
  319. current_url = self.page.url.lower()
  320. if 'auth' in current_url or 'login' in current_url:
  321. self.is_healthy = False
  322. raise SessionExpiredOrInvalidError()
  323. applicant = self.free_config.get('applicant')
  324. location_name = self.free_config.get('location')
  325. location_id = self.LOCATIONS.get(location_name.upper(), {}).get('id')
  326. # 2. 等待页面元素
  327. self.page.ele(f"xpath://label[text()='{applicant}']", timeout=60)
  328. post_select = self.page.ele('#po