vfs_plugin.py 58 KB

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