vfs_plugin.py 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273
  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, Callable
  10. from curl_cffi import requests, const
  11. # 加密库
  12. from cryptography.hazmat.primitives import serialization, hashes
  13. from cryptography.hazmat.primitives.asymmetric import padding
  14. from cryptography.hazmat.backends import default_backend
  15. from vs_plg import IVSPlg
  16. from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, DateAvailability, AvailabilityStatus, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  17. from toolkit.vs_cloud_api import VSCloudApi
  18. # ----------------- 静态常量与辅助数据 -----------------
  19. VFS_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
  20. MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuupFgB+lYIOtSxrRoHzc
  21. LmCZKJ6+oSbgqgOPzFMM0TasOeLw0NXEn1XfIzXdx75+tegNKwyIZumoh0yhubKs
  22. t59GV321kN0iquYRHrdh3ygfDDHlS9rROQeBqRga0ncSADtbLMrBPqXJjPCoV76y
  23. t92towriKoH75BhiazY0mghm4LjmAWrV0u/GNpV3tk9bxbtHEXGaFmxCJqjg+7x6
  24. 1e5wXLfvpj9w1QsiSWOSJxLOyICz/9ByxXycQQFdNmjnnnwco9Gt/Mi33NYH71j0
  25. 5oXIjklFC4lvJqaqSY5lS7Vwb9oCt9zX9J0Yz4z4e/3V+0jgRnWOFGofyks4FKe2
  26. GQIDAQAB
  27. -----END PUBLIC KEY-----"""
  28. COUNTRY_MAP = {
  29. "afghanistan": "AFG", "albania": "ALB", "algeria": "DZA", "andorra": "AND", "angola": "AGO",
  30. "antigua and barbuda": "ATG", "argentina": "ARG", "armenia": "ARM", "australia": "AUS", "austria": "AUT",
  31. "azerbaijan": "AZE", "bahamas": "BHS", "bahrain": "BHR", "bangladesh": "BGD", "barbados": "BRB", "belarus": "BLR",
  32. "belgium": "BEL", "belize": "BLZ", "benin": "BEN", "bhutan": "BTN", "bolivia": "BOL", "bosnia and herzegovina": "BIH",
  33. "botswana": "BWA", "brazil": "BRA", "brunei": "BRN", "bulgaria": "BGR", "burkina faso": "BFA", "burundi": "BDI",
  34. "cabo verde": "CPV", "cambodia": "KHM", "cameroon": "CMR", "canada": "CAN", "central african republic": "CAF",
  35. "chad": "TCD", "chile": "CHL", "china": "CHN", "colombia": "COL", "comoros": "COM", "congo (brazzaville)": "COG",
  36. "congo (kinshasa)": "COD", "costa rica": "CRI", "croatia": "HRV", "cuba": "CUB", "cyprus": "CYP", "czech republic": "CZE",
  37. "denmark": "DNK", "djibouti": "DJI", "dominica": "DMA", "dominican republic": "DOM", "ecuador": "ECU", "egypt": "EGY",
  38. "el salvador": "SLV", "equatorial guinea": "GNQ", "eritrea": "ERI", "estonia": "EST", "eswatini": "SWZ", "ethiopia": "ETH",
  39. "fiji": "FJI", "finland": "FIN", "france": "FRA", "gabon": "GAB", "gambia": "GMB", "georgia": "GEO", "germany": "DEU",
  40. "ghana": "GHA", "greece": "GRC", "grenada": "GRD", "guatemala": "GTM", "guinea": "GIN", "guinea-bissau": "GNB", "guyana": "GUY",
  41. "haiti": "HTI", "honduras": "HND", "hungary": "HUN", "iceland": "ISL", "india": "IND", "indonesia": "IDN", "iran": "IRN",
  42. "iraq": "IRQ", "ireland": "IRL", "israel": "ISR", "italy": "ITA", "jamaica": "JAM", "japan": "JPN", "jordan": "JOR",
  43. "kazakhstan": "KAZ", "kenya": "KEN", "kiribati": "KIR", "korea, north": "PRK", "korea, south": "KOR", "kuwait": "KWT",
  44. "kyrgyzstan": "KGZ", "laos": "LAO", "latvia": "LVA", "lebanon": "LBN", "lesotho": "LSO", "liberia": "LBR", "libya": "LBY",
  45. "liechtenstein": "LIE", "lithuania": "LTU", "luxembourg": "LUX", "madagascar": "MDG", "malawi": "MWI", "malaysia": "MYS",
  46. "maldives": "MDV", "mali": "MLI", "malta": "MLT", "marshall islands": "MHL", "mauritania": "MRT", "mauritius": "MUS",
  47. "mexico": "MEX", "micronesia": "FSM", "moldova": "MDA", "monaco": "MCO", "mongolia": "MNG", "montenegro": "MNE", "morocco": "MAR",
  48. "mozambique": "MOZ", "myanmar": "MMR", "namibia": "NAM", "nauru": "NRU", "nepal": "NPL", "netherlands": "NLD", "new zealand": "NZL",
  49. "nicaragua": "NIC", "niger": "NER", "nigeria": "NGA", "north macedonia": "MKD", "norway": "NOR", "oman": "OMN", "pakistan": "PAK",
  50. "palau": "PLW", "panama": "PAN", "papua new guinea": "PNG", "paraguay": "PRY", "peru": "PER", "philippines": "PHL", "poland": "POL",
  51. "portugal": "PRT", "qatar": "QAT", "romania": "ROU", "russia": "RUS", "rwanda": "RWA", "saudi arabia": "SAU", "senegal": "SEN",
  52. "serbia": "SRB", "seychelles": "SYC", "sierra leone": "SLE", "singapore": "SGP", "slovakia": "SVK", "slovenia": "SVN",
  53. "solomon islands": "SLB", "somalia": "SOM", "south africa": "ZAF", "spain": "ESP", "sri lanka": "LKA", "sudan": "SDN",
  54. "suriname": "SUR", "sweden": "SWE", "switzerland": "CHE", "syria": "SYR", "tajikistan": "TJK", "tanzania": "TZA", "thailand": "THA",
  55. "timor-leste": "TLS", "togo": "TGO", "tonga": "TON", "tunisia": "TUN", "turkey": "TUR", "turkmenistan": "TKM", "uganda": "UGA",
  56. "ukraine": "UKR", "united arab emirates": "ARE", "united kingdom": "GBR", "united states": "USA", "uruguay": "URY", "uzbekistan": "UZB",
  57. "vanuatu": "VUT", "venezuela": "VEN", "vietnam": "VNM", "yemen": "YEM", "zambia": "ZMB", "zimbabwe": "ZWE"
  58. }
  59. def get_country_iso3(name: str) -> str:
  60. return COUNTRY_MAP.get(name.lower(), "CHN")
  61. def get_alias_email(email: str, new_domain: str = "gmail-app.com") -> str:
  62. """
  63. 将邮箱域名替换为指定域名(默认 gmail-app.com)
  64. """
  65. if "@" not in email:
  66. raise ValueError(f"Invalid email: {email}")
  67. local_part, _ = email.rsplit("@", 1)
  68. return f"{local_part}@{new_domain}"
  69. def to_yyyymmdd(data_str: str, date_str_format: str, target_format: str="%Y-%m-%d"):
  70. # 转换日期到YYYY-MM-DD 固定格式
  71. dt = datetime.strptime(data_str, date_str_format)
  72. return dt.strftime("%Y-%m-%d")
  73. class VfsPlugin(IVSPlg):
  74. def __init__(self, group_id: str):
  75. self.group_id = group_id
  76. self.config: Optional[VSPlgConfig] = None
  77. self.free_config: Dict[str, Any] = {}
  78. self.logger = None
  79. self.session: Optional[requests.Session] = None
  80. self.jwt_token: str = ""
  81. self.user_agent: str = ""
  82. self.real_ip: str = ""
  83. self.is_healthy: bool = True
  84. # 缓存配置
  85. self.center_conf = None
  86. self.category_conf: Dict = {}
  87. self.subcategory_conf: Dict = {}
  88. # 加载公钥
  89. self.public_key = serialization.load_pem_public_key(
  90. VFS_PUBLIC_KEY_PEM.encode(),
  91. backend=default_backend()
  92. )
  93. self.session_create_time: float = 0
  94. def get_group_id(self) -> str:
  95. return self.group_id
  96. def set_config(self, config: VSPlgConfig):
  97. self.config = config
  98. self.free_config = config.free_config or {}
  99. def set_log(self, logger: Callable[[str], None]) -> None:
  100. self.logger = logger
  101. def health_check(self) -> bool:
  102. if not self.is_healthy:
  103. return False
  104. if self.session is None:
  105. return False
  106. if self.config.session_max_life > 0:
  107. current_time = time.time()
  108. elapsed_time = current_time - self.session_create_time
  109. if elapsed_time > self.config.session_max_life * 60:
  110. self._log(f"Session Life ({int(elapsed_time)}s) out of max life limit ({self.config.session_max_life * 60}s), mark as unhealth session")
  111. return False
  112. return True
  113. def create_session(self) -> None:
  114. # 初始化 Session
  115. curlopt = {
  116. const.CurlOpt.MAXAGE_CONN: 1800,
  117. const.CurlOpt.MAXLIFETIME_CONN: 1800,
  118. const.CurlOpt.VERBOSE: self.config.debug,
  119. }
  120. self.session = requests.Session(
  121. proxy=self._get_proxy_url(),
  122. impersonate="chrome124",
  123. curl_options=curlopt,
  124. use_thread_local_curl=False,
  125. http_version=const.CurlHttpVersion.V2TLS
  126. )
  127. # 获取真实IP
  128. self.real_ip = self._get_realnetwork_ip()
  129. # 1. Cloudflare Turnstile
  130. cf_token = self._handle_cloudflare_challenge()
  131. # 2. 准备参数
  132. email = self.config.account.username
  133. password = self.config.account.password
  134. enc_password = self._encrypt_password(password)
  135. mission_code = self.free_config.get("mission_code", "")
  136. country_code = self.free_config.get("country_code", "")
  137. client_src = self._get_client_source()
  138. orange_src = self._get_orange_source(email)
  139. url = "https://lift-api.vfsglobal.com/user/login"
  140. headers = self._get_common_headers(with_auth=False)
  141. headers.update({
  142. "clientsource": client_src,
  143. "orangex": orange_src,
  144. "content-type": "application/x-www-form-urlencoded"
  145. })
  146. data = {
  147. "username": email,
  148. "password": enc_password,
  149. "missioncode": mission_code,
  150. "countrycode": country_code,
  151. "languageCode": "en-US",
  152. "captcha_version": "cloudflare-v1",
  153. "captcha_api_key": cf_token
  154. }
  155. # 3. 发送登录请求
  156. resp = self._perform_request("POST", url, headers=headers, data=data)
  157. resp_json = resp.json()
  158. # 分支 1: 登录直接成功,获取到 Token
  159. if resp_json.get('accessToken'):
  160. self.jwt_token = resp_json["accessToken"]
  161. self._log("Login successful, JWT obtained.")
  162. # 分支 2: 需要 OTP 验证
  163. elif resp_json.get("enableOTPAuthentication"):
  164. self._log("Login requires OTP.")
  165. otp = self._read_otp_email()
  166. # 提交 OTP,如果失败该函数内部应抛出异常
  167. self._submit_login_otp(None, otp)
  168. # 分支 3: 异常情况(既无 Token 也无 OTP)
  169. else:
  170. # 在分支内部抛出异常,包含响应内容方便调试
  171. raise BizLogicError(message=f"Login failed: No access token or OTP flow. Response: {resp_json}")
  172. self.session_create_time = time.time()
  173. self._log("Session created successfully.")
  174. def query(self) -> VSQueryResult:
  175. """查询可预约 Slot"""
  176. result = VSQueryResult()
  177. appt_types = self.free_config.get("appointment_types", [])
  178. if not appt_types:
  179. raise NotFoundError(message="No matching appointment configuration found.")
  180. apt_config = random.choice(appt_types)
  181. self._fetch_configurations(apt_config)
  182. earliest_date = self._query_earliest_slot(apt_config)
  183. result.success = False
  184. result.availability_status = AvailabilityStatus.NoneAvailable
  185. result.visa_type = apt_config.get("visa_type", "")
  186. result.city = apt_config.get("city", "")
  187. result.country = apt_config.get("country", "")
  188. result.routing_key = apt_config.get("routing_key", "")
  189. if earliest_date:
  190. result.success = True
  191. if "WaitList" in earliest_date:
  192. result.availability_status = AvailabilityStatus.Waitlist
  193. else:
  194. result.availability_status = AvailabilityStatus.Available
  195. result.earliest_date = earliest_date
  196. result.availability = [
  197. DateAvailability(
  198. date=earliest_date,
  199. times=[],
  200. )
  201. ]
  202. return result
  203. def book(self, slot_info: VSQueryResult, user_inputs) -> VSBookResult:
  204. """
  205. 执行完整的预约流程,包含:上传文档 -> 添加申请人 -> OTP -> 选时间 -> 锁定 -> 支付
  206. """
  207. user_email = user_inputs.get('email')
  208. user_inputs['alias_email'] = get_alias_email(user_email, new_domain="gmail-app.com")
  209. res = VSBookResult()
  210. slot_routing_key = slot_info.routing_key
  211. from_date = slot_info.earliest_date if slot_info.earliest_date else datetime.now().strftime("%Y-%m-%d")
  212. apt_config = None
  213. appt_types = self.free_config.get("appointment_types", [])
  214. for apt in appt_types:
  215. if apt.get("routing_key") == slot_routing_key:
  216. apt_config = apt
  217. break
  218. if not apt_config:
  219. raise NotFoundError(message="Book: Config missing.")
  220. self._fetch_configurations(apt_config)
  221. sub_cc = apt_config.get("subcategory_code")
  222. sub_conf = self.subcategory_conf.get(sub_cc, {})
  223. # OCR 识别 / 文档上传
  224. ocr_enabled = sub_conf.get("isOCREnable", False)
  225. if ocr_enabled:
  226. self._log("OCR Enabled, uploading documents...")
  227. upload_res = self._upload_applicant_documents(apt_config, user_inputs, upload_res)
  228. user_inputs["applicant_image"] = upload_res.get("passportImageFilename")
  229. user_inputs["applicant_image_data"] = upload_res.get("passportImageFileBytes") # Base64
  230. user_inputs["guid"] = upload_res.get("uploadDocumentGUID")
  231. # 需要提供申请号 (Cover Letter)
  232. enable_reference_number = sub_conf.get("enableReferenceNumber", False)
  233. # 添加申请人 (核心步骤 1)
  234. final_urn = None
  235. is_waitlist = (slot_info.availability_status == AvailabilityStatus.Waitlist)
  236. add_primary_retry = 0
  237. MAX_RETRY = 6
  238. while add_primary_retry < MAX_RETRY:
  239. try:
  240. final_urn = self._add_primary_applicant(apt_config, user_inputs, is_waitlist, ocr_enabled, enable_reference_number)
  241. if not final_urn:
  242. raise NotFoundError(message="URN not found")
  243. break
  244. except Exception as e:
  245. self._log(f"Add Applicant retry {add_primary_retry}...")
  246. time.sleep(10)
  247. add_primary_retry += 1
  248. if not final_urn:
  249. raise BizLogicError(message="Failed to add primary applicant (Slot likely taken)")
  250. self._log(f"Applicant Added. URN: {final_urn}")
  251. # 申请人 OTP 验证 (核心步骤 2)
  252. otp_enabled = sub_conf.get("isApplicantOTPEnabled", False)
  253. if otp_enabled:
  254. self._log("Applicant OTP Required.")
  255. if not self._applicant_otp_send(apt_config, final_urn):
  256. raise BizLogicError(message='applicant otp send failed')
  257. # 复用之前的读邮件逻辑
  258. otp_code = self._read_otp_email()
  259. if not self._applicant_otp_verify(apt_config, final_urn, otp_code):
  260. raise BizLogicError(message='applicant otp verify failed')
  261. # 如果是 Waitlist 模式,直接确认并返回
  262. if is_waitlist:
  263. if self._confirm_waitlist(apt_config, final_urn):
  264. res.success = True
  265. res.urn = final_urn
  266. return res
  267. raise BizLogicError(message='confirm waitlist failed')
  268. # 规则引擎与日期筛选 (核心步骤 3)
  269. expected_start = user_inputs.get("expected_start_date", "")
  270. expected_end = user_inputs.get("expected_end_date", "")
  271. # 计算需要扫描的月份, 如果 expected_start/end 为空,默认使用 from_date 所在月
  272. months = self._get_filtered_covered_months(expected_start, expected_end, from_date)
  273. self._log(f"Scanning months: {months} (From: {from_date})")
  274. selected_slot_id = ""
  275. selected_slot_date = ""
  276. selected_slot_time_range = ""
  277. # 记录所有有号日期,避免重复处理
  278. all_ads = set()
  279. forbidden_dates = set()
  280. found_slot = False
  281. # 遍历月份寻找 Slot
  282. for m_str in months:
  283. ads = self._query_slot_calendar(apt_config, final_urn, m_str)
  284. # 过滤已知的 slots
  285. new_ads = [d for d in ads if d not in all_ads]
  286. all_ads.update(new_ads)
  287. # 尝试 3 次选择
  288. for _ in range(3):
  289. # 排除 forbidden
  290. avail_candidates = [d for d in list(all_ads) if d not in forbidden_dates]
  291. # 规则筛选
  292. sel_dates = self._filter_dates(avail_candidates, expected_start, expected_end)
  293. print(f'avail_candidates={avail_candidates}, sel_dates={sel_dates}')
  294. if not sel_dates:
  295. break
  296. tmp_date = sel_dates[0]
  297. forbidden_dates.add(tmp_date)
  298. # 审计日志
  299. if not self._saveuseractionaudit(apt_config, final_urn, tmp_date):
  300. time.sleep(3)
  301. continue
  302. # 查询具体时间
  303. ats = self._query_slot_time(apt_config, final_urn, tmp_date)
  304. if not ats:
  305. time.sleep(3)
  306. continue
  307. # 随机选择一个时间段
  308. sel_tm = random.choice(ats)
  309. selected_slot_id = sel_tm.get("allocationId")
  310. selected_slot_date = tmp_date
  311. selected_slot_time_range = sel_tm.get("slot")
  312. found_slot = True
  313. break
  314. if found_slot:
  315. break
  316. if not found_slot:
  317. self._log("No valid slots found.")
  318. res.success = False
  319. return res
  320. self._log(f"Slot Selected: {selected_slot_date} {selected_slot_time_range} (ID: {selected_slot_id})")
  321. # 服务、费用、最终预约 (核心步骤 4)
  322. self._submit_no_addition_service(final_urn)
  323. amount, currency = self._query_fee(apt_config, final_urn)
  324. schedule_res = self._schedule(apt_config, final_urn, amount, currency, selected_slot_id)
  325. if not schedule_res.get("IsAppointmentBooked"):
  326. self._log(f"IsAppointmentBooked is false")
  327. res.success = False
  328. return res
  329. # 构造返回结果
  330. res.success = True
  331. res.account = self.config.account.username
  332. res.book_date = selected_slot_date
  333. res.book_time = selected_slot_time_range
  334. res.urn = final_urn
  335. res.fee_amount = int(amount * 100)
  336. res.fee_currency = currency
  337. # 处理支付链接
  338. if schedule_res.get("IsPaymentRequired", False):
  339. payload = schedule_res.get("payLoad", "")
  340. payment_url = self._pay_request(payload)
  341. if payment_url:
  342. res.payment_link = payment_url
  343. # 保存 Session
  344. saved_session = self._save_http_session(payment_url)
  345. if saved_session:
  346. res.session_id = saved_session['session_id']
  347. return res
  348. def _log(self, message):
  349. if self.logger:
  350. self.logger(f'[VfsPlugin] [{self.group_id}] {message}')
  351. def _get_proxy_url(self):
  352. # 构造代理
  353. proxy_url = ""
  354. if self.config.proxy.ip:
  355. s = self.config.proxy
  356. if s.username:
  357. proxy_url = f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
  358. else:
  359. proxy_url = f"{s.scheme}://{s.ip}:{s.port}"
  360. return proxy_url
  361. def _get_filtered_covered_months(self, start_date, end_date, from_date) -> List[str]:
  362. """
  363. 计算需要查询的月份列表,格式 YYYY-MM-DD (每月1号)
  364. """
  365. fmt = "%Y-%m-%d"
  366. # 默认值处理
  367. try:
  368. dt_start = datetime.strptime(start_date, fmt) if start_date else datetime.now()
  369. dt_end = datetime.strptime(end_date, fmt) if end_date else datetime.now().replace(year=datetime.now().year + 1)
  370. try:
  371. dt_from = datetime.strptime(from_date, fmt)
  372. except:
  373. dt_from = datetime.now()
  374. except:
  375. return []
  376. # 归一化到月初
  377. dt_start = dt_start.replace(day=1)
  378. dt_end = dt_end.replace(day=1)
  379. dt_from = dt_from.replace(day=1)
  380. # 起始点取 max(start, from)
  381. curr = max(dt_start, dt_from)
  382. months = []
  383. while curr <= dt_end:
  384. months.append(curr.strftime(fmt))
  385. # 下个月
  386. if curr.month == 12:
  387. curr = curr.replace(year=curr.year + 1, month=1)
  388. else:
  389. curr = curr.replace(month=curr.month + 1)
  390. return months
  391. def _get_realnetwork_ip(self):
  392. url = "https://api.ipify.org/?format=json"
  393. headers = {
  394. 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
  395. 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
  396. }
  397. resp = self._perform_request('GET', url, headers=headers)
  398. return resp.json()['ip']
  399. def _confirm_waitlist(self, apt_config: Dict[str, Any], urn: str) -> bool:
  400. """
  401. 确认加入候补名单 (对应 C++ VFSApi::confirm_waitlist)
  402. """
  403. url = "https://lift-api.vfsglobal.com/appointment/ConfirmWaitlist"
  404. headers = self._get_common_headers(with_auth=True)
  405. headers["content-type"] = "application/json;charset=UTF-8"
  406. data = {
  407. "missionCode": self.free_config.get("mission_code"),
  408. "countryCode": self.free_config.get("country_code"),
  409. "centerCode": apt_config.get("vac_code"),
  410. "loginUser": self.config.account.username,
  411. "urn": urn,
  412. "notificationType": "none",
  413. "CanVFSReachoutToApplicant": True
  414. }
  415. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  416. return resp.json().get("isConfirmed")
  417. def _upload_applicant_documents(self, apt_config, user_inputs) -> Dict:
  418. """上传护照图片"""
  419. url = "https://lift-api.vfsglobal.com/appointment/UploadApplicantDocument"
  420. passport_url = user_inputs.get("passport_image_url")
  421. if not passport_url:
  422. raise NotFoundError(message="Missing passport_image_url")
  423. img_resp = requests.get(passport_url, timeout=30)
  424. if img_resp.status_code != 200:
  425. raise BizLogicError(message="Failed to download passport image")
  426. b64_str = base64.b64encode(img_resp.content).decode('utf-8')
  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("mission_code"),
  431. "countryCode": self.free_config.get("country_code"),
  432. "centerCode": apt_config.get("vac_code"),
  433. "loginUser": self.config.account.username,
  434. "languageCode": "en-US",
  435. "visaCategoryCode": apt_config.get("subcategory_code"),
  436. "fileBytes": b64_str,
  437. "selfiImageFileBytes": ""
  438. }
  439. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  440. result = resp.json()
  441. result["passportImageFilename"] = "passport_img.jpg"
  442. result["passportImageFileBytes"] = b64_str
  443. return result
  444. def _add_primary_applicant(self, apt_config: Dict[str, Any], user_inputs: Dict[str, Any],
  445. is_waitlist: bool, ocr_enabled: bool, enable_ref: bool) -> str:
  446. """
  447. 构造复杂的申请人 JSON payload 并提交
  448. """
  449. url = "https://lift-api.vfsglobal.com/appointment/applicants"
  450. headers = self._get_common_headers(with_auth=True)
  451. headers["content-type"] = "application/json;charset=UTF-8"
  452. #male/Male -> 1, 否则 -> 2
  453. gender_str = str(user_inputs.get("gender", "")).lower()
  454. gender_code = 1 if gender_str == "male" else 2
  455. raw_dial = user_inputs.get("phone_country_code", "86")
  456. dial_code = str(raw_dial)
  457. # to_ddmmyyyy (YYYY-MM-DD -> DD/MM/YYYY)
  458. def _to_ddmmyyyy(d_str):
  459. try:
  460. # 假设输入是 YYYY-MM-DD
  461. return datetime.strptime(d_str, "%Y-%m-%d").strftime("%d/%m/%Y")
  462. except:
  463. return d_str # 原样返回
  464. dob = _to_ddmmyyyy(str(user_inputs.get("birthday", "")))
  465. ppt_exp = _to_ddmmyyyy(str(user_inputs.get("passport_expiry_date", "")))
  466. # --- 构造单个 Applicant 对象 ---
  467. applicant = {
  468. "urn": "",
  469. "arn": "",
  470. "loginUser": self.config.account.username,
  471. # 基本信息 (全部大写)
  472. "firstName": str(user_inputs.get("first_name", "")).upper(),
  473. "middleName": "",
  474. "lastName": str(user_inputs.get("last_name", "")).upper(),
  475. "employerFirstName": "",
  476. "employerLastName": "",
  477. "salutation": "",
  478. "gender": gender_code,
  479. # 联系信息
  480. "contactNumber": str(user_inputs.get("phone", "")),
  481. "dialCode": dial_code,
  482. "employerContactNumber": "",
  483. "employerDialCode": "",
  484. "emailId": str(user_inputs.get("alias_email", "")).upper(),
  485. "employerEmailId": "",
  486. # 证件信息
  487. "passportNumber": str(user_inputs.get("passport_no", "")).upper(),
  488. "confirmPassportNumber": "",
  489. "passportExpirtyDate": ppt_exp,
  490. "dateOfBirth": dob,
  491. "nationalId": None,
  492. # 国籍 (使用全局辅助函数 get_country_iso3)
  493. "nationalityCode": get_country_iso3(str(user_inputs.get("nationality", ""))),
  494. # 地址与其它 (大部分为空)
  495. "state": None,
  496. "city": None,
  497. "addressline1": None,
  498. "addressline2": None,
  499. "pincode": None,
  500. "isEndorsedChild": False,
  501. "applicantType": 0,
  502. "vlnNumber": None,
  503. "applicantGroupId": 0,
  504. "parentPassportNumber": "",
  505. "parentPassportExpiry": "",
  506. "dateOfDeparture": None,
  507. "entryType": "",
  508. "eoiVisaType": "",
  509. "passportType": "",
  510. "vfsReferenceNumber": "",
  511. "familyReunificationCerificateNumber": "",
  512. "PVRequestRefNumber": "",
  513. "PVStatus": "",
  514. "PVStatusDescription": "",
  515. "PVCanAllowRetry": True,
  516. "PVisVerified": False,
  517. "eefRegistrationNumber": "",
  518. "isAutoRefresh": True,
  519. "helloVerifyNumber": "",
  520. "OfflineCClink": "",
  521. "idenfystatuscheck": False,
  522. "vafStatus": None,
  523. "SpecialAssistance": "",
  524. "AdditionalRefNo": None,
  525. "juridictionCode": "",
  526. "canInitiateVAF": False,
  527. "canEditVAF": False,
  528. "canDeleteVAF": False,
  529. "canDownloadVAF": False,
  530. "Retryleft": "",
  531. # 真实 IP 注入
  532. "ipAddress": self.real_ip
  533. }
  534. # --- 处理 Reference Number (Cover Letter) ---
  535. if enable_ref:
  536. applicant["referenceNumber"] = str(user_inputs.get("cover_letter", ""))
  537. else:
  538. applicant["referenceNumber"] = None
  539. # --- 处理 OCR 数据 ---
  540. if ocr_enabled:
  541. applicant["applicantImage"] = str(user_inputs.get("applicant_image", ""))
  542. applicant["applicantImageData"] = str(user_inputs.get("applicant_image_data", ""))
  543. applicant["GUID"] = str(user_inputs.get("guid", ""))
  544. # --- 构造最外层 Payload ---
  545. payload = {
  546. "countryCode": self.free_config.get("country_code"),
  547. "missionCode": self.free_config.get("mission_code"),
  548. "centerCode": apt_config.get("vac_code"),
  549. "loginUser": self.config.account.username,
  550. "visaCategoryCode": apt_config.get("subcategory_code"),
  551. "applicantList": [applicant], # 数组形式
  552. "languageCode": "en-US",
  553. "isWaitlist": is_waitlist,
  554. "isEdit": False,
  555. "feeEntryTypeCode": None,
  556. "feeExemptionTypeCode": None,
  557. "feeExemptionDetailsCode": None,
  558. "juridictionCode": None,
  559. "regionCode": None
  560. }
  561. # --- 发送请求 ---
  562. resp = self._perform_request("POST", url, headers=headers, json_data=payload)
  563. # --- 处理响应 ---
  564. return resp.json()["urn"]
  565. def _applicant_otp_send(self, apt_config, urn) -> bool:
  566. url = "https://lift-api.vfsglobal.com/appointment/applicantotp"
  567. headers = self._get_common_headers(with_auth=True)
  568. headers["content-type"] = "application/json;charset=UTF-8"
  569. data = {
  570. "urn": urn,
  571. "loginUser": self.config.account.username,
  572. "missionCode": self.free_config.get("mission_code"),
  573. "countryCode": self.free_config.get("country_code"),
  574. "centerCode": apt_config.get("vac_code"),
  575. "OTP": "",
  576. "otpAction": "GENERATE",
  577. "languageCode": "en-US"
  578. }
  579. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  580. return resp.json().get("isOTPGenerated")
  581. def _applicant_otp_verify(self, apt_config, urn, otp) -> bool:
  582. url = "https://lift-api.vfsglobal.com/appointment/applicantotp"
  583. headers = self._get_common_headers(with_auth=True)
  584. headers["datacenter"] = "GERMANY"
  585. headers["content-type"] = "application/json;charset=UTF-8"
  586. data = {
  587. "urn": urn,
  588. "loginUser": self.config.account.username,
  589. "missionCode": self.free_config.get("mission_code"),
  590. "countryCode": self.free_config.get("country_code"),
  591. "centerCode": apt_config.get("vac_code"),
  592. "OTP": otp,
  593. "otpAction": "VALIDATE",
  594. "languageCode": "en-US"
  595. }
  596. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  597. return resp.json().get("isOTPValidated")
  598. def _query_slot_calendar(self, apt_config, urn, from_date) -> List:
  599. url = "https://lift-api.vfsglobal.com/appointment/calendar"
  600. headers = self._get_common_headers(with_auth=True)
  601. headers["content-type"] = "application/json;charset=UTF-8"
  602. dt_m = datetime.strptime(from_date, "%Y-%m-%d")
  603. converted_date = dt_m.strftime("%d/%m/%Y")
  604. data = {
  605. "missionCode": self.free_config.get("mission_code"),
  606. "countryCode": self.free_config.get("country_code"),
  607. "centerCode": apt_config.get("vac_code"),
  608. "loginUser": self.config.account.username,
  609. "visaCategoryCode": apt_config.get("subcategory_code"),
  610. "fromDate": converted_date,
  611. "urn": urn,
  612. "payCode": ""
  613. }
  614. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  615. calendars = resp.json().get("calendars")
  616. if calendars:
  617. ads_out = []
  618. for item in calendars:
  619. # "MM/DD/YYYY" -> "YYYY-MM-DD"
  620. raw = item.get("date")
  621. ads_out.append(to_yyyymmdd(raw, "%m/%d/%Y"))
  622. return ads_out
  623. return []
  624. def _query_slot_time(self, apt_config, urn, slot_date) -> List:
  625. url = "https://lift-api.vfsglobal.com/appointment/timeslot"
  626. headers = self._get_common_headers(with_auth=True)
  627. headers["content-type"] = "application/json;charset=UTF-8"
  628. dt_m = datetime.strptime(slot_date, "%Y-%m-%d")
  629. converted_date = dt_m.strftime("%d/%m/%Y")
  630. data = {
  631. "missionCode": self.free_config.get("mission_code"),
  632. "countryCode": self.free_config.get("country_code"),
  633. "centerCode": apt_config.get("vac_code"),
  634. "loginUser": self.config.account.username,
  635. "visaCategoryCode": apt_config.get("subcategory_code"),
  636. "slotDate": converted_date,
  637. "urn": urn
  638. }
  639. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  640. return resp.json().get("slots")
  641. def _saveuseractionaudit(self, apt_config, urn, earliest_date) -> bool:
  642. url = "https://lift-api.vfsglobal.com/appointment/saveuseractionaudit"
  643. headers = self._get_common_headers(with_auth=True)
  644. headers["content-type"] = "application/json;charset=UTF-8"
  645. # ISO format conversion
  646. dt = datetime.strptime(earliest_date, "%Y-%m-%d")
  647. data = {
  648. "missionCode": self.free_config.get("mission_code"),
  649. "countryCode": self.free_config.get("country_code"),
  650. "centerCode": apt_config.get("vac_code"),
  651. "loginUser": self.config.account.username,
  652. "urn": urn,
  653. "firstEarliestSlotDate": dt.strftime("%d/%m/%Y"),
  654. "action": "schedule",
  655. "ipAddress": self.real_ip,
  656. "eadAppointmentDetail": dt.strftime("%Y-%m-%dT%H:%M:%S")
  657. }
  658. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  659. return resp.json().get("isSavedSuccess", False)
  660. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
  661. """
  662. 统一 HTTP 请求封装,严格复刻 C++ 逻辑:
  663. 1. 发送 OPTIONS 请求
  664. 2. 发送实际请求
  665. """
  666. # --- 1. 发送 OPTIONS 请求 ---
  667. try:
  668. # OPTIONS 请求使用相同的 URL 和 headers (部分 header 如 content-length 会被自动处理)
  669. # 某些服务器反爬会检测 OPTIONS 请求
  670. opt_headers = headers.copy() if headers else {}
  671. # 发送 OPTIONS
  672. self.session.request("OPTIONS", url, headers=opt_headers, timeout=10)
  673. # C++ 代码中并不检查 OPTIONS 的返回值,只检查执行是否成功
  674. # 这里我们假设只要不抛出异常即可
  675. except Exception as e:
  676. # 记录警告但不中断流程,防止仅仅是 OPTIONS 失败导致误判
  677. self._log(f"OPTIONS request failed (non-fatal): {str(e)}")
  678. resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
  679. if self.config.debug:
  680. self._log(f'[perform request] Response={resp.text}\nMethod={method}, Url={url}, Data={data}, JsonData={json_data}, Params={params}')
  681. if resp.status_code == 200:
  682. return resp
  683. elif resp.status_code == 401:
  684. self.is_healthy = False
  685. raise SessionExpiredOrInvalidError()
  686. elif resp.status_code == 403:
  687. raise PermissionDeniedError()
  688. elif resp.status_code == 429:
  689. self.is_healthy = False
  690. raise RateLimiteddError()
  691. else:
  692. raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
  693. def _encrypt_password(self, password: str) -> str:
  694. ciphertext = self.public_key.encrypt(
  695. password.encode(),
  696. padding.OAEP(
  697. mgf=padding.MGF1(algorithm=hashes.SHA256()),
  698. algorithm=hashes.SHA256(),
  699. label=None
  700. )
  701. )
  702. return base64.b64encode(ciphertext).decode()
  703. def _get_orange_source(self, email: str) -> str:
  704. timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
  705. payload = f"{email};{timestamp}"
  706. return self._encrypt_password(payload)
  707. def _get_client_source(self) -> str:
  708. timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
  709. payload = f"GA;{timestamp}Z"
  710. return self._encrypt_password(payload)
  711. def _handle_cloudflare_challenge(self) -> str:
  712. """
  713. 完整实现的 Cloudflare Turnstile 验证逻辑
  714. """
  715. mission = self.free_config.get("mission_code", "")
  716. country = self.free_config.get("country_code", "")
  717. if not mission or not country:
  718. raise NotFoundError(message="Missing mission_code or country_code in free_config")
  719. website_url = f"https://visa.vfsglobal.com/{country}/en/{mission}/login"
  720. # 构造代理字符串传给打码平台 (格式: http://user:pass@ip:port)
  721. proxy_str = self._get_proxy_url()
  722. # 2. 提交任务
  723. self._log(f"Submitting Turnstile task for {website_url}...")
  724. task_id = VSCloudApi.Instance().create_task(
  725. command="AntiCloudflareTurnstileTask",
  726. args={
  727. "proxy": proxy_str,
  728. "websiteUrl":website_url
  729. }
  730. )
  731. result_data = VSCloudApi.Instance().get_task_result(task_id, timeout=60)
  732. task_result = result_data.get("result", {})
  733. token = task_result.get("token")
  734. ua = task_result.get("userAgent")
  735. cookies_list = task_result.get("cookies", [])
  736. if not token:
  737. raise BizLogicError("Captcha solved but token is empty")
  738. # A. 设置 User-Agent
  739. if ua:
  740. self.user_agent = ua
  741. self.session.headers["User-Agent"] = ua
  742. # B. 设置 Cookies
  743. if cookies_list:
  744. self._log(f"Syncing {len(cookies_list)} cookies from Captcha solver...")
  745. for cookie in cookies_list:
  746. # 兼容不同的 cookie 格式
  747. c_name = cookie.get("name")
  748. c_value = cookie.get("value")
  749. c_domain = cookie.get("domain", "")
  750. c_path = cookie.get("path", "/")
  751. if c_name and c_value:
  752. self.session.cookies.set(
  753. name=c_name,
  754. value=c_value,
  755. domain=c_domain,
  756. path=c_path
  757. )
  758. self._log("Cloudflare challenge passed.")
  759. return token
  760. def _get_common_headers(self, with_auth=True) -> Dict[str, str]:
  761. mission = self.free_config.get("mission_code", "")
  762. country = self.free_config.get("country_code", "")
  763. lang = self.free_config.get("language", "en")
  764. route = f"{country}/{lang}/{mission}"
  765. h = {
  766. "accept": "application/json, text/plain, */*",
  767. "accept-language": "en-US,en;q=0.9",
  768. "origin": "https://visa.vfsglobal.com",
  769. "referer": "https://visa.vfsglobal.com/",
  770. "route": route
  771. }
  772. client_src = self._get_client_source()
  773. h["clientsource"] = client_src
  774. if with_auth and self.jwt_token:
  775. h["authorize"] = self.jwt_token
  776. return h
  777. def _query_earliest_slot(self, apt_config) -> Optional[str]:
  778. """
  779. 查询最早 Slot(403 自动绕盾 + 重试)
  780. """
  781. url = "https://lift-api.vfsglobal.com/appointment/CheckIsSlotAvailable"
  782. max_retries = self.free_config.get("slot_query_max_retries", 2)
  783. data = {
  784. "missioncode": self.free_config.get("mission_code"),
  785. "countrycode": self.free_config.get("country_code"),
  786. "vacCode": apt_config.get("vac_code"),
  787. "visaCategoryCode": apt_config.get("subcategory_code"),
  788. "roleName": "Individual",
  789. "loginUser": self.config.account.username,
  790. "payCode": ""
  791. }
  792. headers = self._get_common_headers(with_auth=True)
  793. headers["content-type"] = "application/json;charset=UTF-8"
  794. for attempt in range(1, max_retries + 1):
  795. try:
  796. resp = self._perform_request(
  797. "POST",
  798. url,
  799. headers=headers,
  800. json_data=data
  801. )
  802. break # ✅ 请求成功,跳出重试循环
  803. except PermissionDeniedError:
  804. self._log(f"Earliest slot blocked (403), attempt {attempt}/{max_retries}")
  805. # 最后一次就不再绕盾了
  806. if attempt >= max_retries:
  807. raise PermissionDeniedError()
  808. self._handle_cloudflare_challenge()
  809. self._log("Cloudflare bypass success, retrying...")
  810. continue
  811. # ====== 正常解析响应 ======
  812. if "WaitList" in resp.text:
  813. return "WaitList"
  814. j = resp.json()
  815. if j.get("earliestSlotLists"):
  816. raw_date = j["earliestSlotLists"][0]["date"]
  817. return to_yyyymmdd(raw_date, "%m/%d/%Y %H:%M:%S")
  818. return ""
  819. def _fetch_configurations(self, apt_config: Dict[str, Any]):
  820. # 1. 获取所有中心配置 (query_center)
  821. if not self.center_conf:
  822. self.center_conf = self._query_center()
  823. # 2. 获取 Visa Category 配置
  824. vac_code = apt_config.get("vac_code")
  825. category_code = apt_config.get("category_code")
  826. # 检查目标 category_code 是否已在缓存中
  827. if category_code not in self.category_conf:
  828. visa_categories = []
  829. visa_categories = self._query_visa_category(vac_code)
  830. found = False
  831. for vc in visa_categories:
  832. if vc.get("code") == category_code:
  833. self.category_conf[category_code] = vc
  834. found = True
  835. break
  836. # 如果没找到,可能配置错误,但 C++ 没报错,只继续
  837. if not found:
  838. raise NotFoundError(message=f"{self.group_id} Category code {category_code} not found in VAC {vac_code}")
  839. # 3. 获取 Visa SubCategory 配置
  840. sub_category_code = apt_config.get("subcategory_code")
  841. if sub_category_code not in self.subcategory_conf:
  842. visa_subcategories = self._query_visa_sub_category(vac_code, category_code)
  843. found = False
  844. for svc in visa_subcategories:
  845. if svc.get("code") == sub_category_code:
  846. self.subcategory_conf[sub_category_code] = svc
  847. found = True
  848. break
  849. if not found:
  850. raise NotFoundError(message=f"{self.group_id} SubCategory code {sub_category_code} not found")
  851. def _query_center(self) -> List:
  852. mission = self.free_config.get("mission_code")
  853. country = self.free_config.get("country_code")
  854. url = f"https://lift-api.vfsglobal.com/master/center/{mission}/{country}/en-US"
  855. headers = self._get_common_headers(with_auth=False)
  856. resp = self._perform_request("GET", url, headers=headers)
  857. return resp.json()
  858. def _query_visa_category(self, center_code: str) -> List:
  859. mission = self.free_config.get("mission_code")
  860. country = self.free_config.get("country_code")
  861. enc_center = urllib.parse.quote(center_code)
  862. url = f"https://lift-api.vfsglobal.com/master/visacategory/{mission}/{country}/{enc_center}/en-US"
  863. headers = self._get_common_headers(with_auth=False)
  864. resp = self._perform_request("GET", url, headers=headers)
  865. return resp.json()
  866. def _query_visa_sub_category(self, center_code: str, category_code: str) -> List:
  867. mission = self.free_config.get("mission_code")
  868. country = self.free_config.get("country_code")
  869. enc_center = urllib.parse.quote(center_code)
  870. enc_cat = urllib.parse.quote(category_code)
  871. url = f"https://lift-api.vfsglobal.com/master/subvisacategory/{mission}/{country}/{enc_center}/{enc_cat}/en-US"
  872. headers = self._get_common_headers(with_auth=False)
  873. resp = self._perform_request("GET", url, headers=headers)
  874. return resp.json()
  875. def _read_otp_email(self) -> str:
  876. """
  877. 读取 OTP 邮件
  878. """
  879. master_email = "visafly666@gmail.com"
  880. recipient = self.config.account.username
  881. sender = "donotreply at vfshelpline.com"
  882. subject_keywords = "One Time Password"
  883. body_keywords = "OTP"
  884. now_utc = datetime.utcnow()
  885. formatted_utc_time = now_utc.strftime("%Y-%m-%d %H:%M:%S")
  886. self._log(f"Waiting for OTP email sent after {formatted_utc_time}...")
  887. # 3. 轮询查收
  888. for i in range(12):
  889. content_out = VSCloudApi.Instance().fetch_mail_content(
  890. master_email,
  891. sender,
  892. recipient,
  893. subject_keywords,
  894. body_keywords,
  895. formatted_utc_time,
  896. 300
  897. )
  898. if content_out:
  899. match = re.search(r'\b\d{6}\b', content_out)
  900. if match:
  901. otp = match.group(0)
  902. self._log(f"OTP code found: {otp}")
  903. return otp
  904. time.sleep(5)
  905. raise NotFoundError(message="OTP email not found (timeout)")
  906. def _submit_login_otp(self, cf_token: str, otp: str):
  907. """
  908. 提交 OTP 验证码进行登录
  909. """
  910. self._log("Submitting Login OTP...")
  911. # 1. 准备基础数据
  912. email = self.config.account.username
  913. password = self.config.account.password
  914. enc_password = self._encrypt_password(password)
  915. mission_code = self.free_config.get("mission_code", "")
  916. country_code = self.free_config.get("country_code", "")
  917. # 2. 生成加密 Source (每次请求时间戳不同,建议重新生成)
  918. client_src = self._get_client_source()
  919. orange_src = self._get_orange_source(email)
  920. # 3. 构造请求
  921. url = "https://lift-api.vfsglobal.com/user/login"
  922. headers = self._get_common_headers(with_auth=False)
  923. headers.update({
  924. "clientsource": client_src,
  925. "orangex": orange_src,
  926. "content-type": "application/x-www-form-urlencoded"
  927. })
  928. # 为了稳健,如果传入为空,尝试重新获取。
  929. if not cf_token:
  930. self._log("CF Token is empty, regenerating for OTP...")
  931. cf_token = self._handle_cloudflare_challenge()
  932. data = {
  933. "username": email,
  934. "password": enc_password,
  935. "missioncode": mission_code,
  936. "countrycode": country_code,
  937. "languageCode": "en-US",
  938. "captcha_version": "cloudflare-v1",
  939. "captcha_api_key": cf_token,
  940. "otp": otp # 关键字段:OTP 验证码
  941. }
  942. # 4. 发送请求 (POST form-urlencoded)
  943. resp = self._perform_request("POST", url, headers=headers, data=data)
  944. resp_json = resp.json()
  945. if resp_json["accessToken"]:
  946. self.jwt_token = resp_json["accessToken"]
  947. self._log("OTP Login successful, JWT obtained.")
  948. return
  949. raise PermissionDeniedError(message=resp.text)
  950. def _submit_no_addition_service(self, urn):
  951. url = "https://lift-api.vfsglobal.com/vas/mapvas"
  952. headers = self._get_common_headers(with_auth=True)
  953. headers["content-type"] = "application/json;charset=UTF-8"
  954. data = {
  955. "loginUser": self.config.account.username,
  956. "missionCode": self.free_config.get("mission_code"),
  957. "countryCode": self.free_config.get("country_code"),
  958. "urn": urn,
  959. "applicants": []
  960. }
  961. # C++ 只请求不检查结果,或者只要200就行
  962. self._perform_request("POST", url, headers=headers, json_data=data)
  963. def _query_fee(self, apt_config, urn) -> Tuple[float, str]:
  964. url = "https://lift-api.vfsglobal.com/appointment/fees"
  965. headers = self._get_common_headers(with_auth=True)
  966. headers["content-type"] = "application/json;charset=UTF-8"
  967. data = {
  968. "missionCode": self.free_config.get("mission_code"),
  969. "countryCode": self.free_config.get("country_code"),
  970. "centerCode": apt_config.get("vac_code"),
  971. "loginUser": self.config.account.username,
  972. "urn": urn,
  973. "languageCode": "en-US"
  974. }
  975. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  976. j = resp.json()
  977. return j.get("totalamount"), j["feeDetails"][0].get("currency")
  978. def _schedule(self, apt_config, urn, amount, currency, slot_id) -> Dict:
  979. url = "https://lift-api.vfsglobal.com/appointment/schedule"
  980. headers = self._get_common_headers(with_auth=True)
  981. headers["content-type"] = "application/json;charset=UTF-8"
  982. data = {
  983. "missionCode": self.free_config.get("mission_code"),
  984. "countryCode": self.free_config.get("country_code"),
  985. "centerCode": apt_config.get("vac_code"),
  986. "loginUser": self.config.account.username,
  987. "urn": urn,
  988. "notificationType": "none",
  989. "paymentdetails": {
  990. "paymentmode": "Online",
  991. "RequestRefNo": "",
  992. "clientId": "",
  993. "merchantId": "",
  994. "amount": amount,
  995. "currency": currency
  996. },
  997. "allocationId": str(slot_id),
  998. "CanVFSReachoutToApplicant": True
  999. }
  1000. resp = self._perform_request("POST", url, headers=headers, json_data=data)
  1001. return resp.json()
  1002. def _pay_request(self, payload) -> str:
  1003. # C++: 检查 301/302 Redirect Location
  1004. url = f"https://online.vfsglobal.com/PG-Component/Payment/PayRequest?payLoad={payload}"
  1005. headers = {
  1006. "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
  1007. "referer": "https://visa.vfsglobal.com/",
  1008. "user-agent": self.user_agent
  1009. }
  1010. # allow_redirects=False 以捕获 Location header
  1011. resp = self.session.get(url, headers=headers, allow_redirects=False)
  1012. if resp.status_code in [301, 302]:
  1013. loc = resp.headers.get("Location", "")
  1014. if loc.startswith("http"):
  1015. return loc
  1016. else:
  1017. return "https://online.vfsglobal.com" + loc if loc.startswith("/") else "https://online.vfsglobal.com/" + loc
  1018. else:
  1019. raise NotFoundError(message='payment link not found')
  1020. def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
  1021. """
  1022. 根据用户的期望范围筛选可用日期
  1023. :param dates: API 返回的可用日期列表 (YYYY-MM-DD)
  1024. :param start_str: 用户期望开始日期 (YYYY-MM-DD)
  1025. :param end_str: 用户期望结束日期 (YYYY-MM-DD)
  1026. :return: 符合要求的日期列表
  1027. """
  1028. # 如果没有设置范围,则不过滤,返回所有日期
  1029. if not start_str or not end_str:
  1030. return dates
  1031. valid_dates = []
  1032. # 截取前10位以防带有时分秒
  1033. s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
  1034. e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
  1035. for date_str in dates:
  1036. curr_date = datetime.strptime(date_str, "%Y-%m-%d")
  1037. # 比较范围 (闭区间)
  1038. if s_date <= curr_date <= e_date:
  1039. valid_dates.append(date_str)
  1040. random.shuffle(valid_dates)
  1041. return valid_dates
  1042. def _save_http_session(self, page_url):
  1043. """
  1044. 提取 cookies, local_storage, 存入 VSCloudApi
  1045. """
  1046. cookies_dict = {}
  1047. # 方式 1: curl_cffi 的 cookies 对象通常支持 get_dict()
  1048. if hasattr(self.session.cookies, "get_dict"):
  1049. cookies_dict = self.session.cookies.get_dict()
  1050. else:
  1051. # 方式 2: 迭代 (兼容标准 CookieJar)
  1052. for c in self.session.cookies:
  1053. cookies_dict[c.name] = c.value
  1054. cookies_str = json.dumps(cookies_dict)
  1055. # 简单生成 SessionID hash
  1056. ua_str = self.user_agent or "unknown_ua"
  1057. raw = cookies_str + ua_str + page_url
  1058. session_id = hashes.Hash(hashes.SHA256(), backend=default_backend())
  1059. session_id.update(raw.encode())
  1060. sid = session_id.finalize().hex()
  1061. proxy_str = ""
  1062. if self.config.proxy.ip:
  1063. proxy_str = f"{self.config.proxy.scheme}://"
  1064. if self.config.proxy.username:
  1065. proxy_str += f"{self.config.proxy.username}:{self.config.proxy.password}@"
  1066. proxy_str += f"{self.config.proxy.ip}:{self.config.proxy.port}"
  1067. return VSCloudApi.Instance().create_http_session(
  1068. sid, cookies_str, "", ua_str, proxy_str, page_url
  1069. )