bel_plugin.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import time
  2. import json
  3. import random
  4. import re
  5. import os
  6. import uuid
  7. import shutil
  8. import base64
  9. import socket
  10. import requests
  11. from datetime import datetime
  12. from typing import List, Dict, Optional, Any, Callable
  13. from urllib.parse import urljoin, urlparse, urlencode
  14. # DrissionPage 核心
  15. from DrissionPage import ChromiumPage, ChromiumOptions
  16. from vs_plg import IVSPlg
  17. from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  18. from toolkit.vs_cloud_api import VSCloudApi
  19. from toolkit.proxy_tunnel import ProxyTunnel
  20. class BrowserResponse:
  21. def __init__(self, result_dict):
  22. result_dict = result_dict or {}
  23. self.status_code = result_dict.get('status', 0)
  24. self.text = result_dict.get('body', '')
  25. self.headers = result_dict.get('headers', {})
  26. self.url = result_dict.get('url', '')
  27. self._json = None
  28. def json(self):
  29. if self._json is None:
  30. if not self.text: return {}
  31. try: self._json = json.loads(self.text)
  32. except: self._json = {}
  33. return self._json
  34. def to_yyyymmdd(data_str: str, date_str_format: str, target_format: str="%Y-%m-%d"):
  35. dt = datetime.strptime(data_str, date_str_format)
  36. return dt.strftime("%Y-%m-%d")
  37. def get_alias_email(email: str, new_domain: str = "gmail-app.com") -> str:
  38. if "@" not in email: raise ValueError(f"Invalid email: {email}")
  39. local_part, _ = email.rsplit("@", 1)
  40. return f"{local_part}@{new_domain}"
  41. class BelPlugin(IVSPlg):
  42. """
  43. Belgium (https://visaonweb.diplomatie.be/en) 签证预约插件 (Browser + Tunnel Mode)
  44. """
  45. def __init__(self, group_id: str):
  46. self.group_id = group_id
  47. self.config: Optional[VSPlgConfig] = None
  48. self.free_config: Dict[str, Any] = {}
  49. self.logger = None
  50. # 浏览器实例
  51. self.page: Optional[ChromiumPage] = None
  52. # 资源隔离
  53. self.instance_id = uuid.uuid4().hex[:8]
  54. self.root_workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"{self.group_id}.{self.instance_id}"))
  55. self.user_data_path = os.path.join(self.root_workspace, "user_data")
  56. if not os.path.exists(self.root_workspace):
  57. os.makedirs(self.root_workspace)
  58. self.applicants = []
  59. self.tunnel = None # 代理隧道
  60. self.is_healthy = True
  61. self.session_create_time: float = 0
  62. def get_group_id(self) -> str:
  63. return self.group_id
  64. def set_log(self, logger: Callable[[str], None]) -> None:
  65. self.logger = logger
  66. def _log(self, message):
  67. if self.logger:
  68. self.logger(f'[PolPlugin] [{self.group_id}] {message}')
  69. else:
  70. print(f'[PolPlugin] [{self.group_id}] {message}')
  71. def set_config(self, config: VSPlgConfig):
  72. self.config = config
  73. self.free_config = config.free_config or {}
  74. def keep_alive(self):
  75. pass
  76. def health_check(self) -> bool:
  77. if not self.is_healthy:
  78. return False
  79. if not self.page:
  80. return False
  81. try:
  82. if not self.page.run_js("return 1;"):
  83. return False
  84. except:
  85. return False
  86. if self.config.session_max_life > 0:
  87. if time.time() - self.session_create_time > self.config.session_max_life * 60:
  88. self._log("Session expired.")
  89. return False
  90. return True
  91. def create_session(self):
  92. """
  93. 创建会话:启动浏览器 -> 代理隧道 -> 提取 Captcha -> 本地识别 -> 提交 -> 获取 Context
  94. """
  95. self._log(f"Initializing Session (ID: {self.instance_id})...")
  96. co = ChromiumOptions()
  97. def get_free_port():
  98. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  99. s.bind(('', 0)); return s.getsockname()[1]
  100. co.set_local_port(get_free_port())
  101. co.set_user_data_path(self.user_data_path)
  102. chrome_path = os.getenv("CHROME_BIN")
  103. if chrome_path and os.path.exists(chrome_path):
  104. co.set_paths(browser_path=chrome_path)
  105. if self.config.proxy and self.config.proxy.ip:
  106. p = self.config.proxy
  107. if p.username and p.password:
  108. self._log(f"Starting Tunnel for {p.ip}...")
  109. self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
  110. local_proxy = self.tunnel.start()
  111. self._log(f"Tunnel started at {local_proxy}")
  112. co.set_argument(f'--proxy-server={local_proxy}')
  113. else:
  114. proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
  115. co.set_argument(f'--proxy-server={proxy_str}')
  116. else:
  117. self._log("[WARN] No proxy configured!")
  118. co.headless(False)
  119. co.set_argument('--no-sandbox')
  120. co.set_argument('--disable-gpu')
  121. co.set_argument('--disable-dev-shm-usage')
  122. co.set_argument('--window-size=1920,1080')
  123. co.set_argument('--disable-blink-features=AutomationControlled')
  124. co.set_argument('--ignore-certificate-errors')
  125. try:
  126. self.page = ChromiumPage(co)
  127. url_home = "https://visaonweb.diplomatie.be/Account/Login?ReturnUrl=%2Fen"
  128. self._log(f"Navigating to {url_home}")
  129. self.page.get(url_home)
  130. self.page.wait.doc_loaded()
  131. self.page.wait.ele_displayed('tag:button@@type=submit', timeout=10)
  132. self.page.ele('tag:input@@name=UserName').input(self.config.account.username)
  133. self.page.ele('tag:input@@name=Password').input(self.config.account.password)
  134. self.page.ele('tag:button@@type=submit').click()
  135. self.page.listen.start('/VisaApplication/MyList')
  136. self.page.get('https://visaonweb.diplomatie.be/en/VisaApplication/IndexByUserId')
  137. packet = self.page.listen.wait(timeout=10)
  138. if not packet:
  139. raise BizLogicError(message='Get userList failed')
  140. self.page.listen.stop()
  141. resp_body = packet.response.body
  142. for dat in resp_body.get('data', []):
  143. if dat.get('St') == 'Submitted':
  144. applicant = {
  145. "id": dat.get('Id'),
  146. "first_name": dat.get('FName'),
  147. "last_name": dat.get('LName'),
  148. "vow_id": dat.get('VOWId'),
  149. "os_id": dat.get('OSId'),
  150. "sub_group_id": dat.get('SubGroupId'),
  151. "vac": dat.get('Vac'),
  152. }
  153. self.applicants.append(applicant)
  154. self.session_create_time = time.time()
  155. self._log("Session created successfully.")
  156. except Exception as e:
  157. self._log(f"Session Create Failed: {e}")
  158. time.sleep(3600)
  159. self.cleanup()
  160. raise e
  161. def query(self, apt_type: AppointmentType) -> VSQueryResult:
  162. res = VSQueryResult()
  163. res.success = False
  164. applicant = random.choice(self.applicants)
  165. applicant_id = applicant.get('id')
  166. url = 'https://visaonweb.diplomatie.be/Common/GetEAppointmentUrl'
  167. params = {
  168. 'id': applicant_id
  169. }
  170. headers = {
  171. "cache-control": "max-age=0",
  172. "if-modified-since": "0",
  173. "x-requested-with": "XMLHttpRequest",
  174. }
  175. resp = self._perform_request('GET', url, headers=headers, params=params)
  176. appointment_url = resp.json().get('url')
  177. tab = self.page.new_tab(appointment_url)
  178. # solve hcaptcha
  179. # sitekey = 5f64399c-14a8-415e-ad1a-7ebccdc4943a
  180. token = self.get_captcha_solution(self.free_config.get('capsolver_key'), '5f64399c-14a8-415e-ad1a-7ebccdc4943a', appointment_url)
  181. script = f'document.querySelector(\'textarea[name="h-captcha-response"]\').value="{token}";'
  182. tab.run_js(script)
  183. time.sleep(5)
  184. self._log(tab.html)
  185. available_dates = []
  186. if available_dates:
  187. res.success = True
  188. res.availability_status = AvailabilityStatus.Available
  189. earliest_date = available_dates[0]
  190. earliest_dt = datetime.strptime(earliest_date, "%Y-%m-%d")
  191. res.earliest_date = earliest_dt
  192. res.availability = [
  193. DateAvailability(
  194. date=datetime.strptime(d, "%Y-%m-%d"),
  195. times=[],
  196. )
  197. for d in available_dates
  198. ]
  199. else:
  200. res.success = False
  201. res.availability_status = AvailabilityStatus.NoneAvailable
  202. res.availability = []
  203. return res
  204. def get_captcha_solution(self, api_key, site_key, site_url):
  205. payload = {
  206. "clientKey": api_key,
  207. "task": {
  208. "type": "HCaptchaTaskProxyLess",
  209. "websiteKey": site_key,
  210. "websiteURL": site_url
  211. }
  212. }
  213. with requests.Session() as session:
  214. res = session.post("https://api.capsolver.com/createTask", json=payload)
  215. task_id = res.json().get("taskId")
  216. if not task_id:
  217. raise Exception("Failed to create task:", res.text)
  218. print("Waiting for CAPTCHA solution...")
  219. while True:
  220. time.sleep(5) # Wait for 5 seconds before checking the result
  221. res = session.post("https://api.capsolver.com/getTaskResult", json={"clientKey": api_key, "taskId": task_id})
  222. result = res.json()
  223. if result.get("status") == "ready":
  224. return result["solution"]["gRecaptchaResponse"]
  225. if result.get("status") == "failed":
  226. raise Exception("CAPTCHA solve failed:", res.text)
  227. def book(self, slot_info: VSQueryResult, user_inputs: Dict) -> VSBookResult:
  228. res = VSBookResult()
  229. return res
  230. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None, retry_count=0):
  231. if not self.page:
  232. raise BizLogicError("Browser not init")
  233. req_url = url
  234. if params:
  235. sep = '&' if '?' in req_url else '?'
  236. req_url += sep + urlencode(params)
  237. fetch_opts = { "method": method.upper(), "headers": headers or {}, "credentials": "include" }
  238. if json_data:
  239. fetch_opts['body'] = json.dumps(json_data)
  240. fetch_opts['headers']['Content-Type'] = 'application/json'
  241. elif data:
  242. if isinstance(data, dict):
  243. fetch_opts['body'] = urlencode(data)
  244. fetch_opts['headers']['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
  245. else:
  246. fetch_opts['body'] = data
  247. js = f"""
  248. return fetch("{req_url}", {json.dumps(fetch_opts)})
  249. .then(async r => {{
  250. const h = {{}}; r.headers.forEach((v, k) => h[k] = v);
  251. return {{ status: r.status, body: await r.text(), headers: h, url: r.url }};
  252. }}).catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
  253. """
  254. resp = BrowserResponse(self.page.run_js(js, timeout=60))
  255. if resp.status_code == 200:
  256. return resp
  257. elif resp.status_code == 403:
  258. if "Just a moment" in resp.text and retry_count < 2:
  259. self._log("Cloudflare 403. Refreshing...")
  260. if self._refresh_firewall_session():
  261. return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
  262. raise PermissionDeniedError(f"HTTP 403: {resp.text[:100]}")
  263. elif resp.status_code == 429:
  264. self.is_healthy = False
  265. raise RateLimiteddError()
  266. elif resp.status_code in [401, 419]:
  267. self.is_healthy = False
  268. raise SessionExpiredOrInvalidError()
  269. else:
  270. raise BizLogicError(f"HTTP {resp.status_code}: {resp.text[:100]}")
  271. def _filter_dates(self, dates, start, end):
  272. if not start or not end: return dates
  273. valid = []
  274. s = datetime.strptime(start[:10], "%Y-%m-%d")
  275. e = datetime.strptime(end[:10], "%Y-%m-%d")
  276. for d in dates:
  277. c = datetime.strptime(d, "%Y-%m-%d")
  278. if s <= c <= e: valid.append(d)
  279. random.shuffle(valid)
  280. return valid
  281. def cleanup(self):
  282. if self.page:
  283. try: self.page.quit()
  284. except: pass
  285. self.page = None
  286. if os.path.exists(self.root_workspace):
  287. for _ in range(3):
  288. try: time.sleep(0.2); shutil.rmtree(self.root_workspace, ignore_errors=True); break
  289. except: time.sleep(0.5)
  290. if self.tunnel:
  291. try: self.tunnel.stop()
  292. except: pass
  293. self.tunnel = None
  294. def __del__(self):
  295. self.cleanup()