vfs_plugin2.py 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376
  1. # plugins/vfs_plugin2.py
  2. import os
  3. import time
  4. import json
  5. import random
  6. import base64
  7. import re
  8. import urllib.parse
  9. from datetime import datetime
  10. from typing import Dict, Any, Optional, List, Tuple, Callable
  11. # DrissionPage 核心引入
  12. from DrissionPage import ChromiumPage, ChromiumOptions
  13. from DrissionPage.common import Settings
  14. # 加密库
  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. from vs_plg import IVSPlg
  19. from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, DateAvailability, AvailabilityStatus, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  20. from toolkit.vs_cloud_api import VSCloudApi
  21. # ----------------- 静态常量与辅助数据 -----------------
  22. VFS_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
  23. MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuupFgB+lYIOtSxrRoHzc
  24. LmCZKJ6+oSbgqgOPzFMM0TasOeLw0NXEn1XfIzXdx75+tegNKwyIZumoh0yhubKs
  25. t59GV321kN0iquYRHrdh3ygfDDHlS9rROQeBqRga0ncSADtbLMrBPqXJjPCoV76y
  26. t92towriKoH75BhiazY0mghm4LjmAWrV0u/GNpV3tk9bxbtHEXGaFmxCJqjg+7x6
  27. 1e5wXLfvpj9w1QsiSWOSJxLOyICz/9ByxXycQQFdNmjnnnwco9Gt/Mi33NYH71j0
  28. 5oXIjklFC4lvJqaqSY5lS7Vwb9oCt9zX9J0Yz4z4e/3V+0jgRnWOFGofyks4FKe2
  29. GQIDAQAB
  30. -----END PUBLIC KEY-----"""
  31. # (Country Map 省略以节省篇幅,请保持原样)
  32. COUNTRY_MAP = {
  33. "afghanistan": "AFG", "albania": "ALB", "algeria": "DZA", "andorra": "AND", "angola": "AGO",
  34. "china": "CHN", "united kingdom": "GBR", "netherlands": "NLD",
  35. # ... 请保留你原来的完整映射 ...
  36. }
  37. def get_country_iso3(name: str) -> str:
  38. return COUNTRY_MAP.get(name.lower(), "CHN")
  39. def to_yyyymmdd(data_str: str, date_str_format: str, target_format: str="%Y-%m-%d"):
  40. try:
  41. dt = datetime.strptime(data_str, date_str_format)
  42. return dt.strftime(target_format)
  43. except:
  44. return data_str
  45. def create_proxy_auth_extension(ip, port, username, password, plugin_path="./chrome_proxy_auth_plugin"):
  46. """
  47. 创建一个 Chrome 插件来自动处理代理认证
  48. """
  49. if not os.path.exists(plugin_path):
  50. os.makedirs(plugin_path)
  51. # 1. manifest.json
  52. manifest_json = """
  53. {
  54. "version": "1.0.0",
  55. "manifest_version": 2,
  56. "name": "Chrome Proxy Auth Extension",
  57. "permissions": [
  58. "proxy",
  59. "tabs",
  60. "unlimitedStorage",
  61. "storage",
  62. "<all_urls>",
  63. "webRequest",
  64. "webRequestBlocking"
  65. ],
  66. "background": {
  67. "scripts": ["background.js"]
  68. },
  69. "minimum_chrome_version": "22.0.0"
  70. }
  71. """
  72. # 2. background.js
  73. background_js = f"""
  74. var config = {{
  75. mode: "fixed_servers",
  76. rules: {{
  77. singleProxy: {{
  78. scheme: "http",
  79. host: "{ip}",
  80. port: parseInt({port})
  81. }},
  82. bypassList: ["localhost"]
  83. }}
  84. }};
  85. chrome.proxy.settings.set({{value: config, scope: "regular"}}, function() {{}});
  86. function callbackFn(details) {{
  87. return {{
  88. authCredentials: {{
  89. username: "{username}",
  90. password: "{password}"
  91. }}
  92. }};
  93. }}
  94. chrome.webRequest.onAuthRequired.addListener(
  95. callbackFn,
  96. {{urls: ["<all_urls>"]}},
  97. ['blocking']
  98. );
  99. """
  100. with open(os.path.join(plugin_path, "manifest.json"), "w") as f:
  101. f.write(manifest_json)
  102. with open(os.path.join(plugin_path, "background.js"), "w") as f:
  103. f.write(background_js)
  104. return os.path.abspath(plugin_path)
  105. # --- 模拟 Requests Response 对象 ---
  106. class BrowserResponse:
  107. def __init__(self, result_dict):
  108. result_dict = result_dict or {}
  109. self.status_code = result_dict.get('status', 0)
  110. self.text = result_dict.get('body', '')
  111. self.headers = result_dict.get('headers', {})
  112. self.url = result_dict.get('url', '')
  113. self._json = None
  114. def json(self):
  115. if self._json is None:
  116. if not self.text:
  117. return {}
  118. try:
  119. self._json = json.loads(self.text)
  120. except:
  121. self._json = {}
  122. return self._json
  123. @property
  124. def content(self):
  125. return self.text.encode('utf-8')
  126. class VfsPlugin2(IVSPlg):
  127. def __init__(self, group_id: str):
  128. self.group_id = group_id
  129. self.config: Optional[VSPlgConfig] = None
  130. self.free_config: Dict[str, Any] = {}
  131. self.logger = None
  132. # 替换 requests.Session 为 DrissionPage
  133. self.page: Optional[ChromiumPage] = None
  134. self.jwt_token: str = ""
  135. self.real_ip: str = ""
  136. self.is_healthy: bool = True
  137. self.center_conf = None
  138. self.category_conf: Dict = {}
  139. self.subcategory_conf: Dict = {}
  140. self.public_key = serialization.load_pem_public_key(
  141. VFS_PUBLIC_KEY_PEM.encode(),
  142. backend=default_backend()
  143. )
  144. self.session_create_time: float = 0
  145. def get_group_id(self) -> str:
  146. return self.group_id
  147. def set_config(self, config: VSPlgConfig):
  148. self.config = config
  149. self.free_config = config.free_config or {}
  150. def set_log(self, logger: Callable[[str], None]) -> None:
  151. self.logger = logger
  152. def _log(self, message):
  153. if self.logger:
  154. self.logger(f'[VfsPlugin] [{self.group_id}] {message}')
  155. else:
  156. print(f'[VfsPlugin] [{self.group_id}] {message}')
  157. def health_check(self) -> bool:
  158. if not self.is_healthy:
  159. return False
  160. if self.page is None:
  161. return False
  162. # 检查页面是否还活着
  163. try:
  164. if not self.page.run_js("return 1;"):
  165. return False
  166. except:
  167. return False
  168. if self.config.session_max_life > 0:
  169. current_time = time.time()
  170. elapsed_time = current_time - self.session_create_time
  171. if elapsed_time > self.config.session_max_life * 60:
  172. self._log(f"Session expired.")
  173. return False
  174. return True
  175. def create_session(self) -> None:
  176. """
  177. 使用 DrissionPage 创建会话:
  178. 1. 启动浏览器
  179. 2. 导航到登录页
  180. 3. 自动过盾并提取 Token
  181. 4. JS fetch 登录
  182. """
  183. self._log("Initializing Browser Session...")
  184. # 0. 配置浏览器
  185. co = ChromiumOptions()
  186. co.auto_port() # 自动分配端口
  187. if self.config.proxy and self.config.proxy.ip:
  188. p = self.config.proxy
  189. # 情况 A: 有账号密码 -> 使用插件方案
  190. if p.username and p.password:
  191. self._log(f"Configuring authenticated proxy: {p.ip}:{p.port}")
  192. plugin_path = create_proxy_auth_extension(
  193. ip=p.ip,
  194. port=p.port,
  195. username=p.username,
  196. password=p.password
  197. )
  198. co.add_extension(plugin_path)
  199. # 情况 B: 无账号密码 (IP白名单模式) -> 直接设置
  200. else:
  201. self._log(f"Configuring standard proxy: {p.ip}:{p.port}")
  202. co.set_proxy(f"{p.scheme}://{p.ip}:{p.port}")
  203. # 无头模式 (生产环境建议 True, 调试 False)
  204. # co.headless(True)
  205. co.headless(False) # 调试时设为 False 方便观察
  206. # 反爬参数
  207. co.set_argument('--no-sandbox')
  208. co.set_argument('--disable-gpu')
  209. co.set_argument('--window-size=1920,1080')
  210. # 禁用自动化特征
  211. co.set_argument('--disable-blink-features=AutomationControlled')
  212. try:
  213. self.page = ChromiumPage(co)
  214. # 1. 导航到登录页面 (建立 Context)
  215. mission = self.free_config.get("mission_code", "")
  216. country = self.free_config.get("country_code", "")
  217. lang = self.free_config.get("language", "en")
  218. if not mission or not country:
  219. raise BizLogicError("Missing mission/country code config")
  220. login_page_url = f"https://visa.vfsglobal.com/{country}/{lang}/{mission}/login"
  221. self._log(f"Navigating to {login_page_url}...")
  222. self.page.get(login_page_url)
  223. # 2. 等待 Cloudflare 验证通过
  224. # DrissionPage 会自动处理 Turnstile,我们只需要等待结果出现
  225. # 通常 CF 的 widget 会生成一个 hidden input name="cf-turnstile-response"
  226. self._log("Waiting for Cloudflare challenge...")
  227. # 最多等待 30 秒
  228. cf_token = ""
  229. for _ in range(10):
  230. # 间隔 1 秒
  231. time.sleep(1)
  232. self._handle_cookie_banner()
  233. # 尝试从 DOM 获取 Token
  234. try:
  235. # 检查是否有 cf-turnstile-response 元素且有值
  236. ele = self.page.ele('xpath://input[@name="cf-turnstile-response"]')
  237. if ele and ele.value:
  238. cf_token = ele.value
  239. self._log("Cloudflare Turnstile token extracted from DOM.")
  240. break
  241. except:
  242. pass
  243. # 也可以检查是否已经看到了登录框 (id="mat-input-0" 或 form)
  244. if self.page.ele('xpath://form'):
  245. self._log("Login form detected.")
  246. # 即使 form 出来了,有时候 token 还在生成,稍微再等一下
  247. # 如果没拿到 token,尝试直接继续,或者报错
  248. # 注意:有些 VFS 页面可能没有显式的 turnstile,而是隐式的
  249. if not cf_token:
  250. self._log("[WARN] Could not extract Turnstile token. Trying to proceed anyway...")
  251. # 3. 准备登录 API 参数
  252. email = self.config.account.username
  253. password = self.config.account.password
  254. enc_password = self._encrypt_password(password)
  255. client_src = self._get_client_source()
  256. orange_src = self._get_orange_source(email)
  257. url = "https://lift-api.vfsglobal.com/user/login"
  258. headers = self._get_common_headers(with_auth=False)
  259. headers.update({
  260. "clientsource": client_src,
  261. "orangex": orange_src,
  262. # DrissionPage fetch 不需要 content-type,json参数会自动加
  263. })
  264. data = {
  265. "username": email,
  266. "password": enc_password,
  267. "missioncode": mission,
  268. "countrycode": country,
  269. "languageCode": "en-US",
  270. "captcha_version": "cloudflare-v1",
  271. "captcha_api_key": cf_token # 填入提取到的 Token
  272. }
  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. # 分支 1: 登录成功
  277. if resp_json.get('accessToken'):
  278. self.jwt_token = resp_json["accessToken"]
  279. self._log("Login successful, JWT obtained.")
  280. # 分支 2: OTP
  281. elif resp_json.get("enableOTPAuthentication"):
  282. self._log("Login requires OTP.")
  283. otp = self._read_otp_email()
  284. self._submit_login_otp(cf_token, otp)
  285. else:
  286. raise BizLogicError(f"Login failed: {resp.text[:200]}")
  287. self.session_create_time = time.time()
  288. # 获取真实IP (用于日志)
  289. try:
  290. self.real_ip = self._get_realnetwork_ip()
  291. except:
  292. self.real_ip = "0.0.0.0"
  293. except Exception as e:
  294. self._log(f"Create Session Failed: {e}")
  295. if self.page:
  296. self.page.quit()
  297. self.page = None
  298. raise e
  299. def query(self) -> VSQueryResult:
  300. """查询可预约 Slot"""
  301. result = VSQueryResult()
  302. appt_types = self.free_config.get("appointment_types", [])
  303. if not appt_types:
  304. raise NotFoundError(message="No matching appointment configuration found.")
  305. apt_config = random.choice(appt_types)
  306. try:
  307. self._fetch_configurations(apt_config)
  308. earliest_date = self._query_earliest_slot(apt_config)
  309. result.success = False
  310. result.availability_status = AvailabilityStatus.NoneAvailable
  311. result.visa_type = apt_config.get("visa_type", "")
  312. result.city = apt_config.get("city", "")
  313. if earliest_date:
  314. result.success = True
  315. if "WaitList" in earliest_date:
  316. result.availability_status = AvailabilityStatus.Waitlist
  317. else:
  318. result.availability_status = AvailabilityStatus.Available
  319. result.earliest_date = earliest_date
  320. result.availability = [DateAvailability(date=earliest_date, times=[])]
  321. self._log(f"Slot Found! Date: {earliest_date}")
  322. else:
  323. self._log("No slots available.")
  324. except Exception as e:
  325. self._log(f"Query Error: {e}")
  326. raise e
  327. return result
  328. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
  329. """
  330. 核心方法:在 DrissionPage 浏览器上下文中注入 JS 执行 fetch
  331. """
  332. if not self.page:
  333. raise BizLogicError("Browser session not initialized")
  334. # 1. 确保在正确的上下文 (VFS 登录页或 API 域名)
  335. # create_session 已经打开了页面,这里通常不需要额外跳转
  336. # 如果页面崩溃或跳转了,可能需要恢复
  337. # 2. 构造参数
  338. if params:
  339. if '?' in url:
  340. url += '&' + urllib.parse.urlencode(params)
  341. else:
  342. url += '?' + urllib.parse.urlencode(params)
  343. fetch_options = {
  344. "method": method.upper(),
  345. "headers": headers or {},
  346. "credentials": "include" # 关键:带上浏览器 Cookie
  347. }
  348. if json_data:
  349. fetch_options['body'] = json.dumps(json_data)
  350. fetch_options['headers']['Content-Type'] = 'application/json'
  351. elif data:
  352. if isinstance(data, dict):
  353. fetch_options['body'] = urllib.parse.urlencode(data)
  354. fetch_options['headers']['Content-Type'] = 'application/x-www-form-urlencoded'
  355. else:
  356. fetch_options['body'] = data
  357. # 3. 注入 JS
  358. js_script = f"""
  359. const url = "{url}";
  360. const options = {json.dumps(fetch_options)};
  361. return fetch(url, options)
  362. .then(async response => {{
  363. const text = await response.text();
  364. const headers = {{}};
  365. response.headers.forEach((value, key) => headers[key] = value);
  366. return {{
  367. status: response.status,
  368. body: text,
  369. headers: headers,
  370. url: response.url
  371. }};
  372. }})
  373. .catch(error => {{
  374. return {{
  375. status: 0,
  376. body: error.toString(),
  377. headers: {{}},
  378. url: url
  379. }};
  380. }});
  381. """
  382. if self.config.debug:
  383. self._log(f"[Browser Fetch] {method} {url}")
  384. try:
  385. # run_js 直接返回 return 的对象
  386. res_dict = self.page.run_js(js_script, timeout=30)
  387. except Exception as e:
  388. raise BizLogicError(f"Browser JS Execution Error: {e}")
  389. resp = BrowserResponse(res_dict)
  390. # 4. 统一处理状态码
  391. if resp.status_code == 200:
  392. return resp
  393. elif resp.status_code == 401:
  394. self.is_healthy = False
  395. raise SessionExpiredOrInvalidError(f"401 Unauthorized: {resp.text[:100]}")
  396. elif resp.status_code == 403:
  397. raise PermissionDeniedError(f"403 Forbidden: {resp.text[:100]}")
  398. elif resp.status_code == 429:
  399. self.is_healthy = False
  400. raise RateLimiteddError(f"429 Rate Limit: {resp.text[:100]}")
  401. elif resp.status_code == 0:
  402. raise BizLogicError(f"Network Error (Fetch Failed): {resp.text}")
  403. else:
  404. # 允许 400 业务错误通过,交给上层解析 (例如登录失败)
  405. if url.endswith("/login") and resp.status_code == 400:
  406. return resp
  407. raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
  408. def _handle_cookie_banner(self):
  409. """
  410. 处理 OneTrust Cookie 遮挡
  411. 策略:尝试点击“接受所有”,如果点不到就直接移除 DOM
  412. """
  413. try:
  414. # 使用 JS 处理最快,且不会因为元素运动报错
  415. js = """
  416. try {
  417. // 1. 尝试点击 '接受所有' 按钮
  418. var acceptBtn = document.getElementById('onetrust-accept-btn-handler');
  419. if (acceptBtn) {
  420. acceptBtn.click();
  421. return true;
  422. }
  423. // 2. 如果没有按钮,或者还在遮挡,直接把整个 banner 删掉
  424. var banner = document.getElementById('onetrust-banner-sdk');
  425. if (banner) {
  426. banner.style.display = 'none'; // 隐藏
  427. banner.remove(); // 或者移除
  428. return true;
  429. }
  430. } catch(e) {}
  431. return false;
  432. """
  433. self.page.run_js(js)
  434. except:
  435. pass
  436. def _get_proxy_url(self):
  437. if self.config.proxy and self.config.proxy.ip:
  438. s = self.config.proxy
  439. if s.username:
  440. return f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
  441. else:
  442. return f"{s.scheme}://{s.ip}:{s.port}"
  443. return None
  444. def _get_realnetwork_ip(self):
  445. """
  446. 通过新建标签页获取 IP
  447. 解决 CORS 403 问题:新标签页请求属于 Top-Level Navigation,
  448. 不带 Origin: visa.vfsglobal.com,也不带 credentials,符合 ipify 规则。
  449. """
  450. try:
  451. # 1. 新建一个标签页 (后台静默打开)
  452. tab = self.page.new_tab("https://api.ipify.org/?format=json")
  453. # 2. 获取页面内容 (DrissionPage 会自动等待页面加载)
  454. # ipify 返回的是纯 JSON 文本,通常在 body 或 pre 标签里
  455. if tab.ele('tag:pre'):
  456. json_text = tab.ele('tag:pre').text
  457. else:
  458. json_text = tab.ele('tag:body').text
  459. # 3. 提取 IP
  460. ip = json.loads(json_text)['ip']
  461. # 4. 务必关闭标签页,释放资源
  462. tab.close()
  463. self._log(f"Real Network IP: {ip}")
  464. return ip
  465. except Exception as e:
  466. self._log(f"[WARN] Failed to check IP via new tab: {e}")
  467. # 尝试清理可能没关掉的标签页
  468. try:
  469. if self.page.tabs_count > 1:
  470. tab.close()
  471. except:
  472. pass
  473. return "0.0.0.0"
  474. def _get_common_headers(self, with_auth=True) -> Dict[str, str]:
  475. # DrissionPage 浏览器会自动带上 Origin, Referer, User-Agent, Sec-CH-UA 等
  476. # 这里只需要补充业务特定的 Headers
  477. mission = self.free_config.get("mission_code", "")
  478. country = self.free_config.get("country_code", "")
  479. lang = self.free_config.get("language", "en")
  480. route = f"{country}/{lang}/{mission}"
  481. h = {
  482. "accept": "application/json, text/plain, */*",
  483. # "origin": ... 浏览器自动处理
  484. # "referer": ... 浏览器自动处理
  485. "route": route
  486. }
  487. # 即使是浏览器环境,VFS 也需要这两个加密参数
  488. # 注意:这里可能需要从 JS 获取,或者保持 Python 生成
  489. # 如果 Python 生成的总是报错,可以考虑把加密逻辑移到 JS 里跑
  490. h["clientsource"] = self._get_client_source()
  491. if with_auth and self.jwt_token:
  492. h["authorize"] = self.jwt_token
  493. return h
  494. def _encrypt_password(self, password: str) -> str:
  495. ciphertext = self.public_key.encrypt(
  496. password.encode(),
  497. padding.OAEP(
  498. mgf=padding.MGF1(algorithm=hashes.SHA256()),
  499. algorithm=hashes.SHA256(),
  500. label=None
  501. )
  502. )
  503. return base64.b64encode(ciphertext).decode()
  504. def _get_orange_source(self, email: str) -> str:
  505. timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
  506. payload = f"{email};{timestamp}"
  507. return self._encrypt_password(payload)
  508. def _get_client_source(self) -> str:
  509. timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
  510. payload = f"GA;{timestamp}Z"
  511. return self._encrypt_password(payload)
  512. def _query_earliest_slot(self, apt_config) -> Optional[str]:
  513. url = "https://lift-api.vfsglobal.com/appointment/CheckIsSlotAvailable"
  514. data = {
  515. "missioncode": self.free_config.get("mission_code"),
  516. "countrycode": self.free_config.get("country_code"),
  517. "vacCode": apt_config.get("vac_code"),
  518. "visaCategoryCode": apt_config.get("subcategory_code"),
  519. "roleName": "Individual",
  520. "loginUser": self.config.account.username,
  521. "payCode": ""
  522. }
  523. headers = self._get_common_headers(with_auth=True)
  524. # fetch 不需要显式 content-type application/json,json_data会自动处理
  525. # DrissionPage 不需要手动处理 403 绕盾,因为浏览器本身就在盾后面
  526. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  527. if "WaitList" in resp.text:
  528. return "WaitList"
  529. j = resp.json()
  530. if j.get("earliestSlotLists"):
  531. raw_date = j["earliestSlotLists"][0]["date"]
  532. return to_yyyymmdd(raw_date, "%m/%d/%Y %H:%M:%S")
  533. return ""
  534. def _fetch_configurations(self, apt_config: Dict[str, Any]):
  535. if not self.center_conf:
  536. self.center_conf = self._query_center()
  537. vac_code = apt_config.get("vac_code")
  538. category_code = apt_config.get("category_code")
  539. if category_code not in self.category_conf:
  540. visa_categories = self._query_visa_category(vac_code)
  541. found = False
  542. for vc in visa_categories:
  543. if vc.get("code") == category_code:
  544. self.category_conf[category_code] = vc
  545. found = True
  546. break
  547. if not found:
  548. self._log(f"WARN: Category {category_code} not found")
  549. sub_category_code = apt_config.get("subcategory_code")
  550. if sub_category_code not in self.subcategory_conf:
  551. visa_subcategories = self._query_visa_sub_category(vac_code, category_code)
  552. found = False
  553. for svc in visa_subcategories:
  554. if svc.get("code") == sub_category_code:
  555. self.subcategory_conf[sub_category_code] = svc
  556. found = True
  557. break
  558. if not found:
  559. self._log(f"WARN: SubCategory {sub_category_code} not found")
  560. def _query_center(self) -> List:
  561. mission = self.free_config.get("mission_code")
  562. country = self.free_config.get("country_code")
  563. url = f"https://lift-api.vfsglobal.com/master/center/{mission}/{country}/en-US"
  564. headers = self._get_common_headers(with_auth=False)
  565. resp = self._perform_request("GET", url, headers=headers)
  566. return resp.json()
  567. def _query_visa_category(self, center_code: str) -> List:
  568. mission = self.free_config.get("mission_code")
  569. country = self.free_config.get("country_code")
  570. enc_center = urllib.parse.quote(center_code)
  571. url = f"https://lift-api.vfsglobal.com/master/visacategory/{mission}/{country}/{enc_center}/en-US"
  572. headers = self._get_common_headers(with_auth=False)
  573. resp = self._perform_request("GET", url, headers=headers)
  574. return resp.json()
  575. def _query_visa_sub_category(self, center_code: str, category_code: str) -> List:
  576. mission = self.free_config.get("mission_code")
  577. country = self.free_config.get("country_code")
  578. enc_center = urllib.parse.quote(center_code)
  579. enc_cat = urllib.parse.quote(category_code)
  580. url = f"https://lift-api.vfsglobal.com/master/subvisacategory/{mission}/{country}/{enc_center}/{enc_cat}/en-US"
  581. headers = self._get_common_headers(with_auth=False)
  582. resp = self._perform_request("GET", url, headers=headers)
  583. return resp.json()
  584. def _read_otp_email(self) -> str:
  585. # 保持原样,这部分使用云API读取邮件,不依赖本地网络库
  586. master_email = "visafly666@gmail.com"
  587. recipient = self.config.account.username
  588. sender = "donotreply at vfshelpline.com"
  589. subject_keywords = "One Time Password"
  590. body_keywords = "OTP"
  591. now_utc = datetime.utcnow()
  592. formatted_utc_time = now_utc.strftime("%Y-%m-%d %H:%M:%S")
  593. self._log(f"Waiting for OTP email...")
  594. for i in range(12):
  595. content_out = VSCloudApi.Instance().fetch_mail_content(
  596. master_email, sender, recipient, subject_keywords, body_keywords, formatted_utc_time, 300
  597. )
  598. if content_out:
  599. match = re.search(r'\b\d{6}\b', content_out)
  600. if match:
  601. return match.group(0)
  602. time.sleep(5)
  603. raise NotFoundError(message="OTP email not found")
  604. def _submit_login_otp(self, old_cf_token: str, otp: str):
  605. self._log("Submitting Login OTP...")
  606. # --- [新增] 必须刷新 Token ---
  607. # 旧的 old_cf_token 已经在第一步登录时失效了
  608. new_cf_token = self._refresh_turnstile_token()
  609. # ---------------------------
  610. email = self.config.account.username
  611. password = self.config.account.password
  612. enc_password = self._encrypt_password(password)
  613. mission = self.free_config.get("mission_code", "")
  614. country = self.free_config.get("country_code", "")
  615. client_src = self._get_client_source()
  616. orange_src = self._get_orange_source(email)
  617. url = "https://lift-api.vfsglobal.com/user/login"
  618. headers = self._get_common_headers(with_auth=False)
  619. headers.update({
  620. "clientsource": client_src,
  621. "orangex": orange_src
  622. })
  623. data = {
  624. "username": email,
  625. "password": enc_password,
  626. "missioncode": mission,
  627. "countrycode": country,
  628. "languageCode": "en-US",
  629. "captcha_version": "cloudflare-v1",
  630. "captcha_api_key": new_cf_token, # <--- 使用新 Token
  631. "otp": otp
  632. }
  633. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  634. resp_json = resp.json()
  635. if resp_json.get("accessToken"):
  636. self.jwt_token = resp_json["accessToken"]
  637. self._log("OTP Login successful.")
  638. return
  639. # 增加错误详情日志
  640. error_desc = resp_json.get("description", resp.text)
  641. raise PermissionDeniedError(message=f"OTP Login Failed: {error_desc}")
  642. def _refresh_turnstile_token(self) -> str:
  643. """
  644. 强制刷新 Cloudflare Turnstile 并获取新 Token (增强版)
  645. """
  646. self._log("Refreshing Cloudflare Turnstile token...")
  647. # 1. JS 强制重置
  648. # 加上 try-catch 防止页面没有 turnstile 对象导致崩溃
  649. js_reset = """
  650. try {
  651. var input = document.querySelector('input[name="cf-turnstile-response"]');
  652. if (input) input.value = "";
  653. window.turnstile.reset();
  654. } catch(e) {
  655. console.log("Turnstile reset error:", e);
  656. }
  657. """
  658. self.page.run_js(js_reset)
  659. # 2. 轮询等待 (增加到 30 秒)
  660. # 策略:检测 Token -> 如果没有且有 iframe -> 点击 iframe 触发验证
  661. for i in range(60): # 60 * 0.5s = 30s
  662. time.sleep(0.5)
  663. # A. 尝试直接获取 Token (使用 JS 获取更稳定)
  664. token = self.page.run_js('return document.querySelector("input[name=\'cf-turnstile-response\']")?.value')
  665. if token:
  666. self._log("Turnstile token refreshed successfully.")
  667. return token
  668. # B. 如果等待了 3 秒还没结果,尝试寻找 iframe 并点击
  669. # Cloudflare 有时需要用户点一下 "Verify you are human"
  670. if i > 6 and (i % 5 == 0): # 每隔 2.5 秒尝试点一次
  671. try:
  672. # 查找包含 turnstile 或 cloudflare 的 iframe
  673. # VFS 页面通常只有一个
  674. cf_iframe = self.page.ele('xpath://iframe[contains(@src, "turnstile") or contains(@src, "cloudflare")]')
  675. if cf_iframe:
  676. # 尝试点击 iframe 的中心位置
  677. # self._log("Clicking Cloudflare widget to activate...")
  678. cf_iframe.click(by_js=True)
  679. except Exception:
  680. pass
  681. # 如果超时,为了调试,打印一下当前页面源码的一部分或截图(可选)
  682. raise BizLogicError("Failed to refresh Cloudflare Turnstile token (Timeout)")
  683. # -------------------------------------------------------------
  684. # 核心预约逻辑 (DrissionPage 版)
  685. # -------------------------------------------------------------
  686. def book(self, slot_info: VSQueryResult, user_inputs) -> VSBookResult:
  687. """
  688. 执行完整的预约流程
  689. """
  690. self._log("Starting booking process...")
  691. # 1. 准备数据
  692. user_email = user_inputs.get('email')
  693. # 生成别名邮箱 (防止邮箱被 VFS 黑名单)
  694. user_inputs['alias_email'] = get_alias_email(user_email, new_domain="gmail-app.com")
  695. res = VSBookResult()
  696. slot_routing_key = slot_info.routing_key
  697. # 如果没有 earliest_date,默认从今天开始
  698. from_date = slot_info.earliest_date if slot_info.earliest_date else datetime.now().strftime("%Y-%m-%d")
  699. # 2. 查找对应的配置
  700. apt_config = None
  701. appt_types = self.free_config.get("appointment_types", [])
  702. for apt in appt_types:
  703. if apt.get("routing_key") == slot_routing_key:
  704. apt_config = apt
  705. break
  706. if not apt_config:
  707. raise NotFoundError(message="Book: Config missing for this routing key.")
  708. # 确保配置已加载 (SubCategory 等)
  709. self._fetch_configurations(apt_config)
  710. sub_cc = apt_config.get("subcategory_code")
  711. sub_conf = self.subcategory_conf.get(sub_cc, {})
  712. # 3. OCR 识别 / 文档上传 (如果需要)
  713. # 上传结果存入 user_inputs 供后续使用
  714. ocr_enabled = sub_conf.get("isOCREnable", False)
  715. if ocr_enabled:
  716. self._log("OCR Enabled, uploading documents...")
  717. upload_res = self._upload_applicant_documents(apt_config, user_inputs)
  718. user_inputs["applicant_image"] = upload_res.get("passportImageFilename")
  719. user_inputs["applicant_image_data"] = upload_res.get("passportImageFileBytes")
  720. user_inputs["guid"] = upload_res.get("uploadDocumentGUID")
  721. enable_reference_number = sub_conf.get("enableReferenceNumber", False)
  722. # 4. 添加申请人 (核心步骤 1)
  723. final_urn = None
  724. is_waitlist = (slot_info.availability_status == AvailabilityStatus.Waitlist)
  725. # 重试机制:添加申请人有时候会因为并发冲突失败
  726. MAX_RETRY = 3
  727. for i in range(MAX_RETRY):
  728. try:
  729. final_urn = self._add_primary_applicant(apt_config, user_inputs, is_waitlist, ocr_enabled, enable_reference_number)
  730. if final_urn:
  731. break
  732. except Exception as e:
  733. self._log(f"Add Applicant retry {i+1}/{MAX_RETRY}: {e}")
  734. time.sleep(2)
  735. if not final_urn:
  736. raise BizLogicError(message="Failed to add primary applicant (Slot likely taken or API error)")
  737. self._log(f"Applicant Added. URN: {final_urn}")
  738. # 5. 申请人 OTP 验证 (核心步骤 2 - 视配置而定)
  739. otp_enabled = sub_conf.get("isApplicantOTPEnabled", False)
  740. if otp_enabled:
  741. self._log("Applicant OTP Required.")
  742. if not self._applicant_otp_send(apt_config, final_urn):
  743. raise BizLogicError(message='Applicant OTP send failed')
  744. # 复用之前的读邮件逻辑
  745. otp_code = self._read_otp_email()
  746. if not self._applicant_otp_verify(apt_config, final_urn, otp_code):
  747. raise BizLogicError(message='Applicant OTP verify failed')
  748. # 6. Waitlist 模式直接返回
  749. if is_waitlist:
  750. if self._confirm_waitlist(apt_config, final_urn):
  751. res.success = True
  752. res.urn = final_urn
  753. self._log("Waitlist confirmed.")
  754. return res
  755. raise BizLogicError(message='Confirm waitlist failed')
  756. # 7. 寻找具体的时间槽 (核心步骤 3)
  757. expected_start = user_inputs.get("expected_start_date", "")
  758. expected_end = user_inputs.get("expected_end_date", "")
  759. # 计算需要扫描的月份
  760. months = self._get_filtered_covered_months(expected_start, expected_end, from_date)
  761. self._log(f"Scanning months: {months} (Start looking from: {from_date})")
  762. selected_slot_id = ""
  763. selected_slot_date = ""
  764. selected_slot_time_range = ""
  765. all_ads = set()
  766. forbidden_dates = set()
  767. found_slot = False
  768. for m_str in months:
  769. self._log(f"Checking calendar for {m_str}...")
  770. # 查询日历
  771. ads = self._query_slot_calendar(apt_config, final_urn, m_str)
  772. # 去重
  773. new_ads = [d for d in ads if d not in all_ads]
  774. all_ads.update(new_ads)
  775. # 尝试选中一个日期
  776. # 这里做一个简单循环,如果选中日期没时间了,就换一个日期
  777. for _ in range(3):
  778. avail_candidates = [d for d in list(all_ads) if d not in forbidden_dates]
  779. # 根据用户期望过滤
  780. sel_dates = self._filter_dates(avail_candidates, expected_start, expected_end)
  781. if not sel_dates:
  782. break # 当前月没有符合要求的日期,去下一个月
  783. tmp_date = sel_dates[0] # 取第一个(通常 _filter_dates 里已经 shuffle 过了)
  784. forbidden_dates.add(tmp_date) # 标记为已尝试
  785. # 关键:Audit Log (锁定日期)
  786. # VFS 要求在查 timeslot 之前必须先发这个请求
  787. if not self._saveuseractionaudit(apt_config, final_urn, tmp_date):
  788. self._log(f"Audit failed for {tmp_date}, skipping...")
  789. time.sleep(1)
  790. continue
  791. # 查询具体时间
  792. ats = self._query_slot_time(apt_config, final_urn, tmp_date)
  793. if not ats:
  794. self._log(f"No timeslots for {tmp_date}")
  795. continue
  796. # 随机选一个时间
  797. sel_tm = random.choice(ats)
  798. selected_slot_id = sel_tm.get("allocationId")
  799. selected_slot_date = tmp_date
  800. selected_slot_time_range = sel_tm.get("slot")
  801. found_slot = True
  802. break
  803. if found_slot:
  804. break
  805. if not found_slot:
  806. self._log("No valid slots found after scanning.")
  807. res.success = False
  808. return res
  809. self._log(f"Slot Selected: {selected_slot_date} {selected_slot_time_range} (ID: {selected_slot_id})")
  810. # 8. 服务与费用 (核心步骤 4)
  811. self._submit_no_addition_service(final_urn)
  812. amount, currency = self._query_fee(apt_config, final_urn)
  813. # 9. 最终提交
  814. self._log("Submitting schedule...")
  815. schedule_res = self._schedule(apt_config, final_urn, amount, currency, selected_slot_id)
  816. if not schedule_res.get("IsAppointmentBooked"):
  817. self._log(f"Booking failed: {schedule_res}")
  818. res.success = False
  819. return res
  820. # 10. 构造成功结果
  821. res.success = True
  822. res.account = self.config.account.username
  823. res.book_date = selected_slot_date
  824. res.book_time = selected_slot_time_range
  825. res.urn = final_urn
  826. res.fee_amount = int(amount * 100)
  827. res.fee_currency = currency
  828. # 11. 处理支付链接
  829. if schedule_res.get("IsPaymentRequired", False):
  830. payload = schedule_res.get("payLoad", "")
  831. if payload:
  832. self._log("Processing payment link...")
  833. payment_url = self._pay_request(payload)
  834. if payment_url:
  835. res.payment_link = payment_url
  836. return res
  837. # -------------------------------------------------------------
  838. # 辅助方法实现 (DrissionPage 适配版)
  839. # -------------------------------------------------------------
  840. def _upload_applicant_documents(self, apt_config, user_inputs) -> Dict:
  841. """上传图片:先下载外部图片,再通过浏览器上传到 VFS"""
  842. import requests as standard_requests # 使用标准库下载外部资源
  843. url = "https://lift-api.vfsglobal.com/appointment/UploadApplicantDocument"
  844. passport_url = user_inputs.get("passport_image_url")
  845. if not passport_url:
  846. raise NotFoundError(message="Missing passport_image_url")
  847. # 下载图片 (不走代理或走系统代理,不使用 DrissionPage,因为是外部链接)
  848. try:
  849. img_resp = standard_requests.get(passport_url, timeout=30)
  850. if img_resp.status_code != 200:
  851. raise BizLogicError(message=f"Failed to download passport image: {img_resp.status_code}")
  852. b64_str = base64.b64encode(img_resp.content).decode('utf-8')
  853. except Exception as e:
  854. raise BizLogicError(message=f"Image download error: {e}")
  855. headers = self._get_common_headers(with_auth=True)
  856. # DrissionPage fetch 不需要显式 content-type application/json,json_data会自动处理
  857. data = {
  858. "missioncode": self.free_config.get("mission_code"),
  859. "countryCode": self.free_config.get("country_code"),
  860. "centerCode": apt_config.get("vac_code"),
  861. "loginUser": self.config.account.username,
  862. "languageCode": "en-US",
  863. "visaCategoryCode": apt_config.get("subcategory_code"),
  864. "fileBytes": b64_str,
  865. "selfiImageFileBytes": ""
  866. }
  867. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  868. result = resp.json()
  869. # 补充返回数据供后续使用
  870. result["passportImageFilename"] = "passport_img.jpg"
  871. result["passportImageFileBytes"] = b64_str
  872. return result
  873. def _add_primary_applicant(self, apt_config: Dict[str, Any], user_inputs: Dict[str, Any],
  874. is_waitlist: bool, ocr_enabled: bool, enable_ref: bool) -> str:
  875. """构造申请人 payload 并提交"""
  876. url = "https://lift-api.vfsglobal.com/appointment/applicants"
  877. headers = self._get_common_headers(with_auth=True)
  878. gender_str = str(user_inputs.get("gender", "")).lower()
  879. gender_code = 1 if gender_str == "male" else 2
  880. raw_dial = user_inputs.get("phone_country_code", "86")
  881. dial_code = str(raw_dial)
  882. # 日期格式转换 YYYY-MM-DD -> DD/MM/YYYY
  883. def _to_ddmmyyyy(d_str):
  884. try:
  885. return datetime.strptime(str(d_str), "%Y-%m-%d").strftime("%d/%m/%Y")
  886. except:
  887. return str(d_str)
  888. dob = _to_ddmmyyyy(user_inputs.get("birthday", ""))
  889. ppt_exp = _to_ddmmyyyy(user_inputs.get("passport_expiry_date", ""))
  890. applicant = {
  891. "urn": "",
  892. "arn": "",
  893. "loginUser": self.config.account.username,
  894. "firstName": str(user_inputs.get("first_name", "")).upper(),
  895. "middleName": "",
  896. "lastName": str(user_inputs.get("last_name", "")).upper(),
  897. "employerFirstName": "",
  898. "employerLastName": "",
  899. "salutation": "",
  900. "gender": gender_code,
  901. "contactNumber": str(user_inputs.get("phone", "")),
  902. "dialCode": dial_code,
  903. "employerContactNumber": "",
  904. "employerDialCode": "",
  905. "emailId": str(user_inputs.get("alias_email", "")).upper(),
  906. "employerEmailId": "",
  907. "passportNumber": str(user_inputs.get("passport_no", "")).upper(),
  908. "confirmPassportNumber": "",
  909. "passportExpirtyDate": ppt_exp,
  910. "dateOfBirth": dob,
  911. "nationalId": None,
  912. "nationalityCode": get_country_iso3(str(user_inputs.get("nationality", ""))),
  913. "state": None, "city": None, "addressline1": None, "addressline2": None, "pincode": None,
  914. "isEndorsedChild": False, "applicantType": 0, "vlnNumber": None, "applicantGroupId": 0,
  915. "parentPassportNumber": "", "parentPassportExpiry": "", "dateOfDeparture": None,
  916. "entryType": "", "eoiVisaType": "", "passportType": "", "vfsReferenceNumber": "",
  917. "familyReunificationCerificateNumber": "", "PVRequestRefNumber": "", "PVStatus": "",
  918. "PVStatusDescription": "", "PVCanAllowRetry": True, "PVisVerified": False,
  919. "eefRegistrationNumber": "", "isAutoRefresh": True, "helloVerifyNumber": "",
  920. "OfflineCClink": "", "idenfystatuscheck": False, "vafStatus": None,
  921. "SpecialAssistance": "", "AdditionalRefNo": None, "juridictionCode": "",
  922. "canInitiateVAF": False, "canEditVAF": False, "canDeleteVAF": False,
  923. "canDownloadVAF": False, "Retryleft": "",
  924. # 这里的 IP 应该已经在 create_session 时获取到了
  925. "ipAddress": self.real_ip
  926. }
  927. if enable_ref:
  928. applicant["referenceNumber"] = str(user_inputs.get("cover_letter", ""))
  929. else:
  930. applicant["referenceNumber"] = None
  931. if ocr_enabled:
  932. applicant["applicantImage"] = str(user_inputs.get("applicant_image", ""))
  933. applicant["applicantImageData"] = str(user_inputs.get("applicant_image_data", ""))
  934. applicant["GUID"] = str(user_inputs.get("guid", ""))
  935. payload = {
  936. "countryCode": self.free_config.get("country_code"),
  937. "missionCode": self.free_config.get("mission_code"),
  938. "centerCode": apt_config.get("vac_code"),
  939. "loginUser": self.config.account.username,
  940. "visaCategoryCode": apt_config.get("subcategory_code"),
  941. "applicantList": [applicant],
  942. "languageCode": "en-US",
  943. "isWaitlist": is_waitlist,
  944. "isEdit": False,
  945. "feeEntryTypeCode": None, "feeExemptionTypeCode": None,
  946. "feeExemptionDetailsCode": None, "juridictionCode": None, "regionCode": None
  947. }
  948. resp = self._perform_request("POST", url, headers=headers, json_data=payload)
  949. return resp.json().get("urn")
  950. def _applicant_otp_send(self, apt_config, urn) -> bool:
  951. url = "https://lift-api.vfsglobal.com/appointment/applicantotp"
  952. headers = self._get_common_headers(with_auth=True)
  953. data = {
  954. "urn": urn,
  955. "loginUser": self.config.account.username,
  956. "missionCode": self.free_config.get("mission_code"),
  957. "countryCode": self.free_config.get("country_code"),
  958. "centerCode": apt_config.get("vac_code"),
  959. "OTP": "",
  960. "otpAction": "GENERATE",
  961. "languageCode": "en-US"
  962. }
  963. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  964. return resp.json().get("isOTPGenerated", False)
  965. def _applicant_otp_verify(self, apt_config, urn, otp) -> bool:
  966. url = "https://lift-api.vfsglobal.com/appointment/applicantotp"
  967. headers = self._get_common_headers(with_auth=True)
  968. # VFS 这里的 header 有时需要 datacenter,原代码有就加上
  969. headers["datacenter"] = "GERMANY"
  970. data = {
  971. "urn": urn,
  972. "loginUser": self.config.account.username,
  973. "missionCode": self.free_config.get("mission_code"),
  974. "countryCode": self.free_config.get("country_code"),
  975. "centerCode": apt_config.get("vac_code"),
  976. "OTP": otp,
  977. "otpAction": "VALIDATE",
  978. "languageCode": "en-US"
  979. }
  980. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  981. return resp.json().get("isOTPValidated", False)
  982. def _query_slot_calendar(self, apt_config, urn, from_date) -> List:
  983. url = "https://lift-api.vfsglobal.com/appointment/calendar"
  984. headers = self._get_common_headers(with_auth=True)
  985. # 将 YYYY-MM-DD 转为 DD/MM/YYYY 用于 API
  986. dt_m = datetime.strptime(from_date, "%Y-%m-%d")
  987. converted_date = dt_m.strftime("%d/%m/%Y")
  988. data = {
  989. "missionCode": self.free_config.get("mission_code"),
  990. "countryCode": self.free_config.get("country_code"),
  991. "centerCode": apt_config.get("vac_code"),
  992. "loginUser": self.config.account.username,
  993. "visaCategoryCode": apt_config.get("subcategory_code"),
  994. "fromDate": converted_date,
  995. "urn": urn,
  996. "payCode": ""
  997. }
  998. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  999. calendars = resp.json().get("calendars")
  1000. ads_out = []
  1001. if calendars:
  1002. for item in calendars:
  1003. # API 返回可能是 MM/DD/YYYY 或 DD/MM/YYYY,VFS 比较乱
  1004. # 通常是 MM/DD/YYYY
  1005. raw = item.get("date")
  1006. ads_out.append(to_yyyymmdd(raw, "%m/%d/%Y"))
  1007. return ads_out
  1008. def _query_slot_time(self, apt_config, urn, slot_date) -> List:
  1009. url = "https://lift-api.vfsglobal.com/appointment/timeslot"
  1010. headers = self._get_common_headers(with_auth=True)
  1011. dt_m = datetime.strptime(slot_date, "%Y-%m-%d")
  1012. converted_date = dt_m.strftime("%d/%m/%Y")
  1013. data = {
  1014. "missionCode": self.free_config.get("mission_code"),
  1015. "countryCode": self.free_config.get("country_code"),
  1016. "centerCode": apt_config.get("vac_code"),
  1017. "loginUser": self.config.account.username,
  1018. "visaCategoryCode": apt_config.get("subcategory_code"),
  1019. "slotDate": converted_date,
  1020. "urn": urn
  1021. }
  1022. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  1023. return resp.json().get("slots", [])
  1024. def _saveuseractionaudit(self, apt_config, urn, earliest_date) -> bool:
  1025. url = "https://lift-api.vfsglobal.com/appointment/saveuseractionaudit"
  1026. headers = self._get_common_headers(with_auth=True)
  1027. dt = datetime.strptime(earliest_date, "%Y-%m-%d")
  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. "urn": urn,
  1034. "firstEarliestSlotDate": dt.strftime("%d/%m/%Y"),
  1035. "action": "schedule",
  1036. "ipAddress": self.real_ip,
  1037. "eadAppointmentDetail": dt.strftime("%Y-%m-%dT%H:%M:%S")
  1038. }
  1039. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  1040. return resp.json().get("isSavedSuccess", False)
  1041. def _submit_no_addition_service(self, urn):
  1042. url = "https://lift-api.vfsglobal.com/vas/mapvas"
  1043. headers = self._get_common_headers(with_auth=True)
  1044. data = {
  1045. "loginUser": self.config.account.username,
  1046. "missionCode": self.free_config.get("mission_code"),
  1047. "countryCode": self.free_config.get("country_code"),
  1048. "urn": urn,
  1049. "applicants": []
  1050. }
  1051. # 只要不报错即可
  1052. self._perform_request("POST", url, headers=headers, json_data=data)
  1053. def _query_fee(self, apt_config, urn) -> Tuple[float, str]:
  1054. url = "https://lift-api.vfsglobal.com/appointment/fees"
  1055. headers = self._get_common_headers(with_auth=True)
  1056. data = {
  1057. "missionCode": self.free_config.get("mission_code"),
  1058. "countryCode": self.free_config.get("country_code"),
  1059. "centerCode": apt_config.get("vac_code"),
  1060. "loginUser": self.config.account.username,
  1061. "urn": urn,
  1062. "languageCode": "en-US"
  1063. }
  1064. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  1065. j = resp.json()
  1066. total = j.get("totalamount", 0.0)
  1067. currency = "EUR"
  1068. if j.get("feeDetails"):
  1069. currency = j["feeDetails"][0].get("currency", "EUR")
  1070. return total, currency
  1071. def _schedule(self, apt_config, urn, amount, currency, slot_id) -> Dict:
  1072. url = "https://lift-api.vfsglobal.com/appointment/schedule"
  1073. headers = self._get_common_headers(with_auth=True)
  1074. data = {
  1075. "missionCode": self.free_config.get("mission_code"),
  1076. "countryCode": self.free_config.get("country_code"),
  1077. "centerCode": apt_config.get("vac_code"),
  1078. "loginUser": self.config.account.username,
  1079. "urn": urn,
  1080. "notificationType": "none",
  1081. "paymentdetails": {
  1082. "paymentmode": "Online",
  1083. "RequestRefNo": "",
  1084. "clientId": "",
  1085. "merchantId": "",
  1086. "amount": amount,
  1087. "currency": currency
  1088. },
  1089. "allocationId": str(slot_id),
  1090. "CanVFSReachoutToApplicant": True
  1091. }
  1092. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  1093. return resp.json()
  1094. def _pay_request(self, payload) -> str:
  1095. """
  1096. 解析支付重定向 URL (DrissionPage 新标签页版)
  1097. """
  1098. # 初始 URL,通常是一个 Redirect 接口
  1099. start_url = f"https://online.vfsglobal.com/PG-Component/Payment/PayRequest?payLoad={payload}"
  1100. final_url = ""
  1101. try:
  1102. self._log("Resolving payment redirect...")
  1103. # 使用新标签页去跑,以免当前会话状态丢失
  1104. pay_tab = self.page.new_tab(start_url)
  1105. # 等待跳转完成 (通常会跳到 Stripe, WorldPay 或其他支付网关)
  1106. # 等待直到 URL 不再是 PayRequest
  1107. pay_tab.wait.url_change(start_url, timeout=15)
  1108. final_url = pay_tab.url
  1109. self._log(f"Payment URL resolved: {final_url}")
  1110. # 关闭标签页
  1111. pay_tab.close()
  1112. except Exception as e:
  1113. self._log(f"[WARN] Failed to resolve payment URL: {e}")
  1114. try:
  1115. pay_tab.close()
  1116. except:
  1117. pass
  1118. return final_url
  1119. def _confirm_waitlist(self, apt_config: Dict[str, Any], urn: str) -> bool:
  1120. url = "https://lift-api.vfsglobal.com/appointment/ConfirmWaitlist"
  1121. headers = self._get_common_headers(with_auth=True)
  1122. data = {
  1123. "missionCode": self.free_config.get("mission_code"),
  1124. "countryCode": self.free_config.get("country_code"),
  1125. "centerCode": apt_config.get("vac_code"),
  1126. "loginUser": self.config.account.username,
  1127. "urn": urn,
  1128. "notificationType": "none",
  1129. "CanVFSReachoutToApplicant": True
  1130. }
  1131. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  1132. return resp.json().get("isConfirmed", False)
  1133. def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
  1134. if not start_str or not end_str:
  1135. return dates
  1136. valid_dates = []
  1137. try:
  1138. s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
  1139. e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
  1140. for date_str in dates:
  1141. curr_date = datetime.strptime(date_str, "%Y-%m-%d")
  1142. if s_date <= curr_date <= e_date:
  1143. valid_dates.append(date_str)
  1144. random.shuffle(valid_dates)
  1145. return valid_dates
  1146. except:
  1147. return dates
  1148. def _get_filtered_covered_months(self, start_date, end_date, from_date) -> List[str]:
  1149. fmt = "%Y-%m-%d"
  1150. try:
  1151. dt_start = datetime.strptime(start_date, fmt) if start_date else datetime.now()
  1152. dt_end = datetime.strptime(end_date, fmt) if end_date else datetime.now().replace(year=datetime.now().year + 1)
  1153. try:
  1154. dt_from = datetime.strptime(from_date, fmt)
  1155. except:
  1156. dt_from = datetime.now()
  1157. except:
  1158. return []
  1159. dt_start = dt_start.replace(day=1)
  1160. dt_end = dt_end.replace(day=1)
  1161. dt_from = dt_from.replace(day=1)
  1162. curr = max(dt_start, dt_from)
  1163. months = []
  1164. while curr <= dt_end:
  1165. months.append(curr.strftime(fmt))
  1166. if curr.month == 12:
  1167. curr = curr.replace(year=curr.year + 1, month=1)
  1168. else:
  1169. curr = curr.replace(month=curr.month + 1)
  1170. return months