vfs_plugin.py 58 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427
  1. # plugins/vfs_plugin2.py
  2. import os
  3. import time
  4. import json
  5. import random
  6. import base64
  7. import uuid
  8. import shutil
  9. import re
  10. import socket
  11. import urllib.parse
  12. from datetime import datetime
  13. from typing import Dict, Any, Optional, List, Tuple, Callable
  14. from DrissionPage import ChromiumPage, ChromiumOptions
  15. from cryptography.hazmat.primitives import serialization, hashes
  16. from cryptography.hazmat.primitives.asymmetric import padding
  17. from cryptography.hazmat.backends import default_backend
  18. import configure
  19. from vs_plg import IVSPlg
  20. from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, DateAvailability, AvailabilityStatus, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  21. from toolkit.vs_cloud_api import VSCloudApi
  22. from toolkit.mihomo_tunnel import MihomoTunnel
  23. from toolkit.captcha_breaker import recognize_vfs_captcha_with_qwen
  24. from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
  25. VFS_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
  26. MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuupFgB+lYIOtSxrRoHzc
  27. LmCZKJ6+oSbgqgOPzFMM0TasOeLw0NXEn1XfIzXdx75+tegNKwyIZumoh0yhubKs
  28. t59GV321kN0iquYRHrdh3ygfDDHlS9rROQeBqRga0ncSADtbLMrBPqXJjPCoV76y
  29. t92towriKoH75BhiazY0mghm4LjmAWrV0u/GNpV3tk9bxbtHEXGaFmxCJqjg+7x6
  30. 1e5wXLfvpj9w1QsiSWOSJxLOyICz/9ByxXycQQFdNmjnnnwco9Gt/Mi33NYH71j0
  31. 5oXIjklFC4lvJqaqSY5lS7Vwb9oCt9zX9J0Yz4z4e/3V+0jgRnWOFGofyks4FKe2
  32. GQIDAQAB
  33. -----END PUBLIC KEY-----"""
  34. COUNTRY_MAP = {
  35. "afghanistan": "AFG", "albania": "ALB", "algeria": "DZA", "andorra": "AND", "angola": "AGO",
  36. "antigua and barbuda": "ATG", "argentina": "ARG", "armenia": "ARM", "australia": "AUS", "austria": "AUT",
  37. "azerbaijan": "AZE", "bahamas": "BHS", "bahrain": "BHR", "bangladesh": "BGD", "barbados": "BRB", "belarus": "BLR",
  38. "belgium": "BEL", "belize": "BLZ", "benin": "BEN", "bhutan": "BTN", "bolivia": "BOL", "bosnia and herzegovina": "BIH",
  39. "botswana": "BWA", "brazil": "BRA", "brunei": "BRN", "bulgaria": "BGR", "burkina faso": "BFA", "burundi": "BDI",
  40. "cabo verde": "CPV", "cambodia": "KHM", "cameroon": "CMR", "canada": "CAN", "central african republic": "CAF",
  41. "chad": "TCD", "chile": "CHL", "china": "CHN", "colombia": "COL", "comoros": "COM", "congo (brazzaville)": "COG",
  42. "congo (kinshasa)": "COD", "costa rica": "CRI", "croatia": "HRV", "cuba": "CUB", "cyprus": "CYP", "czech republic": "CZE",
  43. "denmark": "DNK", "djibouti": "DJI", "dominica": "DMA", "dominican republic": "DOM", "ecuador": "ECU", "egypt": "EGY",
  44. "el salvador": "SLV", "equatorial guinea": "GNQ", "eritrea": "ERI", "estonia": "EST", "eswatini": "SWZ", "ethiopia": "ETH",
  45. "fiji": "FJI", "finland": "FIN", "france": "FRA", "gabon": "GAB", "gambia": "GMB", "georgia": "GEO", "germany": "DEU",
  46. "ghana": "GHA", "greece": "GRC", "grenada": "GRD", "guatemala": "GTM", "guinea": "GIN", "guinea-bissau": "GNB", "guyana": "GUY",
  47. "haiti": "HTI", "honduras": "HND", "hungary": "HUN", "iceland": "ISL", "india": "IND", "indonesia": "IDN", "iran": "IRN",
  48. "iraq": "IRQ", "ireland": "IRL", "israel": "ISR", "italy": "ITA", "jamaica": "JAM", "japan": "JPN", "jordan": "JOR",
  49. "kazakhstan": "KAZ", "kenya": "KEN", "kiribati": "KIR", "korea, north": "PRK", "korea, south": "KOR", "kuwait": "KWT",
  50. "kyrgyzstan": "KGZ", "laos": "LAO", "latvia": "LVA", "lebanon": "LBN", "lesotho": "LSO", "liberia": "LBR", "libya": "LBY",
  51. "liechtenstein": "LIE", "lithuania": "LTU", "luxembourg": "LUX", "madagascar": "MDG", "malawi": "MWI", "malaysia": "MYS",
  52. "maldives": "MDV", "mali": "MLI", "malta": "MLT", "marshall islands": "MHL", "mauritania": "MRT", "mauritius": "MUS",
  53. "mexico": "MEX", "micronesia": "FSM", "moldova": "MDA", "monaco": "MCO", "mongolia": "MNG", "montenegro": "MNE", "morocco": "MAR",
  54. "mozambique": "MOZ", "myanmar": "MMR", "namibia": "NAM", "nauru": "NRU", "nepal": "NPL", "netherlands": "NLD", "new zealand": "NZL",
  55. "nicaragua": "NIC", "niger": "NER", "nigeria": "NGA", "north macedonia": "MKD", "norway": "NOR", "oman": "OMN", "pakistan": "PAK",
  56. "palau": "PLW", "panama": "PAN", "papua new guinea": "PNG", "paraguay": "PRY", "peru": "PER", "philippines": "PHL", "poland": "POL",
  57. "portugal": "PRT", "qatar": "QAT", "romania": "ROU", "russia": "RUS", "rwanda": "RWA", "saudi arabia": "SAU", "senegal": "SEN",
  58. "serbia": "SRB", "seychelles": "SYC", "sierra leone": "SLE", "singapore": "SGP", "slovakia": "SVK", "slovenia": "SVN",
  59. "solomon islands": "SLB", "somalia": "SOM", "south africa": "ZAF", "spain": "ESP", "sri lanka": "LKA", "sudan": "SDN",
  60. "suriname": "SUR", "sweden": "SWE", "switzerland": "CHE", "syria": "SYR", "tajikistan": "TJK", "tanzania": "TZA", "thailand": "THA",
  61. "timor-leste": "TLS", "togo": "TGO", "tonga": "TON", "tunisia": "TUN", "turkey": "TUR", "turkmenistan": "TKM", "uganda": "UGA",
  62. "ukraine": "UKR", "united arab emirates": "ARE", "united kingdom": "GBR", "united states": "USA", "uruguay": "URY", "uzbekistan": "UZB",
  63. "vanuatu": "VUT", "venezuela": "VEN", "vietnam": "VNM", "yemen": "YEM", "zambia": "ZMB", "zimbabwe": "ZWE"
  64. }
  65. def get_country_iso3(name: str) -> str:
  66. return COUNTRY_MAP.get(name.lower(), "CHN")
  67. def to_yyyymmdd(data_str: str, date_str_format: str, target_format: str="%Y-%m-%d"):
  68. try:
  69. dt = datetime.strptime(data_str, date_str_format)
  70. return dt.strftime(target_format)
  71. except:
  72. return data_str
  73. def get_alias_email(email: str, new_domain: str = "text.skin") -> str:
  74. """
  75. 将邮箱域名替换为指定域名(默认 text.skin)
  76. """
  77. if "@" not in email:
  78. raise ValueError(f"Invalid email: {email}")
  79. local_part, _ = email.rsplit("@", 1)
  80. return f"{local_part}@{new_domain}"
  81. # --- 模拟 Requests Response 对象 ---
  82. class BrowserResponse:
  83. def __init__(self, result_dict):
  84. result_dict = result_dict or {}
  85. self.status_code = result_dict.get('status', 0)
  86. self.text = result_dict.get('body', '')
  87. self.headers = result_dict.get('headers', {})
  88. self.url = result_dict.get('url', '')
  89. self._json = None
  90. def json(self):
  91. if self._json is None:
  92. if not self.text:
  93. return {}
  94. try:
  95. self._json = json.loads(self.text)
  96. except:
  97. self._json = {}
  98. return self._json
  99. @property
  100. def content(self):
  101. return self.text.encode('utf-8')
  102. class VfsPlugin(IVSPlg):
  103. def __init__(self, group_id: str):
  104. self.group_id = group_id
  105. self.config: Optional[VSPlgConfig] = None
  106. self.free_config: Dict[str, Any] = {}
  107. self.logger = None
  108. self.page: Optional[ChromiumPage] = None
  109. self.jwt_token: str = ""
  110. self.real_ip: str = ""
  111. self.is_healthy: bool = True
  112. self.center_conf = None
  113. self.category_conf: Dict = {}
  114. self.subcategory_conf: Dict = {}
  115. self.booking_wait_applied = False
  116. self.public_key = serialization.load_pem_public_key(
  117. VFS_PUBLIC_KEY_PEM.encode(),
  118. backend=default_backend()
  119. )
  120. self.instance_id = uuid.uuid4().hex[:8]
  121. self.root_workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"{self.group_id}.{self.instance_id}"))
  122. self.user_data_path = os.path.join(self.root_workspace, "user_data")
  123. self.vfs_api_domain = 'lift-api.vfsglobal.com'
  124. if not os.path.exists(self.root_workspace):
  125. os.makedirs(self.root_workspace)
  126. self.tunnel = None
  127. self.session_create_time: float = 0
  128. def set_config(self, config: VSPlgConfig):
  129. self.config = config
  130. self.free_config = config.free_config or {}
  131. if self.free_config.get('country_code') == 'chn':
  132. self.vfs_api_domain = 'lift-apicn.vfsglobal.com'
  133. def set_log(self, logger: Callable[[str], None]) -> None:
  134. self.logger = logger
  135. def _log(self, message):
  136. if self.logger:
  137. self.logger(f'[TlsPlugin] [{self.group_id}] [{self.instance_id}] {message}')
  138. else:
  139. print(f'[TlsPlugin] [{self.group_id}] [{self.instance_id}] {message}')
  140. def keep_alive(self):
  141. try:
  142. keep_alive_funcs = [
  143. self._get_application,
  144. self._query_center,
  145. ]
  146. random.choice(keep_alive_funcs)()
  147. self._log(f'keep_alive request, status ok')
  148. except Exception as e:
  149. self.is_healthy = False
  150. self._log(f'keep_alive failed: {e}')
  151. def health_check(self) -> bool:
  152. if not self.is_healthy:
  153. return False
  154. if self.page is None:
  155. return False
  156. try:
  157. if not self.page.run_js("return 1;"):
  158. return False
  159. except:
  160. return False
  161. if self.config.session_max_life > 0:
  162. current_time = time.time()
  163. elapsed_time = current_time - self.session_create_time
  164. if elapsed_time > self.config.session_max_life:
  165. self._log(f"Session expired.")
  166. return False
  167. return True
  168. def create_session(self) -> None:
  169. """
  170. 使用 DrissionPage 创建会话:
  171. 1. 启动浏览器
  172. 2. 导航到登录页
  173. 3. 自动过盾并提取 Token (集成 CloudflareBypasser)
  174. 4. JS fetch 登录
  175. """
  176. self._log(f"Initializing Session (ID: {self.instance_id})...")
  177. co = ChromiumOptions()
  178. def get_free_port():
  179. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  180. s.bind(('', 0))
  181. return s.getsockname()[1]
  182. debug_port = get_free_port()
  183. self._log(f"Assigned Debug Port: {debug_port}")
  184. self._log(f"Account id={self.config.account.id}, proxy id={self.config.proxy.id}")
  185. co.set_local_port(debug_port)
  186. co.set_user_data_path(self.user_data_path)
  187. chrome_path = configure.CHROME_PATH
  188. if not chrome_path:
  189. chrome_path = os.getenv("CHROME_BIN")
  190. if chrome_path and os.path.exists(chrome_path):
  191. co.set_paths(browser_path=chrome_path)
  192. if self.config.proxy and self.config.proxy.ip:
  193. p = self.config.proxy
  194. if p.username and p.password:
  195. self._log(f"Starting Proxy Tunnel for {p.ip}...")
  196. exit_node = {
  197. "name": "ExitNode",
  198. "type": p.proto,
  199. "server": p.ip,
  200. "port": p.port,
  201. "username": p.username,
  202. "password": p.password
  203. }
  204. relay_node = None
  205. if configure.MIHOMO_RELAY_NODES:
  206. relay_node = random.choice(configure.MIHOMO_RELAY_NODES)
  207. mihomo_path = configure.MIHOMO_BIN_PATH
  208. if not mihomo_path:
  209. mihomo_path = os.getenv("MIHOMO_BIN")
  210. if not mihomo_path:
  211. raise BizLogicError(message='Mihomo path is null, You need set mihomo bin path in configure or os env')
  212. self.tunnel = MihomoTunnel(mihomo_path, exit_node=exit_node, relay_node=relay_node)
  213. local_proxy = self.tunnel.start()
  214. self._log(f"Tunnel started at {local_proxy}")
  215. co.set_argument(f'--proxy-server={local_proxy}')
  216. else:
  217. proxy_str = f"{p.proto}://{p.ip}:{p.port}"
  218. co.set_argument(f'--proxy-server={proxy_str}')
  219. else:
  220. self._log("[WARN] No proxy configured!")
  221. co.headless(False)
  222. co.set_argument('--no-sandbox')
  223. co.set_argument('--disable-gpu')
  224. co.set_argument('--disable-dev-shm-usage')
  225. co.set_argument('--window-size=1920,1080')
  226. co.set_argument('--disable-blink-features=AutomationControlled')
  227. self.page = ChromiumPage(co)
  228. mission = self.free_config.get("mission_code", "")
  229. country = self.free_config.get("country_code", "")
  230. lang = self.free_config.get("language", "en")
  231. if not mission or not country:
  232. raise BizLogicError("Missing mission/country code config")
  233. login_page_url = f"https://visa.vfsglobal.com/{country}/{lang}/{mission}/login"
  234. self._log(f"Navigating to {login_page_url}...")
  235. self.page.get(login_page_url)
  236. self._log("Handling Cloudflare challenge...")
  237. cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
  238. if 'Attention Required! | Cloudflare' in self.page.title and 'Sorry, you have been blocked' in self.page.html:
  239. self._log(f'Block by cloudflare, try refresh...')
  240. self.page.refresh()
  241. self.page.wait.doc_loaded()
  242. time.sleep(random.uniform(3, 5))
  243. cf_bypasser.handle_waiting_room()
  244. cf_token = ""
  245. for i in range(40):
  246. time.sleep(1)
  247. self._handle_cookie_banner()
  248. try:
  249. ele = self.page.ele('@name=cf-turnstile-response', timeout=1)
  250. if ele and ele.value:
  251. cf_token = ele.value
  252. self._log("Cloudflare Turnstile token extracted.")
  253. break
  254. except:
  255. pass
  256. if i > 2:
  257. try:
  258. use_dfs = False
  259. cf_bypasser.click_verification_button(use_dfs)
  260. except Exception as e:
  261. pass
  262. if self.page.ele('tag:form', timeout=0.5) or self.page.ele('#mat-input-0', timeout=0.5):
  263. self._log("Login form detected.")
  264. if i > 5 and not cf_token:
  265. self._log("Form visible but token not found yet...")
  266. if not cf_token:
  267. try:
  268. cf_token = self.page.ele('@name=cf-turnstile-response').value
  269. except:
  270. pass
  271. if not cf_token:
  272. self._log("[WARN] Could not extract Turnstile token.")
  273. raise BizLogicError(f"Could not extract Turnstile token.")
  274. email = self.config.account.username
  275. password = self.config.account.password
  276. enc_password = self._encrypt_password(password)
  277. client_src = self._get_client_source()
  278. orange_src = self._get_orange_source(email)
  279. url = f"https://{self.vfs_api_domain}/user/login"
  280. headers = self._get_common_headers(with_auth=False)
  281. headers.update({
  282. "clientsource": client_src,
  283. "orangex": orange_src
  284. })
  285. data = {
  286. "username": email,
  287. "password": enc_password,
  288. "missioncode": mission,
  289. "countrycode": country,
  290. "languageCode": "en-US",
  291. "captcha_version": "cloudflare-v1",
  292. "captcha_api_key": cf_token
  293. }
  294. now_utc = datetime.utcnow()
  295. sent_at = now_utc.strftime("%Y-%m-%d %H:%M:%S")
  296. self._log("Sending Login Request via Browser Fetch...")
  297. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  298. resp_json = resp.json()
  299. if resp_json.get('accessToken'):
  300. self.jwt_token = resp_json["accessToken"]
  301. self._log("Login successful, JWT obtained.")
  302. elif resp_json.get("enableOTPAuthentication"):
  303. self._log("Login requires OTP.")
  304. otp = self._read_otp_email(sent_at=sent_at)
  305. self._submit_login_otp(cf_token, otp)
  306. else:
  307. raise BizLogicError(f"Login failed: {resp.text[:200]}")
  308. self.session_create_time = time.time()
  309. try:
  310. self.real_ip = self._get_realnetwork_ip()
  311. except:
  312. self.real_ip = self.config.proxy.ip
  313. def query(self, apt_type: AppointmentType) -> VSQueryResult:
  314. """查询可预约 Slot"""
  315. result = VSQueryResult()
  316. apt_config = self.free_config.get("apt_configs", {}).get(apt_type.routing_key)
  317. self._fetch_configurations(apt_config)
  318. query_result = self._query_earliest_slot(apt_config)
  319. result.success = False
  320. result.availability_status = AvailabilityStatus.NoneAvailable
  321. if query_result:
  322. result.success = True
  323. if "WaitList" in query_result:
  324. result.availability_status = AvailabilityStatus.Waitlist
  325. else:
  326. earliest_dt = datetime.strptime(query_result, "%Y-%m-%d")
  327. result.availability_status = AvailabilityStatus.Available
  328. result.earliest_date = earliest_dt
  329. result.availability = [DateAvailability(date=earliest_dt, times=[])]
  330. self._log(f"Slot Found! -> {query_result}")
  331. else:
  332. self._log("No slots available.")
  333. return result
  334. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None, retry_count=0):
  335. """
  336. 核心方法:在 DrissionPage 浏览器上下文中注入 JS 执行 fetch
  337. 并记录详细的 Traffic 日志用于分析
  338. """
  339. if not self.page:
  340. raise BizLogicError("Browser session not initialized")
  341. req_url = url
  342. if params:
  343. sep = '&' if '?' in req_url else '?'
  344. req_url += sep + urllib.parse.urlencode(params)
  345. final_headers = headers or {}
  346. fetch_options = {
  347. "method": method.upper(),
  348. "headers": final_headers,
  349. "credentials": "include" # 关键:带上浏览器 Cookie
  350. }
  351. log_body = "None"
  352. if json_data:
  353. json_str = json.dumps(json_data)
  354. fetch_options['body'] = json_str
  355. fetch_options['headers']['Content-Type'] = 'application/json'
  356. log_body = json_str
  357. elif data:
  358. if isinstance(data, dict):
  359. encoded_data = urllib.parse.urlencode(data)
  360. fetch_options['body'] = encoded_data
  361. fetch_options['headers']['Content-Type'] = 'application/x-www-form-urlencoded'
  362. log_body = encoded_data
  363. else:
  364. fetch_options['body'] = data
  365. log_body = str(data)
  366. self._log(f"┌── [TRAFFIC REQUEST] {method} {req_url}")
  367. self._log(f"├── Headers: {json.dumps(final_headers)}")
  368. self._log(f"└── Body: {log_body}")
  369. js_script = f"""
  370. const url = "{req_url}";
  371. const options = {json.dumps(fetch_options)};
  372. const startTime = Date.now();
  373. return fetch(url, options)
  374. .then(async response => {{
  375. const text = await response.text();
  376. const headers = {{}};
  377. response.headers.forEach((value, key) => headers[key] = value);
  378. const endTime = Date.now();
  379. return {{
  380. status: response.status,
  381. body: text,
  382. headers: headers,
  383. url: response.url,
  384. duration: endTime - startTime
  385. }};
  386. }})
  387. .catch(error => {{
  388. return {{
  389. status: 0,
  390. body: error.toString(),
  391. headers: {{}},
  392. url: url,
  393. duration: Date.now() - startTime
  394. }};
  395. }});
  396. """
  397. try:
  398. res_dict = self.page.run_js(js_script, timeout=60)
  399. except Exception as e:
  400. self._log(f"[TRAFFIC ERROR] JS Execution failed: {e}")
  401. raise BizLogicError(f"Browser JS Execution Error: {e}")
  402. resp = BrowserResponse(res_dict)
  403. duration = res_dict.get('duration', 0)
  404. resp_preview = resp.text[:1000] + "..." if len(resp.text) > 1000 else resp.text
  405. self._log(f"┌── [TRAFFIC RESPONSE] Status: {resp.status_code} | Time: {duration}ms")
  406. self._log(f"└── Body: {resp_preview}")
  407. if resp.status_code == 200:
  408. return resp
  409. elif resp.status_code == 401:
  410. self.is_healthy = False
  411. raise SessionExpiredOrInvalidError(f"401 Unauthorized: {resp.text[:100]}")
  412. elif resp.status_code == 403:
  413. if "Just a moment" in resp.text or "cloudflare" in resp.text.lower():
  414. self._log(f"[TRAFFIC] HTTP 403 (Cloudflare) detected. Re-verifying (Try {retry_count+1}/3)...")
  415. if retry_count < 3:
  416. new_token = self._refresh_turnstile_token()
  417. if new_token:
  418. self._log("[TRAFFIC] In-page verification success. Retrying...")
  419. if json_data and "captcha_api_key" in json_data:
  420. json_data["captcha_api_key"] = new_token
  421. return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
  422. raise PermissionDeniedError(f"HTTP 403 Forbidden: {resp.text[:100]}")
  423. elif resp.status_code == 429:
  424. self.is_healthy = False
  425. raise RateLimiteddError(f"429 Rate Limit: {resp.text[:100]}")
  426. elif resp.status_code == 0:
  427. raise BizLogicError(f"Network Error (Fetch Failed): {resp.text}")
  428. else:
  429. if url.endswith("/login") and resp.status_code == 400:
  430. return resp
  431. raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
  432. def _handle_cookie_banner(self):
  433. """
  434. 处理 OneTrust Cookie 遮挡
  435. 策略:尝试点击“接受所有”,如果点不到就直接移除 DOM
  436. """
  437. try:
  438. js = """
  439. try {
  440. var acceptBtn = document.getElementById('onetrust-accept-btn-handler');
  441. if (acceptBtn) {
  442. acceptBtn.click();
  443. return true;
  444. }
  445. var banner = document.getElementById('onetrust-banner-sdk');
  446. if (banner) {
  447. banner.style.display = 'none';
  448. banner.remove();
  449. return true;
  450. }
  451. } catch(e) {}
  452. return false;
  453. """
  454. self.page.run_js(js)
  455. except:
  456. pass
  457. def _get_realnetwork_ip(self):
  458. """
  459. 通过新建标签页获取 IP
  460. 解决 CORS 403 问题:新标签页请求属于 Top-Level Navigation,
  461. 不带 Origin: visa.vfsglobal.com,也不带 credentials,符合 ipify 规则。
  462. """
  463. try:
  464. tab = self.page.new_tab("https://api.ipify.org/?format=json")
  465. if tab.ele('tag:pre'):
  466. json_text = tab.ele('tag:pre').text
  467. else:
  468. json_text = tab.ele('tag:body').text
  469. ip = json.loads(json_text)['ip']
  470. tab.close()
  471. self._log(f"Real Network IP: {ip}")
  472. return ip
  473. except Exception as e:
  474. self._log(f"[WARN] Failed to check IP via new tab: {e}")
  475. try:
  476. if self.page.tabs_count > 1:
  477. tab.close()
  478. except:
  479. pass
  480. return "0.0.0.0"
  481. def _get_common_headers(self, with_auth=True) -> Dict[str, str]:
  482. # DrissionPage 浏览器会自动带上 Origin, Referer, User-Agent, Sec-CH-UA 等,这里只需要补充业务特定的 Headers
  483. mission = self.free_config.get("mission_code", "")
  484. country = self.free_config.get("country_code", "")
  485. lang = self.free_config.get("language", "en")
  486. route = f"{country}/{lang}/{mission}"
  487. h = {
  488. "accept": "application/json, text/plain, */*",
  489. "route": route
  490. }
  491. h["clientsource"] = self._get_client_source()
  492. if with_auth and self.jwt_token:
  493. h["authorize"] = self.jwt_token
  494. return h
  495. def _encrypt_password(self, password: str) -> str:
  496. ciphertext = self.public_key.encrypt(
  497. password.encode(),
  498. padding.OAEP(
  499. mgf=padding.MGF1(algorithm=hashes.SHA256()),
  500. algorithm=hashes.SHA256(),
  501. label=None
  502. )
  503. )
  504. return base64.b64encode(ciphertext).decode()
  505. def _get_orange_source(self, email: str) -> str:
  506. timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
  507. payload = f"{email};{timestamp}"
  508. return self._encrypt_password(payload)
  509. def _get_client_source(self) -> str:
  510. timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
  511. payload = f"GA;{timestamp}Z"
  512. return self._encrypt_password(payload)
  513. def _query_earliest_slot(self, apt_config) -> Optional[str]:
  514. url = f"https://{self.vfs_api_domain}/appointment/CheckIsSlotAvailable"
  515. data = {
  516. "missioncode": self.free_config.get("mission_code"),
  517. "countrycode": self.free_config.get("country_code"),
  518. "vacCode": apt_config.get("vac_code"),
  519. "visaCategoryCode": apt_config.get("subcategory_code"),
  520. "roleName": "Individual",
  521. "loginUser": self.config.account.username,
  522. "payCode": ""
  523. }
  524. headers = self._get_common_headers(with_auth=True)
  525. resp = self._perform_request("POST", url, headers=headers, json_data=data, retry_count=2)
  526. if "WaitList" in resp.text:
  527. return "WaitList"
  528. j = resp.json()
  529. if j.get("earliestSlotLists"):
  530. raw_date = j["earliestSlotLists"][0]["date"]
  531. return to_yyyymmdd(raw_date, "%m/%d/%Y %H:%M:%S")
  532. return ""
  533. def _fetch_configurations(self, apt_config: Dict[str, Any]):
  534. if not self.center_conf:
  535. self.center_conf = self._query_center()
  536. vac_code = apt_config.get("vac_code")
  537. category_code = apt_config.get("category_code")
  538. if category_code not in self.category_conf:
  539. visa_categories = self._query_visa_category(vac_code)
  540. found = False
  541. for vc in visa_categories:
  542. if vc.get("code") == category_code:
  543. self.category_conf[category_code] = vc
  544. found = True
  545. break
  546. if not found:
  547. self._log(f"WARN: Category {category_code} not found")
  548. sub_category_code = apt_config.get("subcategory_code")
  549. if sub_category_code not in self.subcategory_conf:
  550. visa_subcategories = self._query_visa_sub_category(vac_code, category_code)
  551. found = False
  552. for svc in visa_subcategories:
  553. if svc.get("code") == sub_category_code:
  554. self.subcategory_conf[sub_category_code] = svc
  555. found = True
  556. break
  557. if not found:
  558. self._log(f"WARN: SubCategory {sub_category_code} not found")
  559. def _query_center(self) -> List:
  560. mission = self.free_config.get("mission_code")
  561. country = self.free_config.get("country_code")
  562. url = f"https://{self.vfs_api_domain}/master/center/{mission}/{country}/en-US"
  563. headers = self._get_common_headers(with_auth=False)
  564. resp = self._perform_request("GET", url, headers=headers)
  565. return resp.json()
  566. def _query_visa_category(self, center_code: str) -> List:
  567. mission = self.free_config.get("mission_code")
  568. country = self.free_config.get("country_code")
  569. enc_center = urllib.parse.quote(center_code)
  570. url = f"https://{self.vfs_api_domain}/master/visacategory/{mission}/{country}/{enc_center}/en-US"
  571. headers = self._get_common_headers(with_auth=False)
  572. resp = self._perform_request("GET", url, headers=headers)
  573. return resp.json()
  574. def _query_visa_sub_category(self, center_code: str, category_code: str) -> List:
  575. mission = self.free_config.get("mission_code")
  576. country = self.free_config.get("country_code")
  577. enc_center = urllib.parse.quote(center_code)
  578. enc_cat = urllib.parse.quote(category_code)
  579. url = f"https://{self.vfs_api_domain}/master/subvisacategory/{mission}/{country}/{enc_center}/{enc_cat}/en-US"
  580. headers = self._get_common_headers(with_auth=False)
  581. resp = self._perform_request("GET", url, headers=headers)
  582. return resp.json()
  583. def _read_otp_email(self, sent_at='', sender='VFS Global') -> str:
  584. # 保持原样,这部分使用云API读取邮件,不依赖本地网络库
  585. master_email = "hujiarui8@gmail.com"
  586. recipient = self.config.account.username
  587. subject_keywords = "One Time Password"
  588. body_keywords = "OTP"
  589. if not sent_at:
  590. now_utc = datetime.utcnow()
  591. sent_at = now_utc.strftime("%Y-%m-%d %H:%M:%S")
  592. self._log(f"Waiting for OTP email...")
  593. content_out = VSCloudApi.Instance().fetch_mail_content(
  594. master_email, sender, recipient, subject_keywords, body_keywords, sent_at, 300, only_text=False
  595. )
  596. if content_out:
  597. parts = content_out.split("\n")
  598. text_parts = [p for p in parts if not p.startswith("data:image")]
  599. email_text = " ".join(text_parts)
  600. image_urls = [p for p in parts if p.startswith("data:image")]
  601. match = re.search(r'\b\d{6}\b', email_text)
  602. if match:
  603. self._log("Found OTP from email text via Regex.")
  604. return match.group(0)
  605. if image_urls:
  606. self._log("OTP not found in text, trying LLM image recognition...")
  607. target_image_b64 = image_urls[0]
  608. otp_code = recognize_vfs_captcha_with_qwen(target_image_b64)
  609. if otp_code:
  610. self._log(f"Successfully extracted OTP from image: {otp_code}")
  611. return otp_code
  612. raise BizLogicError(message="OTP code not found")
  613. def _submit_login_otp(self, old_cf_token: str, otp: str):
  614. self._log("Submitting Login OTP...")
  615. new_cf_token = self._refresh_turnstile_token()
  616. email = self.config.account.username
  617. password = self.config.account.password
  618. enc_password = self._encrypt_password(password)
  619. mission = self.free_config.get("mission_code", "")
  620. country = self.free_config.get("country_code", "")
  621. client_src = self._get_client_source()
  622. orange_src = self._get_orange_source(email)
  623. url = f"https://{self.vfs_api_domain}/user/login"
  624. headers = self._get_common_headers(with_auth=False)
  625. headers.update({
  626. "clientsource": client_src,
  627. "orangex": orange_src
  628. })
  629. data = {
  630. "username": email,
  631. "password": enc_password,
  632. "missioncode": mission,
  633. "countrycode": country,
  634. "languageCode": "en-US",
  635. "captcha_version": "cloudflare-v1",
  636. "captcha_api_key": new_cf_token,
  637. "otp": otp
  638. }
  639. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  640. resp_json = resp.json()
  641. if resp_json.get("accessToken"):
  642. self.jwt_token = resp_json["accessToken"]
  643. self._log("OTP Login successful.")
  644. return
  645. error_desc = resp_json.get("description", resp.text)
  646. raise PermissionDeniedError(message=f"OTP Login Failed: {error_desc}")
  647. def _refresh_turnstile_token(self) -> str:
  648. """
  649. 强制刷新 Cloudflare Turnstile 并获取新 Token (集成 CloudflareBypasser 版)
  650. """
  651. self._log("Refreshing Cloudflare Turnstile token...")
  652. js_reset = """
  653. try {
  654. var input = document.querySelector('input[name="cf-turnstile-response"]');
  655. if (input) input.value = "";
  656. window.turnstile.reset();
  657. } catch(e) {
  658. console.log("Turnstile reset error:", e);
  659. }
  660. """
  661. self.page.run_js(js_reset)
  662. cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
  663. for i in range(60):
  664. time.sleep(0.5)
  665. try:
  666. ele = self.page.ele('@name=cf-turnstile-response', timeout=1)
  667. if ele and ele.value:
  668. self._log("Turnstile token refreshed successfully.")
  669. return ele.value
  670. except:
  671. pass
  672. if i > 4:
  673. self._handle_cookie_banner()
  674. try:
  675. use_dfs = False
  676. cf_bypasser.click_verification_button(use_dfs)
  677. except Exception as e:
  678. pass
  679. raise BizLogicError("Failed to refresh Cloudflare Turnstile token (Timeout)")
  680. def book(self, slot_info: VSQueryResult, user_inputs) -> VSBookResult:
  681. """
  682. 执行完整的预约流程
  683. """
  684. self._log("Starting booking process...")
  685. user_email = user_inputs.get('email')
  686. user_inputs['alias_email'] = get_alias_email(user_email, new_domain="text.skin")
  687. res = VSBookResult()
  688. app_type = slot_info.apt_type
  689. from_date = slot_info.earliest_date.strftime("%Y-%m-%d") if slot_info.earliest_date else datetime.now().strftime("%Y-%m-%d")
  690. expected_start = user_inputs.get("expected_start_date", "2000-01-01")
  691. expected_end = user_inputs.get("expected_end_date", "2100-01-01")
  692. if from_date >= expected_end:
  693. self._log("No valid slots in date preference.")
  694. res.success = False
  695. return res
  696. apt_config = self.free_config.get("apt_configs", {}).get(app_type.routing_key)
  697. if not apt_config:
  698. raise NotFoundError(message="Book: Config missing for this routing key.")
  699. self._fetch_configurations(apt_config)
  700. sub_cc = apt_config.get("subcategory_code")
  701. sub_conf = self.subcategory_conf.get(sub_cc, {})
  702. ocr_enabled = sub_conf.get("isOCREnable", False)
  703. if ocr_enabled:
  704. self._log("OCR Enabled, uploading documents...")
  705. upload_res = self._upload_applicant_documents(apt_config, user_inputs)
  706. user_inputs["applicant_image"] = upload_res.get("passportImageFilename")
  707. user_inputs["applicant_image_data"] = upload_res.get("passportImageFileBytes")
  708. user_inputs["guid"] = upload_res.get("uploadDocumentGUID")
  709. enable_reference_number = sub_conf.get("enableReferenceNumber", False)
  710. final_urn = None
  711. is_waitlist = (slot_info.availability_status == AvailabilityStatus.Waitlist)
  712. if not self.booking_wait_applied:
  713. self._log("pre-booking wait: sleeping 20s before booking to avoid risk control")
  714. time.sleep(20)
  715. self.booking_wait_applied = True
  716. MAX_RETRY = 2
  717. for i in range(MAX_RETRY):
  718. try:
  719. final_urn = self._add_primary_applicant(apt_config, user_inputs, is_waitlist, ocr_enabled, enable_reference_number)
  720. break
  721. except BizLogicError as e:
  722. err_msg = str(e)
  723. self._log(f"Add Applicant retry {i+1}/{MAX_RETRY}: {err_msg}")
  724. if 'Capping has exceeded' in err_msg:
  725. raise e
  726. time.sleep(10.0)
  727. if not final_urn:
  728. raise BizLogicError(message="Failed to add primary applicant (Slot likely taken or API error)")
  729. self._log(f"Applicant Added. URN: {final_urn}")
  730. otp_enabled = sub_conf.get("isApplicantOTPEnabled", False)
  731. if otp_enabled:
  732. self._log("Applicant OTP Required.")
  733. now_utc = datetime.utcnow()
  734. sent_at = now_utc.strftime("%Y-%m-%d %H:%M:%S")
  735. if not self._applicant_otp_send(apt_config, final_urn):
  736. raise BizLogicError(message='Applicant OTP send failed')
  737. otp_code = self._read_otp_email(sent_at=sent_at)
  738. if not self._applicant_otp_verify(apt_config, final_urn, otp_code):
  739. raise BizLogicError(message='Applicant OTP verify failed')
  740. if is_waitlist:
  741. if self._confirm_waitlist(apt_config, final_urn):
  742. res.success = True
  743. res.urn = final_urn
  744. res.account = self.config.account.username
  745. self._log("Waitlist confirmed.")
  746. return res
  747. raise BizLogicError(message='Confirm waitlist failed')
  748. months = self._get_filtered_covered_months(expected_start, expected_end, from_date)
  749. self._log(f"Scanning months: {months} (Start looking from: {from_date})")
  750. selected_slot_id = ""
  751. selected_slot_date = ""
  752. selected_slot_time_range = ""
  753. all_ads = set()
  754. forbidden_dates = set()
  755. found_slot = False
  756. for m_str in months:
  757. self._log(f"Checking calendar for {m_str}...")
  758. ads = self._query_slot_calendar(apt_config, final_urn, m_str)
  759. new_ads = [d for d in ads if d not in all_ads]
  760. all_ads.update(new_ads)
  761. for _ in range(3):
  762. avail_candidates = [d for d in list(all_ads) if d not in forbidden_dates]
  763. sel_dates = self._filter_dates(avail_candidates, expected_start, expected_end)
  764. if not sel_dates:
  765. break
  766. tmp_date = sel_dates[0]
  767. forbidden_dates.add(tmp_date)
  768. if not self._saveuseractionaudit(apt_config, final_urn, tmp_date):
  769. self._log(f"Audit failed for {tmp_date}, skipping...")
  770. time.sleep(1)
  771. continue
  772. ats = self._query_slot_time(apt_config, final_urn, tmp_date)
  773. if not ats:
  774. self._log(f"No timeslots for {tmp_date}")
  775. continue
  776. sel_tm = random.choice(ats)
  777. selected_slot_id = sel_tm.get("allocationId")
  778. selected_slot_date = tmp_date
  779. selected_slot_time_range = sel_tm.get("slot")
  780. found_slot = True
  781. break
  782. if found_slot:
  783. break
  784. if not found_slot:
  785. self._log("No valid slots found after scanning.")
  786. res.success = False
  787. return res
  788. self._log(f"Slot Selected: {selected_slot_date} {selected_slot_time_range} (ID: {selected_slot_id})")
  789. self._submit_no_addition_service(final_urn)
  790. amount, currency = self._query_fee(apt_config, final_urn)
  791. self._log("Submitting schedule...")
  792. schedule_res = self._schedule(apt_config, final_urn, amount, currency, selected_slot_id)
  793. if not schedule_res.get("IsAppointmentBooked"):
  794. self._log(f"Booking failed: {schedule_res}")
  795. res.success = False
  796. return res
  797. res.success = True
  798. res.account = self.config.account.username
  799. res.book_date = selected_slot_date
  800. res.book_time = selected_slot_time_range
  801. res.urn = final_urn
  802. if schedule_res.get("IsPaymentRequired", False):
  803. payload = schedule_res.get("payLoad", "")
  804. if payload:
  805. self._log("Processing payment link...")
  806. payment_url = self._pay_request(payload)
  807. if payment_url:
  808. res.payment_link = payment_url
  809. return res
  810. def _get_application(self):
  811. url = f"https://{self.vfs_api_domain}/appointment/application"
  812. headers = self._get_common_headers(with_auth=True)
  813. data = {
  814. 'countryCode': self.free_config.get("country_code"),
  815. 'missionCode': self.free_config.get("mission_code"),
  816. 'loginUser': self.config.account.username,
  817. 'languageCode': 'en-US',
  818. }
  819. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  820. return resp.json()
  821. def _upload_applicant_documents(self, apt_config, user_inputs) -> Dict:
  822. """上传图片:根据前缀判断是下载外部图片还是读取本地文件,并上传到 VFS"""
  823. import requests as standard_requests
  824. url = f"https://{self.vfs_api_domain}/appointment/UploadApplicantDocument"
  825. passport_path_or_url = user_inputs.get("passport_image_url")
  826. if not passport_path_or_url:
  827. raise NotFoundError(message="Missing passport_image_url")
  828. if passport_path_or_url.startswith("http://") or passport_path_or_url.startswith("https://"):
  829. img_resp = standard_requests.get(passport_path_or_url, timeout=30)
  830. if img_resp.status_code != 200:
  831. raise BizLogicError(message=f"Failed to download passport image: {img_resp.status_code}")
  832. img_data = img_resp.content
  833. else:
  834. if not os.path.exists(passport_path_or_url):
  835. raise BizLogicError(message=f"Local passport image file not found: {passport_path_or_url}")
  836. with open(passport_path_or_url, "rb") as f:
  837. img_data = f.read()
  838. b64_str = base64.b64encode(img_data).decode('utf-8')
  839. headers = self._get_common_headers(with_auth=True)
  840. data = {
  841. "missioncode": self.free_config.get("mission_code"),
  842. "countryCode": self.free_config.get("country_code"),
  843. "centerCode": apt_config.get("vac_code"),
  844. "loginUser": self.config.account.username,
  845. "languageCode": "en-US",
  846. "visaCategoryCode": apt_config.get("subcategory_code"),
  847. "fileBytes": b64_str,
  848. "selfiImageFileBytes": ""
  849. }
  850. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  851. result = resp.json()
  852. result["passportImageFilename"] = "passport_img.jpg"
  853. result["passportImageFileBytes"] = b64_str
  854. return result
  855. def _add_primary_applicant(self, apt_config: Dict[str, Any], user_inputs: Dict[str, Any],
  856. is_waitlist: bool, ocr_enabled: bool, enable_ref: bool) -> str:
  857. """构造申请人 payload 并提交"""
  858. url = f"https://{self.vfs_api_domain}/appointment/applicants"
  859. headers = self._get_common_headers(with_auth=True)
  860. gender_str = str(user_inputs.get("gender", "")).lower()
  861. gender_code = 1 if gender_str == "male" else 2
  862. raw_dial = user_inputs.get("phone_country_code", "86")
  863. dial_code = str(raw_dial)
  864. # 日期格式转换 YYYY-MM-DD -> DD/MM/YYYY
  865. def _to_ddmmyyyy(d_str):
  866. try:
  867. return datetime.strptime(str(d_str), "%Y-%m-%d").strftime("%d/%m/%Y")
  868. except:
  869. return str(d_str)
  870. dob = _to_ddmmyyyy(user_inputs.get("birthday", ""))
  871. ppt_exp = _to_ddmmyyyy(user_inputs.get("passport_expiry_date", ""))
  872. applicant = {
  873. "urn": "",
  874. "arn": "",
  875. "loginUser": self.config.account.username,
  876. "firstName": str(user_inputs.get("first_name", "")).strip().upper(),
  877. "middleName": "",
  878. "lastName": str(user_inputs.get("last_name", "")).strip().upper(),
  879. "employerFirstName": "",
  880. "employerLastName": "",
  881. "salutation": "",
  882. "Subclasscode": None,
  883. "VisaToken": None,
  884. "centerClassCode": None,
  885. "dateOfApplication": None,
  886. "selectedSubvisaCategory": None,
  887. "gender": gender_code,
  888. "contactNumber": str(user_inputs.get("phone", "")).strip().lstrip("0"),
  889. "dialCode": dial_code,
  890. "employerContactNumber": "",
  891. "employerDialCode": "",
  892. "emailId": str(user_inputs.get("alias_email", "")).strip().upper(),
  893. "employerEmailId": "",
  894. "passportNumber": str(user_inputs.get("passport_no", "")).strip().upper(),
  895. "confirmPassportNumber": "",
  896. "passportExpirtyDate": ppt_exp,
  897. "dateOfBirth": dob,
  898. "nationalId": None,
  899. "nationalityCode": get_country_iso3(str(user_inputs.get("nationality", ""))),
  900. "state": user_inputs.get("state"),
  901. "city": user_inputs.get("city"),
  902. "addressline1": user_inputs.get("addressline1"),
  903. "addressline2": user_inputs.get("addressline2"),
  904. "pincode": None,
  905. "isEndorsedChild": False,
  906. "applicantType": 0,
  907. "vlnNumber": None,
  908. "applicantGroupId": 0,
  909. "parentPassportNumber": "",
  910. "parentPassportExpiry": "",
  911. "dateOfDeparture": None,
  912. "entryType": "",
  913. "eoiVisaType": "",
  914. "passportType": "",
  915. "vfsReferenceNumber": "",
  916. "familyReunificationCerificateNumber": "",
  917. "PVRequestRefNumber": "",
  918. "PVStatus": "",
  919. "PVStatusDescription": "",
  920. "PVCanAllowRetry": True,
  921. "PVisVerified": False,
  922. "eefRegistrationNumber": "",
  923. "isAutoRefresh": True,
  924. "helloVerifyNumber": "",
  925. "OfflineCClink": "",
  926. "idenfystatuscheck": False,
  927. "vafStatus": None,
  928. "SpecialAssistance": "",
  929. "AdditionalRefNo": None,
  930. "juridictionCode": "",
  931. "canInitiateVAF": False,
  932. "canEditVAF": False,
  933. "canDeleteVAF": False,
  934. "canDownloadVAF": False,
  935. "Retryleft": "",
  936. # 这里的 IP 应该已经在 create_session 时获取到了
  937. "ipAddress": self.real_ip
  938. }
  939. if enable_ref:
  940. applicant["referenceNumber"] = str(user_inputs.get("cover_letter_id", "")).strip()
  941. else:
  942. applicant["referenceNumber"] = None
  943. if ocr_enabled:
  944. applicant["applicantImage"] = str(user_inputs.get("applicant_image", ""))
  945. applicant["applicantImageData"] = str(user_inputs.get("applicant_image_data", ""))
  946. applicant["GUID"] = str(user_inputs.get("guid", ""))
  947. payload = {
  948. "countryCode": self.free_config.get("country_code"),
  949. "missionCode": self.free_config.get("mission_code"),
  950. "centerCode": apt_config.get("vac_code"),
  951. "loginUser": self.config.account.username,
  952. "visaCategoryCode": apt_config.get("subcategory_code"),
  953. "applicantList": [applicant],
  954. "languageCode": "en-US",
  955. "isWaitlist": is_waitlist,
  956. "isEdit": False,
  957. "feeEntryTypeCode": None, "feeExemptionTypeCode": None,
  958. "feeExemptionDetailsCode": None, "juridictionCode": None, "regionCode": None
  959. }
  960. resp = self._perform_request("POST", url, headers=headers, json_data=payload)
  961. urn = resp.json().get("urn")
  962. if not urn:
  963. err_msg = resp.json().get('error')
  964. raise BizLogicError(message=str(err_msg))
  965. return urn
  966. def _applicant_otp_send(self, apt_config, urn) -> bool:
  967. url = f"https://{self.vfs_api_domain}/appointment/applicantotp"
  968. headers = self._get_common_headers(with_auth=True)
  969. data = {
  970. "urn": urn,
  971. "loginUser": self.config.account.username,
  972. "missionCode": self.free_config.get("mission_code"),
  973. "countryCode": self.free_config.get("country_code"),
  974. "centerCode": apt_config.get("vac_code"),
  975. "OTP": "",
  976. "otpAction": "GENERATE",
  977. "languageCode": "en-US"
  978. }
  979. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  980. return resp.json().get("isOTPGenerated", False)
  981. def _applicant_otp_verify(self, apt_config, urn, otp) -> bool:
  982. url = f"https://{self.vfs_api_domain}/appointment/applicantotp"
  983. headers = self._get_common_headers(with_auth=True)
  984. # VFS 这里的 header 有时需要 datacenter,原代码有就加上
  985. headers["datacenter"] = "GERMANY"
  986. data = {
  987. "urn": urn,
  988. "loginUser": self.config.account.username,
  989. "missionCode": self.free_config.get("mission_code"),
  990. "countryCode": self.free_config.get("country_code"),
  991. "centerCode": apt_config.get("vac_code"),
  992. "OTP": otp,
  993. "otpAction": "VALIDATE",
  994. "languageCode": "en-US"
  995. }
  996. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  997. return resp.json().get("isOTPValidated", False)
  998. def _query_slot_calendar(self, apt_config, urn, from_date) -> List:
  999. url = f"https://{self.vfs_api_domain}/appointment/calendar"
  1000. headers = self._get_common_headers(with_auth=True)
  1001. # 将 YYYY-MM-DD 转为 DD/MM/YYYY 用于 API
  1002. dt_m = datetime.strptime(from_date, "%Y-%m-%d")
  1003. converted_date = dt_m.strftime("%d/%m/%Y")
  1004. data = {
  1005. "missionCode": self.free_config.get("mission_code"),
  1006. "countryCode": self.free_config.get("country_code"),
  1007. "centerCode": apt_config.get("vac_code"),
  1008. "loginUser": self.config.account.username,
  1009. "visaCategoryCode": apt_config.get("subcategory_code"),
  1010. "fromDate": converted_date,
  1011. "urn": urn,
  1012. "payCode": ""
  1013. }
  1014. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  1015. calendars = resp.json().get("calendars")
  1016. ads_out = []
  1017. if calendars:
  1018. for item in calendars:
  1019. # 通常是 MM/DD/YYYY
  1020. raw = item.get("date")
  1021. ads_out.append(to_yyyymmdd(raw, "%m/%d/%Y"))
  1022. return ads_out
  1023. def _query_slot_time(self, apt_config, urn, slot_date) -> List:
  1024. url = f"https://{self.vfs_api_domain}/appointment/timeslot"
  1025. headers = self._get_common_headers(with_auth=True)
  1026. dt_m = datetime.strptime(slot_date, "%Y-%m-%d")
  1027. converted_date = dt_m.strftime("%d/%m/%Y")
  1028. data = {
  1029. "missionCode": self.free_config.get("mission_code"),
  1030. "countryCode": self.free_config.get("country_code"),
  1031. "centerCode": apt_config.get("vac_code"),
  1032. "loginUser": self.config.account.username,
  1033. "visaCategoryCode": apt_config.get("subcategory_code"),
  1034. "slotDate": converted_date,
  1035. "urn": urn
  1036. }
  1037. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  1038. return resp.json().get("slots", [])
  1039. def _saveuseractionaudit(self, apt_config, urn, earliest_date) -> bool:
  1040. url = f"https://{self.vfs_api_domain}/appointment/saveuseractionaudit"
  1041. headers = self._get_common_headers(with_auth=True)
  1042. dt = datetime.strptime(earliest_date, "%Y-%m-%d")
  1043. data = {
  1044. "missionCode": self.free_config.get("mission_code"),
  1045. "countryCode": self.free_config.get("country_code"),
  1046. "centerCode": apt_config.get("vac_code"),
  1047. "loginUser": self.config.account.username,
  1048. "urn": urn,
  1049. "firstEarliestSlotDate": dt.strftime("%d/%m/%Y"),
  1050. "action": "schedule",
  1051. "ipAddress": self.real_ip,
  1052. "eadAppointmentDetail": dt.strftime("%Y-%m-%dT%H:%M:%S")
  1053. }
  1054. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  1055. return resp.json().get("isSavedSuccess", False)
  1056. def _submit_no_addition_service(self, urn):
  1057. url = f"https://{self.vfs_api_domain}/vas/mapvas"
  1058. headers = self._get_common_headers(with_auth=True)
  1059. data = {
  1060. "loginUser": self.config.account.username,
  1061. "missionCode": self.free_config.get("mission_code"),
  1062. "countryCode": self.free_config.get("country_code"),
  1063. "urn": urn,
  1064. "applicants": []
  1065. }
  1066. self._perform_request("POST", url, headers=headers, json_data=data)
  1067. def _query_fee(self, apt_config, urn) -> Tuple[float, str]:
  1068. url = f"https://{self.vfs_api_domain}/appointment/fees"
  1069. headers = self._get_common_headers(with_auth=True)
  1070. data = {
  1071. "missionCode": self.free_config.get("mission_code"),
  1072. "countryCode": self.free_config.get("country_code"),
  1073. "centerCode": apt_config.get("vac_code"),
  1074. "loginUser": self.config.account.username,
  1075. "urn": urn,
  1076. "languageCode": "en-US"
  1077. }
  1078. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  1079. j = resp.json()
  1080. total = j.get("totalamount", 0.0)
  1081. currency = "EUR"
  1082. if j.get("feeDetails"):
  1083. currency = j["feeDetails"][0].get("currency", "EUR")
  1084. return total, currency
  1085. def _schedule(self, apt_config, urn, amount, currency, slot_id) -> Dict:
  1086. url = f"https://{self.vfs_api_domain}/appointment/schedule"
  1087. headers = self._get_common_headers(with_auth=True)
  1088. data = {
  1089. "missionCode": self.free_config.get("mission_code"),
  1090. "countryCode": self.free_config.get("country_code"),
  1091. "centerCode": apt_config.get("vac_code"),
  1092. "loginUser": self.config.account.username,
  1093. "urn": urn,
  1094. "notificationType": "none",
  1095. "paymentdetails": {
  1096. "paymentmode": "Online",
  1097. "RequestRefNo": "",
  1098. "clientId": "",
  1099. "merchantId": "",
  1100. "amount": amount,
  1101. "currency": currency
  1102. },
  1103. "allocationId": str(slot_id),
  1104. "CanVFSReachoutToApplicant": True
  1105. }
  1106. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  1107. return resp.json()
  1108. def _pay_request(self, payload) -> str:
  1109. """
  1110. 解析支付重定向 URL (DrissionPage 新标签页版)
  1111. """
  1112. start_url = f"https://online.vfsglobal.com/PG-Component/Payment/PayRequest?payLoad={payload}"
  1113. final_url = ""
  1114. try:
  1115. self._log("Resolving payment redirect...")
  1116. pay_tab = self.page.new_tab(start_url)
  1117. pay_tab.wait.url_change(start_url, timeout=15)
  1118. final_url = pay_tab.url
  1119. self._log(f"Payment URL resolved: {final_url}")
  1120. pay_tab.close()
  1121. except Exception as e:
  1122. self._log(f"[WARN] Failed to resolve payment URL: {e}")
  1123. try:
  1124. pay_tab.close()
  1125. except:
  1126. pass
  1127. return final_url
  1128. def _confirm_waitlist(self, apt_config: Dict[str, Any], urn: str) -> bool:
  1129. url = f"https://{self.vfs_api_domain}/appointment/ConfirmWaitlist"
  1130. headers = self._get_common_headers(with_auth=True)
  1131. data = {
  1132. "missionCode": self.free_config.get("mission_code"),
  1133. "countryCode": self.free_config.get("country_code"),
  1134. "centerCode": apt_config.get("vac_code"),
  1135. "loginUser": self.config.account.username,
  1136. "urn": urn,
  1137. "notificationType": "none",
  1138. "CanVFSReachoutToApplicant": True
  1139. }
  1140. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  1141. return resp.json().get("isConfirmed", False)
  1142. def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
  1143. if not start_str or not end_str:
  1144. return dates
  1145. valid_dates = []
  1146. try:
  1147. s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
  1148. e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
  1149. for date_str in dates:
  1150. curr_date = datetime.strptime(date_str, "%Y-%m-%d")
  1151. if s_date <= curr_date <= e_date:
  1152. valid_dates.append(date_str)
  1153. random.shuffle(valid_dates)
  1154. return valid_dates
  1155. except:
  1156. return dates
  1157. def _get_filtered_covered_months(self, start_date, end_date, from_date) -> List[str]:
  1158. fmt = "%Y-%m-%d"
  1159. try:
  1160. dt_start = datetime.strptime(start_date, fmt) if start_date else datetime.now()
  1161. dt_end = datetime.strptime(end_date, fmt) if end_date else datetime.now().replace(year=datetime.now().year + 1)
  1162. try:
  1163. dt_from = datetime.strptime(from_date, fmt)
  1164. except:
  1165. dt_from = datetime.now()
  1166. except:
  1167. return []
  1168. dt_start = dt_start.replace(day=1)
  1169. dt_end = dt_end.replace(day=1)
  1170. dt_from = dt_from.replace(day=1)
  1171. curr = max(dt_start, dt_from)
  1172. months = []
  1173. while curr <= dt_end:
  1174. months.append(curr.strftime(fmt))
  1175. if curr.month == 12:
  1176. curr = curr.replace(year=curr.year + 1, month=1)
  1177. else:
  1178. curr = curr.replace(month=curr.month + 1)
  1179. return months
  1180. def cleanup(self):
  1181. """
  1182. 销毁浏览器并彻底删除临时文件
  1183. """
  1184. if self.page:
  1185. try:
  1186. self.page.quit()
  1187. except Exception:
  1188. pass
  1189. self.page = None
  1190. if os.path.exists(self.root_workspace):
  1191. for _ in range(3):
  1192. try:
  1193. time.sleep(0.2)
  1194. shutil.rmtree(self.root_workspace, ignore_errors=True)
  1195. break
  1196. except Exception as e:
  1197. self._log(f"Cleanup retry: {e}")
  1198. time.sleep(0.5)
  1199. if os.path.exists(self.root_workspace):
  1200. self._log(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
  1201. if self.tunnel:
  1202. try: self.tunnel.stop()
  1203. except: pass
  1204. self.tunnel = None
  1205. def __del__(self):
  1206. """
  1207. 析构函数:当对象被垃圾回收时自动调用
  1208. """
  1209. self.cleanup()