vfs_plugin.py 68 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687
  1. # plugins/vfs_global_plugin.py
  2. import time
  3. import json
  4. import random
  5. import base64
  6. import re
  7. import urllib.parse
  8. from datetime import datetime
  9. from typing import Dict, Any, Optional, List, Tuple
  10. # 使用 curl_cffi 模拟浏览器 TLS 指纹,这是 VFS 必须的
  11. try:
  12. from curl_cffi import requests
  13. except ImportError:
  14. raise ImportError("Please install curl-cffi: pip install curl-cffi")
  15. # 加密库
  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, VSError # type: ignore
  20. from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, QueryWaitMode # type: ignore
  21. from vs_log_macros import VSC_INFO, VSC_ERROR, VSC_DEBUG, VSC_WARN # type: ignore
  22. from toolkit.vs_cloud_api import VSCloudApi # type: ignore
  23. from toolkit.rule_engine import RuleEngine # type: ignore
  24. # ----------------- 静态常量与辅助数据 -----------------
  25. VFS_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
  26. MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuupFgB+lYIOtSxrRoHzc
  27. LmCZKJ6+oSbgqgOPzFMM0TasOeLw0NXEn1XfIzXdx75+tegNKwyIZumoh0yhubKs
  28. t59GV321kN0iquYRHrdh3ygfDDHlS9rROQeBqRga0ncSADtbLMrBPqXJjPCoV76y
  29. t92towriKoH75BhiazY0mghm4LjmAWrV0u/GNpV3tk9bxbtHEXGaFmxCJqjg+7x6
  30. 1e5wXLfvpj9w1QsiSWOSJxLOyICz/9ByxXycQQFdNmjnnnwco9Gt/Mi33NYH71j0
  31. 5oXIjklFC4lvJqaqSY5lS7Vwb9oCt9zX9J0Yz4z4e/3V+0jgRnWOFGofyks4FKe2
  32. GQIDAQAB
  33. -----END PUBLIC KEY-----"""
  34. COUNTRY_MAP = {
  35. "china": "CHN", "france": "FRA", "germany": "DEU", "italy": "ITA",
  36. "united kingdom": "GBR", "united states": "USA", "india": "IND",
  37. "russia": "RUS", "turkey": "TUR", "vietnam": "VNM"
  38. }
  39. def get_country_iso3(name: str) -> str:
  40. return COUNTRY_MAP.get(name.lower(), "CHN")
  41. def get_alias_email(email: str, new_domain: str = "gmail-app.com") -> str:
  42. """
  43. 将邮箱域名替换为指定域名(默认 gmail-app.com)
  44. """
  45. if "@" not in email:
  46. raise ValueError(f"Invalid email: {email}")
  47. local_part, _ = email.rsplit("@", 1)
  48. return f"{local_part}@{new_domain}"
  49. # ----------------- VfsPlugin 类 -----------------
  50. class VfsPlugin(IVSPlg):
  51. def __init__(self, group_id: str):
  52. self.group_id = group_id
  53. self.config: Optional[VSPlgConfig] = None
  54. self.free_config: Dict[str, Any] = {}
  55. self.session = requests.Session()
  56. # 模拟 Chrome 124
  57. self.session.impersonate = "chrome124"
  58. self.jwt_token = ""
  59. self.user_agent = ""
  60. self.last_error = VSError(0, "OK")
  61. self.real_ip = ""
  62. # 缓存配置
  63. self.center_conf = None
  64. self.category_conf = {}
  65. self.subcategory_conf = {}
  66. # 加载公钥
  67. self.public_key = serialization.load_pem_public_key(
  68. VFS_PUBLIC_KEY_PEM.encode(),
  69. backend=default_backend()
  70. )
  71. # --- IVSPlg 接口实现 ---
  72. def get_group_id(self) -> str:
  73. return self.group_id
  74. def set_config(self, config: VSPlgConfig):
  75. self.config = config
  76. try:
  77. self.free_config = json.loads(config.free_config) if config.free_config else {}
  78. except:
  79. self.free_config = {}
  80. # 设置代理
  81. if config.proxy.ip:
  82. proxy_str = f"{config.proxy.scheme}://"
  83. if config.proxy.username:
  84. proxy_str += f"{config.proxy.username}:{config.proxy.password}@"
  85. proxy_str += f"{config.proxy.ip}:{config.proxy.port}"
  86. self.session.proxies = {"http": proxy_str, "https": proxy_str}
  87. VSC_DEBUG("vfs_plg", "[%s] Proxy set: %s", self.group_id, config.proxy.ip)
  88. def health_check(self) -> bool:
  89. return True
  90. def get_last_error(self) -> VSError:
  91. return self.last_error
  92. def create_session(self) -> bool:
  93. """登录流程"""
  94. VSC_INFO("vfs_plg", "[%s] Starting login...", self.group_id)
  95. # 1. Cloudflare Turnstile
  96. cf_token = self._handle_cloudflare_challenge()
  97. if not cf_token:
  98. return False
  99. # 2. 准备参数
  100. email = self.config.account.username
  101. password = self.config.account.password
  102. enc_password = self._encrypt_password(password)
  103. mission_code = self.free_config.get("missionCode", "")
  104. country_code = self.free_config.get("countryCode", "")
  105. client_src = self._get_client_source()
  106. orange_src = self._get_orange_source(email)
  107. url = "https://lift-api.vfsglobal.com/user/login"
  108. headers = self._get_common_headers(with_auth=False)
  109. headers.update({
  110. "clientsource": client_src,
  111. "orangex": orange_src,
  112. "content-type": "application/x-www-form-urlencoded"
  113. })
  114. data = {
  115. "username": email,
  116. "password": enc_password,
  117. "missioncode": mission_code,
  118. "countrycode": country_code,
  119. "languageCode": "en-US",
  120. "captcha_version": "cloudflare-v1",
  121. "captcha_api_key": cf_token
  122. }
  123. # 3. 发送登录请求 (包含 OPTIONS)
  124. if not self._perform_request("POST", url, headers=headers, data=data):
  125. return False
  126. try:
  127. resp_json = self.session.last_response.json()
  128. if "accessToken" in resp_json and resp_json["accessToken"]:
  129. self.jwt_token = resp_json["accessToken"]
  130. VSC_INFO("vfs_plg", "[%s] Login successful, JWT obtained.", self.group_id)
  131. return True
  132. # OTP 处理
  133. if resp_json.get("enableOTPAuthentication"):
  134. VSC_INFO("vfs_plg", "[%s] Login requires OTP.", self.group_id)
  135. otp = self._read_otp_email()
  136. if not otp:
  137. self._set_error(3001, "Failed to read Login OTP")
  138. return False
  139. return self._submit_login_otp(None, otp)
  140. self._set_error(1001, "Login failed: No access token or OTP flow.")
  141. return False
  142. except Exception as e:
  143. self._set_error(9001, f"Login parse error: {str(e)}")
  144. return False
  145. def query(self) -> VSQueryResult:
  146. """查询可预约 Slot"""
  147. result = VSQueryResult()
  148. apt_config = None
  149. target_tag = self.group_id
  150. appt_types = self.free_config.get("appointmentType", [])
  151. for apt in appt_types:
  152. if apt.get("tag") == target_tag or len(appt_types) == 1:
  153. apt_config = apt
  154. break
  155. if not apt_config:
  156. self._set_error(2001, "No matching appointment configuration found.")
  157. return result
  158. if not self._fetch_configurations(apt_config):
  159. return result
  160. earliest_date = []
  161. if not self._query_earliest_slot(apt_config, earliest_date):
  162. return result
  163. if not earliest_date:
  164. return result
  165. date_str = earliest_date[0]
  166. result.success = True
  167. result.visa_type = apt_config.get("subcategoryCode", "")
  168. result.city = apt_config.get("vacCode", "")
  169. result.country = self.free_config.get("countryCode", "")
  170. if "WaitList" in date_str:
  171. result.availability_status = AvailabilityStatus.Waitlist
  172. result.earliest_date = "WaitList"
  173. VSC_INFO("vfs_plg", "[%s] Found WaitList.", self.group_id)
  174. else:
  175. result.availability_status = AvailabilityStatus.Available
  176. result.earliest_date = date_str
  177. VSC_INFO("vfs_plg", "[%s] Found Slot: %s", self.group_id, date_str)
  178. day_info = VSQueryResult.DateAvailability()
  179. day_info.date = date_str
  180. result.availability.append(day_info)
  181. return result
  182. def _get_filtered_covered_months(self, start_date, end_date, from_date) -> List[str]:
  183. """
  184. 计算需要查询的月份列表,格式 YYYY-MM-DD (每月1号)
  185. """
  186. fmt = "%Y-%m-%d"
  187. # 默认值处理
  188. try:
  189. dt_start = datetime.strptime(start_date, fmt) if start_date else datetime.now()
  190. dt_end = datetime.strptime(end_date, fmt) if end_date else datetime.now().replace(year=datetime.now().year + 1)
  191. # from_date 格式可能是 DD/MM/YYYY (从 slot_info 来)
  192. try:
  193. dt_from = datetime.strptime(from_date, "%d/%m/%Y")
  194. except:
  195. dt_from = datetime.now()
  196. except:
  197. return []
  198. # 归一化到月初
  199. dt_start = dt_start.replace(day=1)
  200. dt_end = dt_end.replace(day=1)
  201. dt_from = dt_from.replace(day=1)
  202. # 起始点取 max(start, from)
  203. curr = max(dt_start, dt_from)
  204. months = []
  205. while curr <= dt_end:
  206. months.append(curr.strftime(fmt))
  207. # 下个月
  208. if curr.month == 12:
  209. curr = curr.replace(year=curr.year + 1, month=1)
  210. else:
  211. curr = curr.replace(month=curr.month + 1)
  212. return months
  213. def book(self, slot_info: VSQueryResult, user_inputs) -> VSBookResult:
  214. """
  215. 执行完整的预约流程 (对应 C++ VFSApi::book)
  216. 包含:上传文档 -> 添加申请人 -> OTP -> 选时间 -> 锁定 -> 支付
  217. """
  218. user_email = user_inputs.get('email', 'get_visa_666@example.com')
  219. user_inputs['alias_email'] = get_alias_email(user_email, new_domain="gmail-app.com")
  220. res = VSBookResult()
  221. # 定位 Appointment Config
  222. slot_routing_key = slot_info.routing_key
  223. # C++ 中 from_date 是入参,对应 Python 的 slot_info.earliest_date
  224. from_date = slot_info.earliest_date if slot_info.earliest_date else datetime.now().strftime("%d/%m/%Y")
  225. apt_config = None
  226. appt_types = self.free_config.get("appointmentType", [])
  227. for apt in appt_types:
  228. if apt.get("routingKey") == slot_routing_key or len(appt_types) == 1:
  229. apt_config = apt
  230. break
  231. if not apt_config:
  232. self._set_error(3001, "Book: Config missing.")
  233. return res
  234. # 刷新子配置 (获取 OCR/OTP 开关)
  235. if not self._fetch_configurations(apt_config):
  236. return res
  237. sub_cc = apt_config.get("subcategoryCode")
  238. sub_conf = self.subcategory_conf.get(sub_cc, {})
  239. # ---------------- OCR 识别 / 文档上传 ----------------
  240. # C++: bool ocr_enabled = ...
  241. ocr_enabled = sub_conf.get("isOCREnable", False)
  242. if ocr_enabled:
  243. VSC_INFO("vfs_plg", "[%s] OCR Enabled, uploading documents...", self.group_id)
  244. upload_res = {}
  245. if not self._upload_applicant_documents(apt_config, user_inputs, upload_res):
  246. return res
  247. # 回填上传结果到 user_inputs,供 add_primary_applicant 使用
  248. user_inputs["applicant_image"] = upload_res.get("passportImageFilename")
  249. user_inputs["applicant_image_data"] = upload_res.get("passportImageFileBytes") # Base64
  250. user_inputs["guid"] = upload_res.get("uploadDocumentGUID")
  251. # ---------------- 需要提供申请号 (Cover Letter) ----------------
  252. enable_reference_number = sub_conf.get("enableReferenceNumber", False)
  253. # ---------------- 添加申请人 (核心步骤 1) ----------------
  254. urn = []
  255. is_waitlist = (slot_info.availability_status == AvailabilityStatus.Waitlist)
  256. # C++: Retry loop for 422 Invalid Request
  257. add_primary_retry = 0
  258. MAX_RETRY = 3
  259. success_add = False
  260. while add_primary_retry < MAX_RETRY:
  261. urn = [] # 清空
  262. if self._add_primary_applicant(apt_config, user_inputs, is_waitlist, ocr_enabled, enable_reference_number, urn):
  263. success_add = True
  264. break
  265. # 检查是否是 422 错误
  266. err = self.get_last_error()
  267. # 注意:需要在 _perform_request 或 _add_primary_applicant 中正确解析并设置 error_code 为 422
  268. # 简单起见,如果 msg 包含 Invalid request 也算
  269. if err.error_code == 422 or "Invalid request" in err.error_message:
  270. VSC_WARN("vfs_plg", "[%s] Add Applicant 422 error, retrying in 10s...", self.group_id)
  271. time.sleep(10)
  272. add_primary_retry += 1
  273. else:
  274. # 其他错误直接退出
  275. return res
  276. if not success_add:
  277. self._set_error(3002, "Failed to add primary applicant after retries")
  278. return res
  279. final_urn = urn[0]
  280. VSC_INFO("vfs_plg", "[%s] Applicant Added. URN: %s", self.group_id, final_urn)
  281. # ---------------- 申请人 OTP 验证 (核心步骤 2) ----------------
  282. otp_enabled = sub_conf.get("isApplicantOTPEnabled", False)
  283. if otp_enabled:
  284. VSC_INFO("vfs_plg", "[%s] Applicant OTP Required.", self.group_id)
  285. if not self._applicant_otp_send(apt_config, final_urn):
  286. return res
  287. otp_code = self._read_otp_email() # 复用之前的读邮件逻辑
  288. if not otp_code:
  289. self._set_error(3003, "Failed to read Applicant OTP from email")
  290. return res
  291. if not self._applicant_otp_verify(apt_config, final_urn, otp_code):
  292. return res
  293. # ---------------- 如果是 Waitlist 模式,直接确认并返回 ----------------
  294. if is_waitlist:
  295. if self._confirm_waitlist(apt_config, final_urn):
  296. res.success = True
  297. res.urn = final_urn
  298. res.order_id = final_urn
  299. res.message = "Joined Waitlist"
  300. return res
  301. return res
  302. # ---------------- 规则引擎与日期筛选 (核心步骤 3) ----------------
  303. # C++: RuleEngine rule_engine(rules);
  304. rules_str = user_inputs.get("rules", "")
  305. rule_engine = RuleEngine(rules_str)
  306. expected_start = user_inputs.get("expected_start_date", "")
  307. expected_end = user_inputs.get("expected_end_date", "")
  308. rule_engine.set_date_range_start(expected_start)
  309. rule_engine.set_date_range_end(expected_end)
  310. # 计算需要扫描的月份
  311. # 如果 expected_start/end 为空,默认使用 from_date 所在月
  312. months = self._get_filtered_covered_months(expected_start, expected_end, from_date)
  313. VSC_INFO("vfs_plg", "[%s] Scanning months: %s (From: %s)", self.group_id, months, from_date)
  314. selected_slot_id = ""
  315. selected_slot_date = ""
  316. selected_slot_time_range = ""
  317. all_ads = set() # 记录所有有号日期,避免重复处理
  318. forbidden_dates = set()
  319. found_slot = False
  320. # 遍历月份寻找 Slot
  321. for m_str in months:
  322. # m_str format: YYYY-MM-DD (月初)
  323. # C++ 需要 DD/MM/YYYY
  324. try:
  325. dt_m = datetime.strptime(m_str, "%Y-%m-%d")
  326. converted_date = dt_m.strftime("%d/%m/%Y")
  327. except:
  328. continue
  329. ads = [] # Available Date Strings
  330. if not self._query_slot_calendar(apt_config, final_urn, converted_date, ads):
  331. time.sleep(3)
  332. continue
  333. if not ads:
  334. time.sleep(3)
  335. continue
  336. # 过滤已知的 slots
  337. new_ads = [d for d in ads if d not in all_ads]
  338. all_ads.update(new_ads)
  339. # 尝试 3 次选择
  340. for _ in range(3):
  341. # 排除 forbidden
  342. avail_candidates = [d for d in list(all_ads) if d not in forbidden_dates]
  343. # 规则筛选
  344. sel_dates = rule_engine.select_date(avail_candidates, "%d/%m/%Y")
  345. if not sel_dates:
  346. break # 当前月份符合规则的都没了
  347. tmp_date = sel_dates[0] # 取第一个符合规则的日期
  348. forbidden_dates.add(tmp_date) # 标记为已尝试
  349. # 审计日志 (C++ saveuseractionaudit)
  350. if not self._saveuseractionaudit(apt_config, final_urn, tmp_date):
  351. time.sleep(3)
  352. continue
  353. # 查询具体时间 (query_slot_time)
  354. ats = []
  355. if not self._query_slot_time(apt_config, final_urn, tmp_date, ats):
  356. time.sleep(3)
  357. continue
  358. if not ats:
  359. continue
  360. # 随机选择一个时间段
  361. sel_tm = random.choice(ats)
  362. selected_slot_id = sel_tm.get("allocationId")
  363. selected_slot_date = tmp_date
  364. selected_slot_time_range = sel_tm.get("slot")
  365. found_slot = True
  366. break
  367. if found_slot:
  368. break
  369. if not found_slot:
  370. self._set_error(3004, "No valid slots found after Rule Engine filtering.")
  371. return res
  372. VSC_INFO("vfs_plg", "[%s] Slot Selected: %s %s (ID: %s)",
  373. self.group_id, selected_slot_date, selected_slot_time_range, selected_slot_id)
  374. # ---------------- 服务、费用、最终预约 (核心步骤 4) ----------------
  375. # 跳过附加服务
  376. if not self._submit_no_addition_service(final_urn):
  377. return res
  378. # 查询费用
  379. amount = 0.0
  380. currency = ""
  381. amount, currency = self._query_fee(apt_config, final_urn)
  382. # 简单保留两位小数
  383. amount = round(amount, 2)
  384. # 最终提交 (Schedule)
  385. schedule_res = {}
  386. # C++: schedule_with_retry (max 3)
  387. schedule_success = False
  388. for _ in range(3):
  389. if self._schedule(apt_config, final_urn, amount, currency, selected_slot_id, schedule_res):
  390. schedule_success = True
  391. break
  392. # 检查是否是被防火墙拦截
  393. if self.get_last_error().error_code == 403:
  394. # 重新处理 CF
  395. self._handle_cloudflare_challenge()
  396. else:
  397. break # 其他错误不重试
  398. if not schedule_success:
  399. return res
  400. # ---------------- 构造返回结果 ----------------
  401. res.success = True
  402. res.book_date = selected_slot_date
  403. res.book_time = selected_slot_time_range
  404. res.urn = final_urn
  405. res.order_id = final_urn
  406. res.fee_amount = int(amount * 100)
  407. res.fee_currency = currency
  408. # 处理支付链接
  409. is_pay_req = schedule_res.get("IsPaymentRequired", False)
  410. if is_pay_req:
  411. payload = schedule_res.get("payLoad", "")
  412. payment_url = self._pay_request(payload)
  413. if payment_url:
  414. res.payment_link = payment_url
  415. # 保存 Session (C++ save_http_session)
  416. saved_session = self._save_http_session(payment_url)
  417. if saved_session:
  418. res.session_id = saved_session['session_id']
  419. else:
  420. res.message = "Booking confirmed (No payment required)"
  421. return res
  422. def _confirm_waitlist(self, apt_config: Dict[str, Any], urn: str) -> bool:
  423. """
  424. 确认加入候补名单 (对应 C++ VFSApi::confirm_waitlist)
  425. """
  426. url = "https://lift-api.vfsglobal.com/appointment/ConfirmWaitlist"
  427. headers = self._get_common_headers(with_auth=True)
  428. headers["content-type"] = "application/json;charset=UTF-8"
  429. data = {
  430. "missionCode": self.free_config.get("missionCode"),
  431. "countryCode": self.free_config.get("countryCode"),
  432. "centerCode": apt_config.get("vacCode"),
  433. "loginUser": self.config.account.username,
  434. "urn": urn,
  435. "notificationType": "none",
  436. "CanVFSReachoutToApplicant": True
  437. }
  438. # 发送请求
  439. if not self._perform_request("POST", url, headers=headers, json_data=data):
  440. return False
  441. try:
  442. j = self.session.last_response.json()
  443. # 1. 检查 API 返回的 error 字段
  444. if "error" in j and j["error"]:
  445. err_val = j["error"]
  446. # 兼容 error 为字符串或对象的情况
  447. if isinstance(err_val, str) and err_val:
  448. self._set_error(2006, f"Confirm Waitlist API Error: {err_val}")
  449. return False
  450. elif isinstance(err_val, dict) or isinstance(err_val, list):
  451. self._set_error(2006, f"Confirm Waitlist API Error: {err_val}")
  452. return False
  453. # 2. 检查 isConfirmed 字段
  454. if j.get("isConfirmed") is True:
  455. VSC_INFO("vfs_plg", "[%s] Waitlist confirmed successfully for URN: %s", self.group_id, urn)
  456. return True
  457. self._set_error(2007, f"Confirm Waitlist failed, response: {str(j)[:100]}")
  458. except Exception as e:
  459. self._set_error(9001, f"Confirm Waitlist parse error: {str(e)}")
  460. return False
  461. def _upload_applicant_documents(self, apt_config, user_inputs, res_out: Dict) -> bool:
  462. """上传护照图片"""
  463. url = "https://lift-api.vfsglobal.com/appointment/UploadApplicantDocument"
  464. passport_url = user_inputs.get("passport_image_url")
  465. if not passport_url:
  466. self._set_error(9007, "Missing passport_image_url")
  467. return False
  468. # 下载图片转 Base64 (C++ download_img_encode_base64)
  469. try:
  470. # 简单的下载,这里不使用 session,直接下载静态资源
  471. img_resp = requests.get(passport_url, timeout=30)
  472. if img_resp.status_code != 200:
  473. self._set_error(9008, "Failed to download passport image")
  474. return False
  475. b64_str = base64.b64encode(img_resp.content).decode('utf-8')
  476. except Exception as e:
  477. self._set_error(9008, f"Image download exception: {e}")
  478. return False
  479. headers = self._get_common_headers(with_auth=True)
  480. headers["content-type"] = "application/json;charset=UTF-8"
  481. data = {
  482. "missioncode": self.free_config.get("missionCode"),
  483. "countryCode": self.free_config.get("countryCode"),
  484. "centerCode": apt_config.get("vacCode"),
  485. "loginUser": self.config.account.username,
  486. "languageCode": "en-US",
  487. "visaCategoryCode": apt_config.get("subcategoryCode"),
  488. "fileBytes": b64_str,
  489. "selfiImageFileBytes": "" # 暂不支持自拍
  490. }
  491. if not self._perform_request("POST", url, headers=headers, json_data=data):
  492. return False
  493. try:
  494. j = self.session.last_response.json()
  495. if "error" in j and j["error"]:
  496. self._set_error(2005, f"Upload error: {j}")
  497. return False
  498. res_out.update(j)
  499. # 补全 C++ 逻辑中的模拟文件名
  500. res_out["passportImageFilename"] = "passport_img.jpg"
  501. res_out["passportImageFileBytes"] = b64_str
  502. return True
  503. except:
  504. return False
  505. def _add_primary_applicant(self, apt_config: Dict[str, Any], user_inputs: Dict[str, Any],
  506. is_waitlist: bool, ocr_enabled: bool, enable_ref: bool,
  507. urn_out_list: List[str]) -> bool:
  508. """
  509. 添加主申请人 (对应 C++ VFSApi::add_primary_applicant)
  510. 构造复杂的申请人 JSON payload 并提交
  511. """
  512. url = "https://lift-api.vfsglobal.com/appointment/applicants"
  513. headers = self._get_common_headers(with_auth=True)
  514. headers["content-type"] = "application/json;charset=UTF-8"
  515. # --- 辅助 Helper: 映射性别 ---
  516. # C++: male/Male -> 1, 否则 -> 2
  517. gender_str = str(user_inputs.get("gender", "")).lower()
  518. gender_code = 1 if gender_str == "male" else 2
  519. # --- 辅助 Helper: 获取 Dial Code ---
  520. # C++ 逻辑处理了 int 和 string
  521. raw_dial = user_inputs.get("phone_country_code", "86")
  522. dial_code = str(raw_dial)
  523. # --- 辅助 Helper: 格式化日期 ---
  524. # C++: to_ddmmyyyy (YYYY-MM-DD -> DD/MM/YYYY)
  525. def _to_ddmmyyyy(d_str):
  526. try:
  527. # 假设输入是 YYYY-MM-DD
  528. return datetime.strptime(d_str, "%Y-%m-%d").strftime("%d/%m/%Y")
  529. except:
  530. return d_str # 原样返回
  531. dob = _to_ddmmyyyy(str(user_inputs.get("birthday", "")))
  532. ppt_exp = _to_ddmmyyyy(str(user_inputs.get("passport_expiry_date", "")))
  533. # --- 构造单个 Applicant 对象 ---
  534. # 对应 C++ 中庞大的 applicant JSON 构建
  535. applicant = {
  536. "urn": "",
  537. "arn": "",
  538. "loginUser": self.config.account.username,
  539. # 基本信息 (全部大写)
  540. "firstName": str(user_inputs.get("first_name", "")).upper(),
  541. "middleName": "",
  542. "lastName": str(user_inputs.get("last_name", "")).upper(),
  543. "employerFirstName": "",
  544. "employerLastName": "",
  545. "salutation": "",
  546. "gender": gender_code,
  547. # 联系信息
  548. "contactNumber": str(user_inputs.get("phone", "")),
  549. "dialCode": dial_code,
  550. "employerContactNumber": "",
  551. "employerDialCode": "",
  552. "emailId": str(user_inputs.get("alias_email", "")).upper(),
  553. "employerEmailId": "",
  554. # 证件信息
  555. "passportNumber": str(user_inputs.get("passport_no", "")).upper(),
  556. "confirmPassportNumber": "", # 通常留空
  557. "passportExpirtyDate": ppt_exp, # 注意拼写 Expirty 是 VFS API 的特征
  558. "dateOfBirth": dob,
  559. "nationalId": None,
  560. # 国籍 (使用全局辅助函数 get_country_iso3)
  561. "nationalityCode": get_country_iso3(str(user_inputs.get("nationality", ""))),
  562. # 地址与其它 (大部分为空)
  563. "state": None,
  564. "city": None,
  565. "addressline1": None,
  566. "addressline2": None,
  567. "pincode": None,
  568. "isEndorsedChild": False,
  569. "applicantType": 0,
  570. "vlnNumber": None,
  571. "applicantGroupId": 0,
  572. "parentPassportNumber": "",
  573. "parentPassportExpiry": "",
  574. "dateOfDeparture": None,
  575. "entryType": "",
  576. "eoiVisaType": "",
  577. "passportType": "",
  578. "vfsReferenceNumber": "",
  579. "familyReunificationCerificateNumber": "",
  580. "PVRequestRefNumber": "",
  581. "PVStatus": "",
  582. "PVStatusDescription": "",
  583. "PVCanAllowRetry": True,
  584. "PVisVerified": False,
  585. "eefRegistrationNumber": "",
  586. "isAutoRefresh": True,
  587. "helloVerifyNumber": "",
  588. "OfflineCClink": "",
  589. "idenfystatuscheck": False,
  590. "vafStatus": None,
  591. "SpecialAssistance": "",
  592. "AdditionalRefNo": None,
  593. "juridictionCode": "",
  594. "canInitiateVAF": False,
  595. "canEditVAF": False,
  596. "canDeleteVAF": False,
  597. "canDownloadVAF": False,
  598. "Retryleft": "",
  599. # 真实 IP 注入
  600. "ipAddress": self.real_ip or "127.0.0.1"
  601. }
  602. # --- 处理 Reference Number (Cover Letter) ---
  603. if enable_ref:
  604. applicant["referenceNumber"] = str(user_inputs.get("cover_letter", ""))
  605. else:
  606. applicant["referenceNumber"] = None
  607. # --- 处理 OCR 数据 ---
  608. if ocr_enabled:
  609. # 必须从 user_inputs 获取上传后返回的 metadata
  610. applicant["applicantImage"] = str(user_inputs.get("applicant_image", ""))
  611. applicant["applicantImageData"] = str(user_inputs.get("applicant_image_data", ""))
  612. applicant["GUID"] = str(user_inputs.get("guid", ""))
  613. # --- 构造最外层 Payload ---
  614. payload = {
  615. "countryCode": self.free_config.get("countryCode"),
  616. "missionCode": self.free_config.get("missionCode"),
  617. "centerCode": apt_config.get("vacCode"),
  618. "loginUser": self.config.account.username,
  619. "visaCategoryCode": apt_config.get("subcategoryCode"),
  620. "applicantList": [applicant], # 数组形式
  621. "languageCode": "en-US",
  622. "isWaitlist": is_waitlist,
  623. "isEdit": False,
  624. "feeEntryTypeCode": None,
  625. "feeExemptionTypeCode": None,
  626. "feeExemptionDetailsCode": None,
  627. "juridictionCode": None,
  628. "regionCode": None
  629. }
  630. # --- 发送请求 ---
  631. if not self._perform_request("POST", url, headers=headers, json_data=payload):
  632. return False
  633. # --- 处理响应 ---
  634. try:
  635. j = self.session.last_response.json()
  636. # 1. 成功情况:返回了 urn
  637. if "urn" in j and j["urn"]:
  638. urn_out_list.append(j["urn"])
  639. return True
  640. # 2. 错误处理:检查是否为 422 Invalid Request (反爬/校验失败)
  641. # C++ 逻辑依赖于捕获这个特定的错误码来进行重试
  642. if "error" in j and j["error"]:
  643. err_data = j["error"]
  644. code = 0
  645. desc = ""
  646. if isinstance(err_data, dict):
  647. code = err_data.get("code", 0)
  648. desc = err_data.get("description", "") or err_data.get("message", "")
  649. # 设置到 last_error 以供上层重试逻辑检查
  650. if code == 422 or "Invalid request" in desc:
  651. self._set_error(422, f"Add Applicant 422: {desc}")
  652. else:
  653. self._set_error(3005, f"Add Applicant API Error: {desc}")
  654. return False
  655. except Exception as e:
  656. self._set_error(9001, f"Add Applicant parse error: {str(e)}")
  657. return False
  658. def _applicant_otp_send(self, apt_config, urn) -> bool:
  659. url = "https://lift-api.vfsglobal.com/appointment/applicantotp"
  660. headers = self._get_common_headers(with_auth=True)
  661. headers["content-type"] = "application/json;charset=UTF-8"
  662. data = {
  663. "urn": urn,
  664. "loginUser": self.config.account.username,
  665. "missionCode": self.free_config.get("missionCode"),
  666. "countryCode": self.free_config.get("countryCode"),
  667. "centerCode": apt_config.get("vacCode"),
  668. "OTP": "",
  669. "otpAction": "GENERATE",
  670. "languageCode": "en-US"
  671. }
  672. if not self._perform_request("POST", url, headers=headers, json_data=data):
  673. return False
  674. try:
  675. if self.session.last_response.json().get("isOTPGenerated"):
  676. return True
  677. except:
  678. pass
  679. return False
  680. def _applicant_otp_verify(self, apt_config, urn, otp) -> bool:
  681. url = "https://lift-api.vfsglobal.com/appointment/applicantotp"
  682. headers = self._get_common_headers(with_auth=True)
  683. # C++ specific: datacenter header
  684. headers["datacenter"] = "GERMANY"
  685. headers["content-type"] = "application/json;charset=UTF-8"
  686. data = {
  687. "urn": urn,
  688. "loginUser": self.config.account.username,
  689. "missionCode": self.free_config.get("missionCode"),
  690. "countryCode": self.free_config.get("countryCode"),
  691. "centerCode": apt_config.get("vacCode"),
  692. "OTP": otp,
  693. "otpAction": "VALIDATE",
  694. "languageCode": "en-US"
  695. }
  696. if not self._perform_request("POST", url, headers=headers, json_data=data):
  697. return False
  698. try:
  699. if self.session.last_response.json().get("isOTPValidated"):
  700. return True
  701. except:
  702. pass
  703. return False
  704. def _query_slot_calendar(self, apt_config, urn, from_date, ads_out: List) -> bool:
  705. url = "https://lift-api.vfsglobal.com/appointment/calendar"
  706. headers = self._get_common_headers(with_auth=True)
  707. headers["content-type"] = "application/json;charset=UTF-8"
  708. data = {
  709. "missionCode": self.free_config.get("missionCode"),
  710. "countryCode": self.free_config.get("countryCode"),
  711. "centerCode": apt_config.get("vacCode"),
  712. "loginUser": self.config.account.username,
  713. "visaCategoryCode": apt_config.get("subcategoryCode"),
  714. "fromDate": from_date,
  715. "urn": urn,
  716. "payCode": ""
  717. }
  718. if not self._perform_request("POST", url, headers=headers, json_data=data):
  719. return False
  720. try:
  721. j = self.session.last_response.json()
  722. calendars = j.get("calendars")
  723. if calendars:
  724. ads_out.clear()
  725. for item in calendars:
  726. # item["date"] is usually "MM/DD/YYYY" or "YYYY-MM-DD" depending on API version
  727. # C++ assumes "MM/DD/YYYY" -> "DD/MM/YYYY"
  728. raw = item.get("date")
  729. try:
  730. # Normalize to DD/MM/YYYY
  731. dObj = datetime.strptime(raw, "%m/%d/%Y")
  732. ads_out.append(dObj.strftime("%d/%m/%Y"))
  733. except:
  734. ads_out.append(raw)
  735. return True
  736. except:
  737. pass
  738. return False
  739. def _query_slot_time(self, apt_config, urn, slot_date, ats_out: List) -> bool:
  740. url = "https://lift-api.vfsglobal.com/appointment/timeslot"
  741. headers = self._get_common_headers(with_auth=True)
  742. headers["content-type"] = "application/json;charset=UTF-8"
  743. data = {
  744. "missionCode": self.free_config.get("missionCode"),
  745. "countryCode": self.free_config.get("countryCode"),
  746. "centerCode": apt_config.get("vacCode"),
  747. "loginUser": self.config.account.username,
  748. "visaCategoryCode": apt_config.get("subcategoryCode"),
  749. "slotDate": slot_date,
  750. "urn": urn
  751. }
  752. if not self._perform_request("POST", url, headers=headers, json_data=data):
  753. return False
  754. try:
  755. j = self.session.last_response.json()
  756. slots = j.get("slots")
  757. if slots:
  758. ats_out.extend(slots)
  759. return True
  760. except:
  761. pass
  762. return False
  763. def _saveuseractionaudit(self, apt_config, urn, earliest_date) -> bool:
  764. url = "https://lift-api.vfsglobal.com/appointment/saveuseractionaudit"
  765. headers = self._get_common_headers(with_auth=True)
  766. headers["content-type"] = "application/json;charset=UTF-8"
  767. # ISO format conversion
  768. try:
  769. dt = datetime.strptime(earliest_date, "%d/%m/%Y")
  770. iso_date = dt.strftime("%Y-%m-%dT%H:%M:%S")
  771. except:
  772. iso_date = earliest_date
  773. data = {
  774. "missionCode": self.free_config.get("missionCode"),
  775. "countryCode": self.free_config.get("countryCode"),
  776. "centerCode": apt_config.get("vacCode"),
  777. "loginUser": self.config.account.username,
  778. "urn": urn,
  779. "firstEarliestSlotDate": earliest_date,
  780. "action": "schedule",
  781. "ipAddress": self.real_ip or "127.0.0.1",
  782. "eadAppointmentDetail": iso_date
  783. }
  784. if not self._perform_request("POST", url, headers=headers, json_data=data):
  785. return False
  786. return self.session.last_response.json().get("isSavedSuccess", False)
  787. # ----------------- 内部功能函数 -----------------
  788. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None) -> bool:
  789. """
  790. 统一 HTTP 请求封装,严格复刻 C++ 逻辑:
  791. 1. 发送 OPTIONS 请求
  792. 2. 发送实际请求
  793. """
  794. print(f'[perform request] {method} {url}')
  795. # --- 1. 发送 OPTIONS 请求 ---
  796. try:
  797. # OPTIONS 请求使用相同的 URL 和 headers (部分 header 如 content-length 会被自动处理)
  798. # 某些服务器反爬会检测 OPTIONS 请求
  799. opt_headers = headers.copy() if headers else {}
  800. # 发送 OPTIONS
  801. self.session.request("OPTIONS", url, headers=opt_headers, timeout=10)
  802. # C++ 代码中并不检查 OPTIONS 的返回值,只检查执行是否成功
  803. # 这里我们假设只要不抛出异常即可
  804. except Exception as e:
  805. # 记录警告但不中断流程,防止仅仅是 OPTIONS 失败导致误判
  806. VSC_DEBUG("vfs_plg", "OPTIONS request failed (non-fatal): %s", str(e))
  807. # --- 2. 发送实际请求 ---
  808. try:
  809. resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
  810. self.session.last_response = resp
  811. if resp.status_code == 200:
  812. return True
  813. elif resp.status_code == 401:
  814. self._set_error(401, "Session Invalid (401)")
  815. elif resp.status_code == 403:
  816. self._set_error(403, "Blocked by Firewall (403)")
  817. elif resp.status_code == 429:
  818. self._set_error(429, "Rate Limited (429)")
  819. else:
  820. self._set_error(resp.status_code, f"HTTP Error {resp.status_code}: {resp.text[:100]}")
  821. return False
  822. except Exception as e:
  823. self._set_error(9000, f"Network Exception: {str(e)}")
  824. return False
  825. def _encrypt_password(self, password: str) -> str:
  826. ciphertext = self.public_key.encrypt(
  827. password.encode(),
  828. padding.OAEP(
  829. mgf=padding.MGF1(algorithm=hashes.SHA256()),
  830. algorithm=hashes.SHA256(),
  831. label=None
  832. )
  833. )
  834. return base64.b64encode(ciphertext).decode()
  835. def _get_orange_source(self, email: str) -> str:
  836. timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
  837. payload = f"{email};{timestamp}"
  838. return self._encrypt_password(payload)
  839. def _get_client_source(self) -> str:
  840. timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
  841. payload = f"GA;{timestamp}Z"
  842. return self._encrypt_password(payload)
  843. def _handle_cloudflare_challenge(self) -> str:
  844. """
  845. 完整实现的 Cloudflare Turnstile 验证逻辑
  846. 对应 C++ VFSApi::handle_cloudflare_challenge
  847. """
  848. # 1. 准备参数
  849. mission = self.free_config.get("missionCode", "")
  850. country = self.free_config.get("countryCode", "")
  851. if not mission or not country:
  852. self._set_error(9001, "Missing missionCode or countryCode in free_config")
  853. return ""
  854. website_url = f"https://visa.vfsglobal.com/{country}/en/{mission}/login"
  855. # 构造代理字符串传给打码平台 (格式: http://user:pass@ip:port)
  856. proxy_str = ""
  857. if self.config.proxy.ip:
  858. proxy_str = f"{self.config.proxy.scheme}://"
  859. if self.config.proxy.username:
  860. proxy_str += f"{self.config.proxy.username}:{self.config.proxy.password}@"
  861. proxy_str += f"{self.config.proxy.ip}:{self.config.proxy.port}"
  862. # 2. 提交任务
  863. VSC_INFO("vfs_plg", "[%s] Submitting Turnstile task for %s...", self.group_id, website_url)
  864. task_out = VSCloudApi.Instance().submit_anti_turnstile_task(proxy_str, website_url)
  865. if not task_out:
  866. self._set_error(9002, "Failed to submit captcha task to Cloud API")
  867. return ""
  868. task_id = str(task_out.get("id"))
  869. if not task_id:
  870. self._set_error(9002, "Cloud API returned invalid task ID")
  871. return ""
  872. # 3. 轮询结果 (超时时间 120秒)
  873. timeout = 120
  874. start_time = time.time()
  875. while time.time() - start_time < timeout:
  876. result_out = VSCloudApi.Instance().get_anti_turnstile_result(task_id)
  877. if not result_out:
  878. # 获取结果的网络请求失败,稍后重试
  879. time.sleep(3)
  880. continue
  881. # status: 0=Pending, 1=Processing, 2=Success, 3=Failed
  882. status = result_out.get("status", 0)
  883. if status == 2: # Success
  884. raw_result = result_out.get("result", "")
  885. try:
  886. # 4. 解析结果 JSON
  887. # 打码平台返回的 result 通常是一个 JSON 字符串,包含 token, userAgent, cookies
  888. if isinstance(raw_result, str):
  889. data = json.loads(raw_result)
  890. else:
  891. data = raw_result # 已经是 dict
  892. token = data.get("token")
  893. ua = data.get("userAgent")
  894. cookies_list = data.get("cookies", [])
  895. if not token:
  896. self._set_error(9004, "Captcha solved but token is empty")
  897. return ""
  898. # 5. 同步环境 (User-Agent 和 Cookies)
  899. # 这是最关键的一步,必须使用通过验证时的环境进行后续请求
  900. # A. 设置 User-Agent
  901. if ua:
  902. self.user_agent = ua
  903. self.session.headers["User-Agent"] = ua
  904. # 注意:curl_cffi 的 impersonate 可能会覆盖 header,
  905. # 如果打码平台返回的 UA 看起来像 Chrome 124,通常兼容性没问题。
  906. # 如果非常严格,可能需要根据返回的 UA 调整 impersonate 参数,但在 VFS 场景下
  907. # 只要 header 对了通常通过率就很高。
  908. # B. 设置 Cookies
  909. # 这里的 cookies 是 Turnstile 验证过程中生成的 (如 cf_clearance)
  910. if cookies_list:
  911. VSC_DEBUG("vfs_plg", "[%s] Syncing %d cookies from Captcha solver...", self.group_id, len(cookies_list))
  912. for cookie in cookies_list:
  913. # 兼容不同的 cookie 格式
  914. c_name = cookie.get("name")
  915. c_value = cookie.get("value")
  916. c_domain = cookie.get("domain", "")
  917. c_path = cookie.get("path", "/")
  918. if c_name and c_value:
  919. self.session.cookies.set(
  920. name=c_name,
  921. value=c_value,
  922. domain=c_domain,
  923. path=c_path
  924. )
  925. VSC_INFO("vfs_plg", "[%s] Cloudflare challenge passed.", self.group_id)
  926. return token
  927. except Exception as e:
  928. self._set_error(9005, f"Failed to parse captcha result JSON: {str(e)}")
  929. return ""
  930. elif status == 3: # Failed
  931. err_msg = result_out.get("result", "Unknown error")
  932. self._set_error(9003, f"Captcha task failed: {err_msg}")
  933. return ""
  934. else:
  935. # Pending / Processing
  936. time.sleep(3)
  937. self._set_error(9003, "Captcha task timeout (120s)")
  938. return ""
  939. def _get_common_headers(self, with_auth=True) -> Dict[str, str]:
  940. mission = self.free_config.get("missionCode", "")
  941. country = self.free_config.get("countryCode", "")
  942. lang = self.free_config.get("language", "en")
  943. route = f"{country}/{lang}/{mission}"
  944. h = {
  945. "accept": "application/json, text/plain, */*",
  946. "accept-language": "en-US,en;q=0.9",
  947. "origin": "https://visa.vfsglobal.com",
  948. "referer": "https://visa.vfsglobal.com/",
  949. "route": route
  950. }
  951. client_src = self._get_client_source()
  952. h["clientsource"] = client_src
  953. if with_auth and self.jwt_token:
  954. h["authorize"] = self.jwt_token
  955. return h
  956. def _query_earliest_slot(self, apt_config, earliest_date_out: List[str]) -> bool:
  957. """
  958. 查询最早 Slot (对应 C++ VFSApi::query_earliest_slot_with_retry)
  959. 增加了 403 被拦截时的 Cloudflare 自动绕过机制
  960. """
  961. url = "https://lift-api.vfsglobal.com/appointment/CheckIsSlotAvailable"
  962. data = {
  963. "missioncode": self.free_config.get("missionCode"),
  964. "countrycode": self.free_config.get("countryCode"),
  965. "vacCode": apt_config.get("vacCode"),
  966. "visaCategoryCode": apt_config.get("subcategoryCode"),
  967. "roleName": "Individual",
  968. "loginUser": self.config.account.username,
  969. "payCode": ""
  970. }
  971. max_retries = 3
  972. for attempt in range(max_retries):
  973. # 每次重试前重新获取 header,因为 handle_cloudflare_challenge 可能会更新 Token/UA
  974. headers = self._get_common_headers(with_auth=True)
  975. headers["content-type"] = "application/json;charset=UTF-8"
  976. # 发送请求
  977. if self._perform_request("POST", url, headers=headers, json_data=data):
  978. # --- 请求成功 (HTTP 200) ---
  979. resp_text = self.session.last_response.text
  980. # 1. 检查是否是 WaitList
  981. if "WaitList" in resp_text:
  982. earliest_date_out.append("WaitList")
  983. return True
  984. # 2. 解析日期
  985. try:
  986. j = self.session.last_response.json()
  987. if j.get("earliestSlotLists"):
  988. raw_date = j["earliestSlotLists"][0]["date"]
  989. # raw_date 示例: "09/10/2025 00:00:00" (表示 2025年9月10日)
  990. try:
  991. # 1. 按 MM/DD/YYYY HH:MM:SS 解析
  992. dt = datetime.strptime(raw_date, "%m/%d/%Y %H:%M:%S")
  993. # 2. 转为 DD/MM/YYYY
  994. std_date = dt.strftime("%d/%m/%Y")
  995. earliest_date_out.append(std_date)
  996. return True
  997. except ValueError:
  998. # 备用:万一格式变了 (比如变成了 YYYY-MM-DD),尝试其他解析或原样返回
  999. # 这里记录警告,防止 silently fail
  1000. VSC_WARN("vfs_plg", "[%s] Date parse warning: '%s' not matching %%m/%%d/%%Y", self.group_id, raw_date)
  1001. # 尝试直接分割,虽然可能格式不对,但总比崩溃好
  1002. earliest_date_out.append(raw_date.split(" ")[0])
  1003. return True
  1004. except Exception as e:
  1005. VSC_DEBUG("vfs_plg", f"Parse earliest slot error: {e}")
  1006. pass
  1007. # 虽然 HTTP 200 但没有 slot 数据
  1008. self._set_error(2002, "No slots found in response")
  1009. return False
  1010. else:
  1011. # --- 请求失败 (HTTP != 200) ---
  1012. err = self.get_last_error()
  1013. # 关键逻辑:如果被防火墙拦截 (403),尝试过盾
  1014. if err.error_code == 403:
  1015. VSC_WARN("vfs_plg", "[%s] Query Blocked (403) - Attempt %d/%d. Solving Cloudflare...",
  1016. self.group_id, attempt + 1, max_retries)
  1017. # 调用过盾逻辑
  1018. cf_token = self._handle_cloudflare_challenge()
  1019. if cf_token:
  1020. # 过盾成功,随机冷却 1-3 秒后重试
  1021. time.sleep(random.uniform(1, 3))
  1022. continue
  1023. else:
  1024. # 过盾失败,无法继续
  1025. self._set_error(403, "Cloudflare challenge failed during query retry")
  1026. return False
  1027. elif err.error_code == 401:
  1028. # Session 失效,通常需要重新登录,这里不重试,直接返回失败让上层处理
  1029. return False
  1030. else:
  1031. # 其他错误 (500, 404 等),不立即重试
  1032. return False
  1033. # 超过最大重试次数
  1034. self._set_error(403, "Query blocked by firewall after max retries")
  1035. return False
  1036. def _set_error(self, code, msg):
  1037. self.last_error = VSError(code, msg)
  1038. VSC_ERROR("vfs_plg", "[%s] Error %d: %s", self.group_id, code, msg)
  1039. def _fmt_date(self, yyyy_mm_dd):
  1040. try:
  1041. return datetime.strptime(yyyy_mm_dd, "%Y-%m-%d").strftime("%d/%m/%Y")
  1042. except:
  1043. return yyyy_mm_dd
  1044. def _fetch_configurations(self, apt_config: Dict[str, Any]) -> bool:
  1045. """
  1046. 获取并缓存签证中心、类别、子类别配置
  1047. 对应 C++ VFSApi::fetch_configurations
  1048. """
  1049. # 1. 获取所有中心配置 (query_center)
  1050. if not self.center_conf:
  1051. centers = []
  1052. if not self._query_center(centers):
  1053. return False
  1054. self.center_conf = centers
  1055. # 2. 获取 Visa Category 配置
  1056. vac_code = apt_config.get("vacCode")
  1057. category_code = apt_config.get("categoryCode")
  1058. # 检查是否已缓存该 VAC 的 category 配置
  1059. # C++ 逻辑是: _category_configuration[category_code] = vc
  1060. # 但这里逻辑似乎是按 category_code 索引。为了保险,我们按 C++ 逻辑实现。
  1061. # 注意:C++ map key 是 vac_code 还是 category_code?
  1062. # C++ 代码中: if (!_category_configuration.contains(vac_code)...)
  1063. # 但存进去是用 category_code 作为 key: _category_configuration[category_code] = vc;
  1064. # 这在 Python 中有点奇怪,我们这里使用字典:self.category_conf[category_code] = config
  1065. # 为了避免重复查询,我们需要知道是否已经查询过这个 VAC。
  1066. # 简单起见,检查目标 category_code 是否已在缓存中
  1067. if category_code not in self.category_conf:
  1068. visa_categories = []
  1069. if not self._query_visa_category(vac_code, visa_categories):
  1070. return False
  1071. found = False
  1072. for vc in visa_categories:
  1073. if vc.get("code") == category_code:
  1074. self.category_conf[category_code] = vc
  1075. found = True
  1076. break
  1077. # 如果没找到,可能配置错误,但 C++ 没报错,只继续
  1078. if not found:
  1079. VSC_WARN("vfs_plg", "[%s] Category code '%s' not found in VAC '%s'",
  1080. self.group_id, category_code, vac_code)
  1081. # 3. 获取 Visa SubCategory 配置
  1082. sub_category_code = apt_config.get("subcategoryCode")
  1083. if sub_category_code not in self.subcategory_conf:
  1084. visa_subcategories = []
  1085. if not self._query_visa_sub_category(vac_code, category_code, visa_subcategories):
  1086. return False
  1087. found = False
  1088. for svc in visa_subcategories:
  1089. if svc.get("code") == sub_category_code:
  1090. self.subcategory_conf[sub_category_code] = svc
  1091. found = True
  1092. break
  1093. if not found:
  1094. VSC_WARN("vfs_plg", "[%s] SubCategory code '%s' not found",
  1095. self.group_id, sub_category_code)
  1096. return True
  1097. def _query_center(self, centers_out: List) -> bool:
  1098. """对应 C++ VFSApi::query_center"""
  1099. mission = self.free_config.get("missionCode")
  1100. country = self.free_config.get("countryCode")
  1101. url = f"https://lift-api.vfsglobal.com/master/center/{mission}/{country}/en-US"
  1102. headers = self._get_common_headers(with_auth=False) # 通常 Master API 不需要 Auth 或者是独立的
  1103. # 实际上根据 C++ 代码,这里使用的是 _get_client_source 生成的 headers,且不需要 authorize token
  1104. # 但 C++ 代码中也没有明确加 authorize header,除非 _jwt_token 不为空
  1105. # 保险起见,如果有了 Token 就带上,没有就不带 (get_common_headers 默认逻辑)
  1106. if not self._perform_request("GET", url, headers=headers):
  1107. return False
  1108. try:
  1109. j = self.session.last_response.json()
  1110. if isinstance(j, list):
  1111. centers_out.extend(j)
  1112. return True
  1113. else:
  1114. self._set_error(2003, "query_center response is not a list")
  1115. except Exception as e:
  1116. self._set_error(9001, f"query_center parse error: {e}")
  1117. return False
  1118. def _query_visa_category(self, center_code: str, visa_category_out: List) -> bool:
  1119. """对应 C++ VFSApi::query_visa_category"""
  1120. mission = self.free_config.get("missionCode")
  1121. country = self.free_config.get("countryCode")
  1122. # URL Encode
  1123. enc_center = urllib.parse.quote(center_code)
  1124. url = f"https://lift-api.vfsglobal.com/master/visacategory/{mission}/{country}/{enc_center}/en-US"
  1125. headers = self._get_common_headers(with_auth=False)
  1126. if not self._perform_request("GET", url, headers=headers):
  1127. return False
  1128. try:
  1129. j = self.session.last_response.json()
  1130. # C++ 增加了错误检查
  1131. if isinstance(j, list):
  1132. if j and "error" in j[0] and j[0]["error"]:
  1133. self._set_error(2004, f"API Error in query_visa_category: {j[0]}")
  1134. return False
  1135. visa_category_out.extend(j)
  1136. return True
  1137. else:
  1138. self._set_error(2003, "query_visa_category response is not a list")
  1139. except Exception as e:
  1140. self._set_error(9001, f"query_visa_category parse error: {e}")
  1141. return False
  1142. def _query_visa_sub_category(self, center_code: str, category_code: str, visa_sub_category_out: List) -> bool:
  1143. """对应 C++ VFSApi::query_visa_sub_category"""
  1144. mission = self.free_config.get("missionCode")
  1145. country = self.free_config.get("countryCode")
  1146. enc_center = urllib.parse.quote(center_code)
  1147. enc_cat = urllib.parse.quote(category_code)
  1148. url = f"https://lift-api.vfsglobal.com/master/subvisacategory/{mission}/{country}/{enc_center}/{enc_cat}/en-US"
  1149. headers = self._get_common_headers(with_auth=False)
  1150. if not self._perform_request("GET", url, headers=headers):
  1151. return False
  1152. try:
  1153. j = self.session.last_response.json()
  1154. if isinstance(j, list):
  1155. if j and "error" in j[0] and j[0]["error"]:
  1156. self._set_error(2004, f"API Error in query_visa_sub_category: {j[0]}")
  1157. return False
  1158. visa_sub_category_out.extend(j)
  1159. return True
  1160. else:
  1161. self._set_error(2003, "query_visa_sub_category response is not a list")
  1162. except Exception as e:
  1163. self._set_error(9001, f"query_visa_sub_category parse error: {e}")
  1164. return False
  1165. def _read_otp_email(self) -> str:
  1166. """
  1167. 读取 OTP 邮件 (对应 C++ VFSApi::read_otp_code)
  1168. """
  1169. # 1. 定义 C++ 代码中的常量
  1170. master_email = "visafly666@gmail.com"
  1171. recipient = self.config.account.username
  1172. # 注意:C++ 代码中 sender 是 "donotreply at vfshelpline.com"
  1173. sender = "donotreply at vfshelpline.com"
  1174. subject_keywords = "One Time Password"
  1175. body_keywords = "OTP"
  1176. # 2. 获取当前 UTC 时间并格式化 (对应 C++ std::put_time "%Y-%m-%d %H:%M:%S")
  1177. # 用于告诉云端只查询这个时间点之后收到的邮件
  1178. now_utc = datetime.utcnow()
  1179. formatted_utc_time = now_utc.strftime("%Y-%m-%d %H:%M:%S")
  1180. VSC_INFO("vfs_plg", "[%s] Waiting for OTP email sent after %s...", self.group_id, formatted_utc_time)
  1181. # 3. 轮询查收 (C++ 逻辑通常由外部调度,Python 插件内部实现轮询更稳健)
  1182. # 尝试 12 次,每次间隔 5 秒,共 60 秒
  1183. for i in range(12):
  1184. content_out = [] # 用于接收结果的容器
  1185. # 对应 C++: expiry = 5 * 60 (300秒)
  1186. content_out = VSCloudApi.Instance().fetch_mail_content(
  1187. master_email,
  1188. sender,
  1189. recipient,
  1190. subject_keywords,
  1191. body_keywords,
  1192. formatted_utc_time,
  1193. 300
  1194. )
  1195. if content_out:
  1196. # 4. 正则匹配 6位数字 (对应 C++ std::regex otp_pattern(R"(\b\d{6}\b)"))
  1197. match = re.search(r'\b\d{6}\b', content_out)
  1198. if match:
  1199. otp = match.group(0)
  1200. VSC_INFO("vfs_plg", "[%s] OTP code found: %s", self.group_id, otp)
  1201. return otp
  1202. # 未找到,等待重试
  1203. time.sleep(5)
  1204. if i % 2 == 0:
  1205. VSC_DEBUG("vfs_plg", "[%s] OTP not found yet, retrying...", self.group_id)
  1206. # 5. 超时处理
  1207. self._set_error(4004, "OTP email not found (timeout)")
  1208. return ""
  1209. def _submit_login_otp(self, cf_token: str, otp: str) -> bool:
  1210. """
  1211. 提交 OTP 验证码进行登录 (对应 C++ VFSApi::submit_otp_code)
  1212. """
  1213. VSC_INFO("vfs_plg", "[%s] Submitting Login OTP...", self.group_id)
  1214. # 1. 准备基础数据
  1215. email = self.config.account.username
  1216. password = self.config.account.password
  1217. enc_password = self._encrypt_password(password)
  1218. mission_code = self.free_config.get("missionCode", "")
  1219. country_code = self.free_config.get("countryCode", "")
  1220. # 2. 生成加密 Source (每次请求时间戳不同,建议重新生成)
  1221. client_src = self._get_client_source()
  1222. orange_src = self._get_orange_source(email)
  1223. # 3. 构造请求
  1224. url = "https://lift-api.vfsglobal.com/user/login"
  1225. headers = self._get_common_headers(with_auth=False)
  1226. headers.update({
  1227. "clientsource": client_src,
  1228. "orangex": orange_src,
  1229. "content-type": "application/x-www-form-urlencoded"
  1230. })
  1231. # 注意:C++ 代码中在此处会再次调用 handle_cloudflare_challenge 获取新 token。
  1232. # 如果传入的 cf_token 已经在上一步(密码登录)中被消耗,这里可能需要重新获取。
  1233. # 为了稳健,如果传入为空,尝试重新获取。
  1234. if not cf_token:
  1235. VSC_DEBUG("vfs_plg", "[%s] CF Token is empty, regenerating for OTP...", self.group_id)
  1236. cf_token = self._handle_cloudflare_challenge()
  1237. if not cf_token:
  1238. return False
  1239. data = {
  1240. "username": email,
  1241. "password": enc_password,
  1242. "missioncode": mission_code,
  1243. "countrycode": country_code,
  1244. "languageCode": "en-US",
  1245. "captcha_version": "cloudflare-v1",
  1246. "captcha_api_key": cf_token,
  1247. "otp": otp # 关键字段:OTP 验证码
  1248. }
  1249. # 4. 发送请求 (POST form-urlencoded)
  1250. if not self._perform_request("POST", url, headers=headers, data=data):
  1251. return False
  1252. # 5. 处理响应
  1253. try:
  1254. resp_json = self.session.last_response.json()
  1255. # A. 检查错误
  1256. if "error" in resp_json and resp_json["error"]:
  1257. err_obj = resp_json["error"]
  1258. # 兼容不同的错误结构 (message 或 description)
  1259. msg = ""
  1260. if isinstance(err_obj, dict):
  1261. msg = err_obj.get("message") or err_obj.get("description") or "Unknown error"
  1262. else:
  1263. msg = str(err_obj)
  1264. if "locked" in str(msg).lower():
  1265. self._set_error(4005, f"Account Locked during OTP: {msg}")
  1266. else:
  1267. self._set_error(4006, f"OTP Login Error: {msg}")
  1268. return False
  1269. # B. 检查 AccessToken (登录成功)
  1270. if "accessToken" in resp_json and resp_json["accessToken"]:
  1271. self.jwt_token = resp_json["accessToken"]
  1272. VSC_INFO("vfs_plg", "[%s] OTP Login successful, JWT obtained.", self.group_id)
  1273. return True
  1274. self._set_error(9006, f"OTP Login failed, unknown response: {str(resp_json)[:100]}")
  1275. return False
  1276. except Exception as e:
  1277. self._set_error(9001, f"OTP Login parse error: {str(e)}")
  1278. return False
  1279. def _submit_no_addition_service(self, urn) -> bool:
  1280. url = "https://lift-api.vfsglobal.com/vas/mapvas"
  1281. headers = self._get_common_headers(with_auth=True)
  1282. headers["content-type"] = "application/json;charset=UTF-8"
  1283. data = {
  1284. "loginUser": self.config.account.username,
  1285. "missionCode": self.free_config.get("missionCode"),
  1286. "countryCode": self.free_config.get("countryCode"),
  1287. "urn": urn,
  1288. "applicants": []
  1289. }
  1290. # C++ 只请求不检查结果,或者只要200就行
  1291. return self._perform_request("POST", url, headers=headers, json_data=data)
  1292. def _query_fee(self, apt_config, urn) -> Tuple[float, str]:
  1293. url = "https://lift-api.vfsglobal.com/appointment/fees"
  1294. headers = self._get_common_headers(with_auth=True)
  1295. headers["content-type"] = "application/json;charset=UTF-8"
  1296. data = {
  1297. "missionCode": self.free_config.get("missionCode"),
  1298. "countryCode": self.free_config.get("countryCode"),
  1299. "centerCode": apt_config.get("vacCode"),
  1300. "loginUser": self.config.account.username,
  1301. "urn": urn,
  1302. "languageCode": "en-US"
  1303. }
  1304. if self._perform_request("POST", url, headers=headers, json_data=data):
  1305. try:
  1306. j = self.session.last_response.json()
  1307. amt = j.get("totalamount", 0.0)
  1308. curr = ""
  1309. if j.get("feeDetails"):
  1310. curr = j["feeDetails"][0].get("currency", "")
  1311. return float(amt), curr
  1312. except:
  1313. pass
  1314. return 0.0, ""
  1315. def _schedule(self, apt_config, urn, amount, currency, slot_id, res_out: Dict) -> bool:
  1316. url = "https://lift-api.vfsglobal.com/appointment/schedule"
  1317. headers = self._get_common_headers(with_auth=True)
  1318. headers["content-type"] = "application/json;charset=UTF-8"
  1319. data = {
  1320. "missionCode": self.free_config.get("missionCode"),
  1321. "countryCode": self.free_config.get("countryCode"),
  1322. "centerCode": apt_config.get("vacCode"),
  1323. "loginUser": self.config.account.username,
  1324. "urn": urn,
  1325. "notificationType": "none",
  1326. "paymentdetails": {
  1327. "paymentmode": "Online",
  1328. "RequestRefNo": "",
  1329. "clientId": "",
  1330. "merchantId": "",
  1331. "amount": amount,
  1332. "currency": currency
  1333. },
  1334. "allocationId": slot_id,
  1335. "CanVFSReachoutToApplicant": True
  1336. }
  1337. if not self._perform_request("POST", url, headers=headers, json_data=data):
  1338. return False
  1339. try:
  1340. j = self.session.last_response.json()
  1341. if j.get("IsAppointmentBooked"):
  1342. res_out.update(j)
  1343. return True
  1344. except:
  1345. pass
  1346. return False
  1347. def _pay_request(self, payload) -> str:
  1348. # C++: 检查 301/302 Redirect Location
  1349. url = f"https://online.vfsglobal.com/PG-Component/Payment/PayRequest?payLoad={payload}"
  1350. headers = {
  1351. "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
  1352. "referer": "https://visa.vfsglobal.com/",
  1353. "user-agent": self.user_agent
  1354. }
  1355. try:
  1356. # allow_redirects=False 以捕获 Location header
  1357. resp = self.session.get(url, headers=headers, allow_redirects=False)
  1358. if resp.status_code in [301, 302]:
  1359. loc = resp.headers.get("Location", "")
  1360. if loc.startswith("http"):
  1361. return loc
  1362. else:
  1363. return "https://online.vfsglobal.com" + loc if loc.startswith("/") else "https://online.vfsglobal.com/" + loc
  1364. except:
  1365. pass
  1366. return ""
  1367. def _save_http_session(self, page_url):
  1368. """
  1369. 提取 cookies, local_storage, 存入 VSCloudApi
  1370. 修复:curl_cffi 没有 requests.utils.dict_from_cookiejar,需手动提取
  1371. """
  1372. cookies_dict = {}
  1373. try:
  1374. # 方式 1: curl_cffi 的 cookies 对象通常支持 get_dict()
  1375. if hasattr(self.session.cookies, "get_dict"):
  1376. cookies_dict = self.session.cookies.get_dict()
  1377. else:
  1378. # 方式 2: 迭代 (兼容标准 CookieJar)
  1379. for c in self.session.cookies:
  1380. cookies_dict[c.name] = c.value
  1381. except Exception as e:
  1382. VSC_WARN("vfs_plg", "[%s] Failed to extract cookies: %s", self.group_id, str(e))
  1383. cookies_str = json.dumps(cookies_dict)
  1384. # 简单生成 SessionID hash
  1385. ua_str = self.user_agent or "unknown_ua"
  1386. raw = cookies_str + ua_str + page_url
  1387. try:
  1388. session_id = hashes.Hash(hashes.SHA256(), backend=default_backend())
  1389. session_id.update(raw.encode())
  1390. sid = session_id.finalize().hex()
  1391. proxy_str = ""
  1392. if self.config.proxy.ip:
  1393. proxy_str = f"{self.config.proxy.scheme}://"
  1394. if self.config.proxy.username:
  1395. proxy_str += f"{self.config.proxy.username}:{self.config.proxy.password}@"
  1396. proxy_str += f"{self.config.proxy.ip}:{self.config.proxy.port}"
  1397. saved_session = VSCloudApi.Instance().create_http_session(
  1398. sid, cookies_str, "", ua_str, proxy_str, page_url
  1399. )
  1400. if saved_session:
  1401. VSC_INFO("vfs_plg", "[%s] Session saved. ID: %s", self.group_id, sid)
  1402. else:
  1403. VSC_ERROR("vfs_plg", "[%s] Session save failed. ID: %s", self.group_id, sid)
  1404. return saved_session
  1405. except Exception as e:
  1406. # 捕获异常,确保即使保存 Session 失败,也不影响主流程返回预订结果
  1407. VSC_WARN("vfs_plg", "[%s] Failed to save session to cloud: %s", self.group_id, str(e))
  1408. return None