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