vfs_plugin2.py 62 KB

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