bls_plugin.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838
  1. import re
  2. import os
  3. import base64
  4. import time
  5. import json
  6. import random
  7. import string
  8. from datetime import datetime, timedelta
  9. from pathlib import Path
  10. from urllib.parse import urlparse, parse_qs, urlencode
  11. from typing import Dict, List, Optional, Any
  12. from curl_cffi import requests, const
  13. from bs4 import BeautifulSoup
  14. from cryptography.hazmat.primitives import serialization, hashes
  15. from cryptography.hazmat.primitives.asymmetric import padding
  16. from cryptography.hazmat.backends import default_backend
  17. # 框架依赖
  18. from vs_plg import IVSPlg
  19. from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  20. from vs_log_macros import VSC_INFO, VSC_ERROR, VSC_DEBUG, VSC_WARN
  21. from toolkit.vs_cloud_api import VSCloudApi
  22. from utils.browser_util import get_browser
  23. class BlsPlugin(IVSPlg):
  24. """
  25. BLS 签证预约插件 (精简版)
  26. """
  27. def __init__(self, group_id: str):
  28. self.group_id = group_id
  29. self.config: Optional[VSPlgConfig] = None
  30. self.free_config: Dict[str, Any] = {}
  31. self.session: Optional[requests.Session] = None
  32. # 运行时状态
  33. self.book_params: Dict = {}
  34. self.is_healthy = True
  35. # OCR 服务地址默认值
  36. self.ocr_service_url = "http://127.0.0.1:8085/predict/bls?model=pytorch"
  37. self.browser = get_browser()
  38. def get_group_id(self) -> str:
  39. return self.group_id
  40. def set_config(self, config: VSPlgConfig):
  41. self.config = config
  42. try:
  43. self.free_config = json.loads(config.free_config) if config.free_config else {}
  44. except:
  45. self.free_config = {}
  46. # 从配置中读取 OCR 服务地址,如果没有则使用默认
  47. if self.free_config.get("ocr_service_url"):
  48. self.ocr_service_url = self.free_config["ocr_service_url"]
  49. def health_check(self) -> bool:
  50. return self.is_healthy
  51. def create_session(self):
  52. self.session = requests.Session(
  53. proxy=self._get_proxy_url(),
  54. impersonate="chrome131",
  55. curl_options={
  56. const.CurlOpt.MAXAGE_CONN: 1800,
  57. const.CurlOpt.VERBOSE: False
  58. }
  59. )
  60. domain = self.free_config.get("domain")
  61. if not domain:
  62. raise NotFoundError(message="Required field [domain] in free config")
  63. # 1.1 获取登录页 & 解析参数
  64. login_url = f"https://{domain}/Global/account/login"
  65. headers = {
  66. 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0 Safari/537.36',
  67. 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
  68. }
  69. resp = self._perform_request('GET', login_url, headers=headers)
  70. soup = BeautifulSoup(resp.text, 'html.parser')
  71. form_data = self._extract_hidden_fields(soup)
  72. # 解析动态 ID (UserId1, Password1 等)
  73. for inp in soup.find_all('input'):
  74. iid = inp.get('id', '')
  75. if 'UserId' in iid and re.search(r'\d+', iid):
  76. form_data["UserIdKey"] = iid # 暂存 Key
  77. form_data["UserId"] = re.search(r'\d+', iid).group(0)
  78. if 'Password' in iid and re.search(r'\d+', iid):
  79. form_data["PasswordKey"] = iid # 暂存 Key
  80. form_data["Password"] = re.search(r'\d+', iid).group(0)
  81. # 解析 data 参数 (用于验证码)
  82. data_val = self._extract_js_var(resp.text, "iframeOpenUrl", r"data=([^']+)")
  83. # 1.2 处理验证码
  84. captcha_token = self._solve_bls_captcha(data_val)
  85. # 1.3 提交登录
  86. submit_url = f"https://{domain}/Global/account/loginsubmit"
  87. payload = form_data
  88. payload["X-Requested-With"] = "XMLHttpRequest"
  89. payload["CaptchaData"] = captcha_token
  90. # 填入账号密码
  91. if "UserIdKey" in form_data:
  92. payload[form_data["UserIdKey"]] = self.config.account.username
  93. if "PasswordKey" in form_data:
  94. payload[form_data["PasswordKey"]] = self.config.account.password
  95. login_resp = self._perform_request('POST', submit_url, data=payload, headers=headers)
  96. if login_resp.json()['success']:
  97. return
  98. raise BizLogicError(message='Login failed')
  99. # =========================================================================
  100. # 2. 查询流程 (Query)
  101. # =========================================================================
  102. def query(self) -> VSQueryResult:
  103. res = VSQueryResult()
  104. domain = self.free_config.get("domain")
  105. # 2.1 签证类型验证
  106. url_vtv = f"https://{domain}/Global/bls/visatypeverification"
  107. headers = {
  108. 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0 Safari/537.36',
  109. 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
  110. }
  111. resp = self._perform_request('GET', url_vtv, headers=headers)
  112. form_vtv = self._extract_hidden_fields(BeautifulSoup(resp.text, 'html.parser'))
  113. captcha_token = self._solve_bls_captcha()
  114. form_vtv['CaptchaData'] = captcha_token
  115. form_vtv["X-Requested-With"] = "XMLHttpRequest"
  116. vtv_resp = self._perform_request('POST', f"https://{domain}/Global/bls/VisaTypeVerification", data=form_vtv, headers=headers)
  117. if not vtv_resp.json()['success']:
  118. raise BizLogicError(message='Submit VisaTypeVerification Failed')
  119. # 2.2 签证类型选择
  120. return_url = vtv_resp.json()['returnUrl'] # 包含 data=xxx
  121. data_val = re.search(r"data=([^&]+)", return_url).group(1)
  122. url_vt = f"https://{domain}/Global/bls/visatype?data={data_val}"
  123. vt_resp = self._perform_request('GET', url_vt, headers=headers)
  124. # 这里需要极其复杂的 JS 变量提取 (JS Arrays -> Match Name -> Get ID)
  125. vt_payload = self._construct_visatype_payload(vt_resp.text, BeautifulSoup(vt_resp.text, 'html.parser'))
  126. vt_res = self._perform_request('POST', f"https://{domain}/Global/bls/VisaType", data=vt_payload, headers=headers)
  127. if not vt_res.json()['success']:
  128. if not vt_res.json()['available']:
  129. res.success = False
  130. res.availability_status = AvailabilityStatus.NoneAvailable
  131. return res
  132. # 2.3 获取预约参数
  133. final_url = vt_res.json()['returnUrl']
  134. q_params = parse_qs(urlparse(final_url).query)
  135. self.book_params = {k: v[0] for k, v in q_params.items()}
  136. # 2.4 查询日历
  137. url_ma = f"https://{domain}/Global/blsAppointment/ManageAppointment?{urlencode(self.book_params)}"
  138. resp_ma = self._perform_request('GET', url_ma, headers=headers)
  139. avail_str = self._extract_js_var(resp_ma.text, "var availDates", r"var availDates =(.*?);")
  140. if avail_str:
  141. avail_json = json.loads(avail_str)
  142. # 提取日期
  143. dates = [x['DateText'] for x in avail_json['ad'] if x['SingleSlotAvailable']]
  144. if dates:
  145. res.success = True
  146. res.availability_status = AvailabilityStatus.Available
  147. res.earliest_date = dates[0]
  148. for d in dates:
  149. da = VSQueryResult.DateAvailability(date=d)
  150. da.times.append(VSQueryResult.DateAvailability.TimeSlot(time="00:00", label="Available"))
  151. res.availability.append(da)
  152. else:
  153. res.success = False
  154. res.availability_status = AvailabilityStatus.NoneAvailable
  155. return res
  156. raise BizLogicError(message='Query page not found required field [var availDates]')
  157. def book(self, slot_info: VSQueryResult, user_inputs: Dict) -> VSBookResult:
  158. res = VSBookResult()
  159. domain = self.free_config.get("domain")
  160. headers = {
  161. 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0 Safari/537.36',
  162. 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
  163. }
  164. # 3.1 获取 Manage Page (为了 Token 和 JS 变量)
  165. url_ma = f"https://{domain}/Global/blsAppointment/ManageAppointment?{urlencode(self.book_params)}"
  166. resp_ma = self._perform_request('GET', url_ma, headers=headers)
  167. ma_soup = BeautifulSoup(resp_ma.text, 'html.parser')
  168. ma_form = self._extract_hidden_fields(ma_soup)
  169. req_token = ma_form.get('__RequestVerificationToken')
  170. # 3.2 上传照片
  171. if 'passport_image_url' not in user_inputs:
  172. raise NotFoundError()
  173. photo_bytes = requests.get(user_inputs['passport_image_url']).content
  174. boundary = "----WebKitFormBoundary" + "".join(random.choices(string.ascii_letters + string.digits, k=16))
  175. upload_headers = {
  176. "content-type": f"multipart/form-data; boundary={boundary}",
  177. "requestverificationtoken": req_token,
  178. "x-requested-with": "XMLHttpRequest",
  179. }
  180. body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n"
  181. f"Content-Type: image/jpeg\r\n\r\n").encode("utf-8") + photo_bytes + f"\r\n--{boundary}--\r\n".encode("utf-8")
  182. up_resp = self.session.post(f"https://{domain}/Global/query/UploadProfileImage", headers=upload_headers, data=body)
  183. if up_resp.status_code !=200:
  184. raise BizLogicError(message='Upload Passport Image failed')
  185. ma_form['ApplicantPhotoId'] = up_resp.json()['fileId']
  186. # 3.3 邮箱 OTP 流程
  187. data_val = self._extract_js_var(resp_ma.text, "win.iframeOpenUrl", r"data=([^&]+)")
  188. # 发送 OTP
  189. headers["X-Requested-With"] = "XMLHttpRequest"
  190. self._perform_request('GET', f"https://{domain}/Global/blsappointment/SendAppointmentVerificationCode?code={data_val}", headers=headers)
  191. # 读取 OTP (Wait 30s max)
  192. otp_code = self._read_otp_email(wait_sec=30)
  193. # 验证 OTP
  194. verify_payload = {"Code": otp_code, "Value": ma_form.get('EmailCode'), "Id": ma_form.get('Id')}
  195. headers['requestverificationtoken'] = req_token
  196. v_resp = self._perform_request('POST', f"https://{domain}/Global/blsappointment/VerifyEmail", data=verify_payload, headers=headers)
  197. headers.pop('requestverificationtoken')
  198. if not v_resp.json().get('success'):
  199. raise BizLogicError(message='Email verification failed')
  200. ma_form['EmailVerified'] = 'True'
  201. ma_form['EmailVerificationCode'] = otp_code
  202. # 3.4 锁定时间 (简单随机)
  203. target_date = slot_info.earliest_date
  204. # Query Slots in Day
  205. slot_url = f"https://{domain}/Global/blsappointment/GetAvailableSlotsByDate"
  206. # 构造复杂的 query params... 省略部分非关键参数
  207. slot_params = {
  208. "appointmentDate": target_date,
  209. "locationId": ma_form.get("LocationId"),
  210. "categoryId": ma_form.get("AppointmentCategoryId"),
  211. "visaType": ma_form.get("VisaType"),
  212. "visaSubType": ma_form.get("VisaSubTypeId"),
  213. "applicantCount": 1,
  214. "dataSource": ma_form.get("DataSource"),
  215. "missionId": ma_form.get("MissionId")
  216. }
  217. headers['requestverificationtoken'] = req_token
  218. slots_resp = self._perform_request('POST', slot_url, params=slot_params, headers=headers)
  219. headers.pop('requestverificationtoken')
  220. slots_data = sorted(slots_resp.json(), key=lambda x: -x["Count"]) # 选剩余最多的
  221. if not slots_data or slots_data[0]['Count'] <= 0:
  222. VSC_WARN('bls', 'Available slot times not found')
  223. res.success = False
  224. return res
  225. target_time = slots_data[0]['Name']
  226. ma_form['ServerAppointmentDate'] = target_date
  227. ma_form['AppointmentDetailsList'] = '[]'
  228. # 这里的 key 是动态的 ID,需重新解析 ID
  229. date_id = re.search(r'AppointmentDate(\d+)', str(ma_soup)).group(1)
  230. slot_id = re.search(r'AppointmentSlot(\d+)', str(ma_soup)).group(1)
  231. ma_form[f'AppointmentDate{date_id}'] = target_date
  232. ma_form[f'AppointmentSlot{slot_id}'] = target_time
  233. # 3.5 再次验证码 & 提交 ManageAppointment
  234. captcha_token = self._solve_bls_captcha(data_val)
  235. ma_form['CaptchaData'] = captcha_token
  236. final_ma_resp = self._perform_request('POST', f"https://{domain}/Global/BLSAppointment/ManageAppointment", data=ma_form, headers=headers)
  237. appt_model_id = final_ma_resp.json().get('model', {}).get('Id')
  238. if not appt_model_id:
  239. raise NotFoundError(message='Appointment model id not found')
  240. # 3.6 填写申请表 (VisaAppointmentForm)
  241. # 获取页面 -> 解析 JS 变量 -> 映射 UserInfo -> 提交
  242. # 这里逻辑较深,核心是映射。简化为提交一个空的 applicants JSON,实际需完整映射。
  243. # 假设 _fill_applicant_form 做了这些工作
  244. self._submit_final_form(appt_model_id, user_inputs, self.book_params, req_token)
  245. # 成功,返回 Liveness 链接
  246. Liveness_page = f"https://{domain}/Global/BlsAppointment/livenessView?id={appt_model_id}"
  247. session_data = self._save_http_session(Liveness_page)
  248. res.success = True
  249. res.account = self.config.account.username
  250. res.session_id = session_data['session_id']
  251. res.book_date = target_date
  252. res.book_time = target_time
  253. VSC_INFO("bls_plg", "[%s] Book Success. Liveness URL: %s", self.group_id, res.payment_link)
  254. return res
  255. def _get_proxy_url(self):
  256. # 构造代理
  257. proxy_url = ""
  258. if self.config.proxy.ip:
  259. s = self.config.proxy
  260. if s.username:
  261. proxy_url = f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
  262. else:
  263. proxy_url = f"{s.scheme}://{s.ip}:{s.port}"
  264. return proxy_url
  265. def _save_debug_html(self, content: str, prefix: str = "debug"):
  266. save_dir = "debug_pages"
  267. if not os.path.exists(save_dir):
  268. os.makedirs(save_dir)
  269. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  270. filename = f"{save_dir}/{prefix}_{self.group_id}_{timestamp}.html"
  271. with open(filename, "w", encoding="utf-8") as f:
  272. f.write(content)
  273. VSC_INFO("bls_plg", "[%s] HTML saved to: %s", self.group_id, filename)
  274. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
  275. """
  276. 统一 HTTP 请求封装,严格复刻 C++ 逻辑:
  277. 1. 发送 OPTIONS 请求
  278. 2. 发送实际请求
  279. """
  280. print(f'[perform request] {method} {url}')
  281. resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
  282. VSC_INFO('bls_plg', resp.text)
  283. if resp.status_code == 200:
  284. return resp
  285. elif resp.status_code == 401:
  286. self.is_healthy = False
  287. raise SessionExpiredOrInvalidError()
  288. elif resp.status_code == 403:
  289. raise PermissionDeniedError()
  290. elif resp.status_code == 429:
  291. self.is_healthy = False
  292. raise RateLimiteddError()
  293. else:
  294. raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
  295. def _solve_bls_captcha(self, data='') -> Optional[str]:
  296. """
  297. 验证码处理:获取图片 -> 调用远程 OCR 服务 -> 提交验证
  298. """
  299. domain = self.free_config.get("domain")
  300. url = f"https://{domain}/Global/NewCaptcha/GenerateCaptcha"
  301. if data: url = f"https://{domain}/Global/CaptchaPublic/GenerateCaptcha?data={data}"
  302. headers = {
  303. 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0 Safari/537.36',
  304. 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
  305. }
  306. resp = self._perform_request("GET", url, headers=headers)
  307. with open("tmp.html", 'w') as f:
  308. f.write(resp.text)
  309. selected_ids = []
  310. html_file_path = Path("tmp.html").resolve()
  311. file_url = f'file://{html_file_path}'
  312. self.browser.get(file_url)
  313. captions_ele = self.browser.ele('xpath://*[@id="captcha-main-div"]/div/div[1]', timeout=5)
  314. if not captions_ele:
  315. raise NotFoundError(message='Captions elements not found')
  316. caption_eles = captions_ele.children()
  317. caption_text = ''
  318. for caption in caption_eles:
  319. if not caption.states.is_covered:
  320. caption_text = caption.text
  321. numbers = re.findall(r'\d+', caption_text)[0]
  322. captcha_images_ele = self.browser.ele('xpath://*[@id="captcha-main-div"]/div/div[2]')
  323. captcha_image_eles = captcha_images_ele.children()
  324. rect_dict = {}
  325. for captcha_image in captcha_image_eles:
  326. img = captcha_image.ele('.captcha-img')
  327. if img.states.has_rect:
  328. rect_dict[img._backend_id] = img.states.has_rect
  329. for captcha_image in captcha_image_eles:
  330. img = captcha_image.ele('.captcha-img')
  331. if img.states.has_rect and img.states.is_covered == False:
  332. img_src = img.attr('src')
  333. if img_src and img_src.startswith('data:image'):
  334. base64_data = re.sub('^data:image/.+;base64,', '', img_src)
  335. img_bytes = base64.b64decode(base64_data)
  336. ocr_resp = requests.post(
  337. self.ocr_service_url,
  338. data=img_bytes,
  339. headers={"Content-Type": "application/octet-stream"},
  340. timeout=5
  341. )
  342. if ocr_resp.status_code == 200:
  343. res_json = ocr_resp.json()
  344. ocr_res = res_json.get('data', '').replace('$', '')[:3]
  345. VSC_INFO("bls_plg", f'ocr captcha id={captcha_image.attr("id")} result={ocr_res}, target={numbers}')
  346. if ocr_res == numbers:
  347. eid = captcha_image.attr('id')
  348. selected_ids.append(eid)
  349. else:
  350. raise BizLogicError(message='Captcha server response error')
  351. if not selected_ids:
  352. raise BizLogicError(message='Captcha selected ids is empty')
  353. VSC_INFO("bls_plg", f'select_ids={selected_ids}')
  354. soup = BeautifulSoup(resp.text, 'html.parser')
  355. # 3. 提交选中结果
  356. form = self._extract_hidden_fields(soup)
  357. form['SelectedImages'] = ",".join(selected_ids)
  358. submit_url = f"https://{domain}/Global/{'CaptchaPublic' if data else 'NewCaptcha'}/SubmitCaptcha"
  359. headers["X-Requested-With"] = "XMLHttpRequest"
  360. resp = self._perform_request('POST', submit_url, headers=headers, data=form)
  361. return resp.json()['captcha']
  362. def _extract_hidden_fields(self, soup) -> Dict:
  363. params = {}
  364. form = soup.find("form")
  365. if form:
  366. for inp in form.find_all("input"):
  367. name = inp.get("name")
  368. if name: params[name] = inp.get("value", "")
  369. else:
  370. VSC_WARN('bls_plg', 'Form element not found')
  371. return params
  372. def _extract_js_var(self, html, context, pattern):
  373. # 简单正则提取
  374. if context in html:
  375. match = re.search(pattern, html)
  376. if match: return match.group(1)
  377. return ""
  378. def _construct_visatype_payload(self, html: str, soup: BeautifulSoup) -> Optional[Dict]:
  379. """
  380. 构造 VisaType 提交参数 (对应原代码 parse_visatype_form)
  381. """
  382. # 1. 基础表单参数 (__RequestVerificationToken 等)
  383. params = self._extract_hidden_fields(soup)
  384. # 2. 提取页面中的 JS 数据变量
  385. def get_js_data(var_name):
  386. try:
  387. # 匹配 var name = [...]; 结构
  388. pattern = f"var {var_name}\\s*=\\s*(.*?);"
  389. match = re.search(pattern, html, re.DOTALL)
  390. if match:
  391. return json.loads(match.group(1))
  392. except Exception as e:
  393. VSC_DEBUG("bls_plg", f"Failed to parse JS var {var_name}: {e}")
  394. return []
  395. jurisdiction_list = get_js_data("jurisdictionData")
  396. location_list = get_js_data("locationData")
  397. visa_type_list = get_js_data("visaIdData")
  398. visa_subtype_list = get_js_data("visasubIdData")
  399. app_category_list = get_js_data("AppointmentCategoryIdData")
  400. # 3. 读取配置
  401. cfg_jur = self.free_config.get("jurisdiction")
  402. cfg_loc = self.free_config.get("location")
  403. cfg_type = self.free_config.get("visaType")
  404. cfg_subtype = self.free_config.get("visaSubType")
  405. cfg_cat = self.free_config.get("appointmentCategory", "Normal")
  406. # 4. 匹配 ID
  407. jur_id = None
  408. loc_id = None
  409. type_id = None
  410. subtype_id = None
  411. cat_id = None
  412. # (A) Appointment Category
  413. for item in app_category_list:
  414. if item.get("Name") == cfg_cat:
  415. cat_id = item.get("Id")
  416. break
  417. # (B) Jurisdiction (如果配置了)
  418. if cfg_jur and jurisdiction_list:
  419. for item in jurisdiction_list:
  420. if item.get("Name") == cfg_jur:
  421. jur_id = item.get("Id")
  422. break
  423. # (C) Location
  424. for item in location_list:
  425. if item.get("Name") == cfg_loc:
  426. loc_id = item.get("Id")
  427. break
  428. # (D) Visa Type (需匹配 LocationId)
  429. if loc_id:
  430. for item in visa_type_list:
  431. # 比较 Name 和 LocationId
  432. if item.get("Name") == cfg_type and str(item.get("LocationId")) == str(loc_id):
  433. type_id = item.get("Id")
  434. break
  435. # (E) Visa SubType (需匹配 VisaType Value)
  436. if type_id:
  437. for item in visa_subtype_list:
  438. # BLS 逻辑: visasubIdData 中的 Value 字段对应 VisaTypeId
  439. if item.get("Name") == cfg_subtype and str(item.get("Value")) == str(type_id):
  440. subtype_id = item.get("Id")
  441. break
  442. # 5. 构造动态参数 & 校验
  443. if not cat_id:
  444. raise NotFoundError(message=f"Config: AppCategory '{cfg_cat}' not found")
  445. params[f"AppointmentCategoryId{cat_id}"] = cat_id
  446. if cfg_jur:
  447. if not jur_id:
  448. raise NotFoundError(message=f"Config: Jurisdiction '{cfg_jur}' not found")
  449. params[f"JurisdictionId{jur_id}"] = jur_id
  450. if not loc_id:
  451. raise NotFoundError(message=f"Config: Location '{cfg_loc}' not found")
  452. params[f"Location{loc_id}"] = loc_id
  453. if not type_id:
  454. raise NotFoundError(message=f"Config: VisaType '{cfg_type}' not found for Loc '{cfg_loc}'")
  455. params[f"VisaType{type_id}"] = type_id
  456. if not subtype_id:
  457. raise NotFoundError(message=f"Config: VisaSubType '{cfg_subtype}' not found")
  458. params[f"VisaSubType{subtype_id}"] = subtype_id
  459. # 固定参数
  460. params["AppointmentFor1"] = "Individual"
  461. # 6. 构造 ResponseData (行为轨迹模拟)
  462. # BLS 后端会校验这个字段,模拟用户选择下拉框的时间间隔
  463. response_data = []
  464. current_time = datetime.utcnow()
  465. def add_trace(prefix, val_id):
  466. nonlocal current_time
  467. # 模拟 1-3 秒的操作间隔
  468. duration = random.randint(1000, 3000)
  469. gap = random.randint(500, 1500)
  470. start = current_time
  471. end = start + timedelta(milliseconds=duration)
  472. # BLS 时间格式: 2023-10-27T10:00:00.123Z
  473. fmt = "%Y-%m-%dT%H:%M:%S.%f"
  474. response_data.append({
  475. "Id": f"{prefix}{val_id}",
  476. "Start": start.strftime(fmt)[:-3] + "Z",
  477. "End": end.strftime(fmt)[:-3] + "Z",
  478. "Total": duration,
  479. "Selected": True
  480. })
  481. current_time = end + timedelta(milliseconds=gap)
  482. # 按顺序添加轨迹
  483. add_trace("AppointmentCategoryId", cat_id)
  484. if jur_id: add_trace("JurisdictionId", jur_id)
  485. add_trace("Location", loc_id)
  486. add_trace("VisaType", type_id)
  487. add_trace("VisaSubType", subtype_id)
  488. params["ResponseData"] = json.dumps(response_data)
  489. params["X-Requested-With"] = "XMLHttpRequest"
  490. return params
  491. def _submit_final_form(self, model_id: str, user_inputs: Dict, book_params: Dict, token: str):
  492. """
  493. 提交最终签证申请表 (VisaAppointmentForm)
  494. 对应原代码的: get_visa_appointment_form_html -> parse -> fix_data -> submit
  495. """
  496. domain = self.free_config.get("domain")
  497. headers = {
  498. 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0 Safari/537.36',
  499. 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
  500. }
  501. # 1. 获取表单页面 (为了提取 JS 变量映射表)
  502. url_get = f"https://{domain}/Global/BlsAppointment/VisaAppointmentForm?appointmentId={model_id}"
  503. # 构造 Referer
  504. ref_query = urlencode(book_params)
  505. referer = f"Global/blsAppointment/ManageAppointment?{ref_query}"
  506. headers['X-Requested-With'] = "XMLHttpRequest"
  507. headers['Referer'] = f"https://{domain}/{referer}"
  508. resp = self._perform_request('GET', url_get, headers=headers)
  509. headers.pop['X-Requested-With']
  510. headers.pop['Referer']
  511. html = resp.text
  512. soup = BeautifulSoup(resp.text, 'html.parser')
  513. # 2. 提取基础隐藏域 (包含 __RequestVerificationToken 等)
  514. form_data = self._extract_hidden_fields(soup)
  515. # 3. 提取下拉菜单数据源 (JS Variables)
  516. # BLS 的页面里有很多 var countryData = [...]; 这种数据
  517. def get_list(name):
  518. val = self._extract_js_var(html, f"var {name}", rf"var {name}\s*=\s*(.*?);")
  519. return json.loads(val) if val else []
  520. # 提取关键数据源
  521. country_data = get_list("countryData")
  522. gender_data = get_list("genderData")
  523. marital_data = get_list("maritalStatusData")
  524. occupation_data = get_list("occupationData")
  525. # passport_type_data = get_list("passportTypeData") # 通常默认 Ordinary
  526. # 4. 辅助函数:根据文本找 ID
  527. def find_id(data_list, text_val, default=None):
  528. if not text_val: return default
  529. text_val = str(text_val).lower().strip()
  530. for item in data_list:
  531. if str(item.get("Name")).lower() == text_val:
  532. return item.get("Id")
  533. return default
  534. # 5. 准备日期 (YYYY-MM-DD)
  535. # uinfo 中的日期可能是不同格式,需统一
  536. def fmt_date(d_str):
  537. try:
  538. # 尝试解析常见格式
  539. for fmt in ["%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"]:
  540. try:
  541. return datetime.strptime(d_str, fmt).strftime("%Y-%m-%d")
  542. except: pass
  543. except: pass
  544. return d_str # 原样返回 fallback
  545. dob = fmt_date(user_inputs.get("birthday", ""))
  546. ppt_issue = fmt_date(user_inputs.get("passport_issue_date", ""))
  547. ppt_expiry = fmt_date(user_inputs.get("passport_expiry_date", ""))
  548. # 自动计算行程日期 (如果未提供,默认一个月后)
  549. try:
  550. travel_date = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d")
  551. except: travel_date = ""
  552. # 6. 构造申请人详细数据对象 (JSON)
  553. # 注意:这里的字段名必须严格匹配 BLS 后端实体定义
  554. applicant_detail = {
  555. "ApplicantSerialNo": "1",
  556. "ApplicantId": form_data.get("applicantId", "0"), # 从页面隐藏域提取
  557. "Id": form_data.get("applicantId", "0"),
  558. "ParentId": form_data.get("Id", model_id), # 关联的 Appointment ID
  559. # 基本信息
  560. "FirstName": user_inputs.get("first_name", ""),
  561. "SurName": user_inputs.get("last_name", ""),
  562. "LastName": user_inputs.get("last_name", ""),
  563. "SurnameAtBirth": user_inputs.get("last_name", ""), # 默认同名
  564. "GenderId": find_id(gender_data, user_inputs.get("gender"), "1"), # 默认 Male
  565. "MaritalStatusId": find_id(marital_data, user_inputs.get("marital_status", "Single"), "1"),
  566. "ServerDateOfBirth": dob,
  567. # 国籍/出生地
  568. "PlaceOfBirth": user_inputs.get("place_of_birth", "-"),
  569. "CountryOfBirthId": find_id(country_data, user_inputs.get("nationality"), "0"),
  570. "NationalityAtBirthId": find_id(country_data, user_inputs.get("nationality"), "0"),
  571. "NationalityId": find_id(country_data, user_inputs.get("nationality"), "0"),
  572. # 护照信息
  573. "PassportType": "Ordinary Passport", # 默认
  574. "PassportNo": user_inputs.get("passport_no", ""),
  575. "ServerPassportIssueDate": ppt_issue,
  576. "ServerPassportExpiryDate": ppt_expiry,
  577. "IssuePlace": user_inputs.get("place_of_issue", "-"),
  578. "IssueCountryId": find_id(country_data, user_inputs.get("nationality"), "0"),
  579. # 联系方式 (必填占位符)
  580. "HomeAddressLine1": "-",
  581. "HomeAddressCity": "-",
  582. "HomeAddressPostalCode": "-",
  583. "HomeAddressContactNumber": user_inputs.get("phone", "-"),
  584. "HomeAddressCountryId": find_id(country_data, user_inputs.get("nationality"), "0"),
  585. "EmployerName": "-",
  586. "EmployerAddress": "-",
  587. # 职业
  588. "CurrentOccupationId": find_id(occupation_data, user_inputs.get("occupation", "Others"), "20"),
  589. # 行程信息 (部分写死为常规旅游)
  590. "PurposeOfJourneyId": "Tourism",
  591. "MemberStateDestinationId": "Spain",
  592. "MemberStateFirstEntryId": "Spain",
  593. "NumberOfEntriesRequested": "Multiple Entries",
  594. "IntendedStayDuration": "5",
  595. "ServerTravelDate": travel_date,
  596. "ServerIntendedDateOfArrival": travel_date,
  597. "ServerIntendedDateOfDeparture": travel_date, # 简化
  598. # 费用承担
  599. "CostCoveredById": "By the Applicant himself / herself",
  600. "MeansOfSupportId": "Cash",
  601. # 杂项
  602. "IsMinor": False,
  603. "IsVisaIssuedBefore": False,
  604. "BlsInvitingAuthority": "1", # 这里的 1 通常代表 "No" 或者特定枚举
  605. "PreviousFingerPrintStatus": "2", # 2 通常代表 No
  606. # 邀请人信息 (旅游通常填酒店或空)
  607. "InvitingAuthorityName": "-",
  608. "InvitingAddress": "-",
  609. "InvitingCity": "-",
  610. "InvitingEmail": "no-reply@example.com"
  611. }
  612. # 7. 更新表单数据
  613. # ApplicantsDetailsList 需要是一个 JSON 字符串
  614. form_data['ApplicantsDetailsList'] = json.dumps([applicant_detail])
  615. # 补全其他可能需要的字段
  616. form_data['PreviousFingerPrintStatus_0'] = "2"
  617. form_data['BlsInvitingAuthority_0'] = "1"
  618. form_data["X-Requested-With"] = "XMLHttpRequest"
  619. # 8. 提交
  620. # 注意:提交地址通常和 manage appointment 相同,或者是特定的 Save 接口
  621. # 根据你的原代码,是 Global/BLSAppointment/ManageAppointment
  622. url_post = f"https://{domain}/Global/BLSAppointment/ManageAppointment"
  623. # Headers 需要 Token
  624. headers = {
  625. 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0 Safari/537.36',
  626. 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
  627. "Referer": f"https://{domain}/{referer}",
  628. "X-Requested-With": "XMLHttpRequest",
  629. "requestverificationtoken": token
  630. }
  631. # 这里的 form_data['params'] 逻辑在 _extract_hidden_fields 可能会有差异
  632. # 确保 form_data 是扁平的字典
  633. submit_resp = self._perform_request('POST', url_post, data=form_data, headers=headers)
  634. if submit_resp.json().get('success'):
  635. VSC_INFO("bls_plg", "[%s] Final Form Submitted Successfully.", self.group_id)
  636. return True
  637. raise BizLogicError(message='Submit application form failed')
  638. def _read_otp_email(self, wait_sec: int = 60) -> str:
  639. """
  640. 读取 BLS 的 OTP 邮件
  641. """
  642. master_email = "visafly666@gmail.com"
  643. recipient = self.config.account.username
  644. sender = "Info@blsinternational.com"
  645. subject_keywords = "BLS"
  646. body_keywords = "verification code"
  647. # 设置时间起点 (UTC)
  648. now_utc = datetime.utcnow()
  649. formatted_utc_time = now_utc.strftime("%Y-%m-%d %H:%M:%S")
  650. VSC_INFO("bls_plg", "[%s] Waiting for OTP from %s...", self.group_id, sender)
  651. # 轮询查收, 每 5 秒查一次
  652. attempts = wait_sec // 5
  653. for i in range(attempts):
  654. # 调用云端接口获取邮件内容
  655. # expiry=300 表示邮件有效搜索窗口为 5 分钟
  656. content_out = VSCloudApi.Instance().fetch_mail_content(
  657. master_email,
  658. sender,
  659. recipient,
  660. subject_keywords,
  661. body_keywords,
  662. formatted_utc_time,
  663. 300
  664. )
  665. # 正则匹配 6 位数字验证码
  666. match = re.search(r'\b\d{6}\b', content_out)
  667. if match:
  668. otp = match.group(0)
  669. VSC_INFO("bls_plg", "[%s] OTP code found: %s", self.group_id, otp)
  670. return otp
  671. # 等待下一次轮询
  672. time.sleep(5)
  673. if i % 2 == 0:
  674. VSC_DEBUG("bls_plg", "[%s] OTP not received yet, retrying...", self.group_id)
  675. # 超时处理
  676. raise NotFoundError(f"OTP email not found within {wait_sec}s")
  677. def _save_http_session(self, page_url):
  678. """
  679. 提取 cookies, local_storage, 存入 VSCloudApi
  680. """
  681. cookies_dict = {}
  682. # 方式 1: curl_cffi 的 cookies 对象通常支持 get_dict()
  683. if hasattr(self.session.cookies, "get_dict"):
  684. cookies_dict = self.session.cookies.get_dict()
  685. else:
  686. # 方式 2: 迭代 (兼容标准 CookieJar)
  687. for c in self.session.cookies:
  688. cookies_dict[c.name] = c.value
  689. cookies_str = json.dumps(cookies_dict)
  690. # 简单生成 SessionID hash
  691. ua_str = self.user_agent or "unknown_ua"
  692. raw = cookies_str + ua_str + page_url
  693. session_id = hashes.Hash(hashes.SHA256(), backend=default_backend())
  694. session_id.update(raw.encode())
  695. sid = session_id.finalize().hex()
  696. proxy_str = ""
  697. if self.config.proxy.ip:
  698. proxy_str = f"{self.config.proxy.scheme}://"
  699. if self.config.proxy.username:
  700. proxy_str += f"{self.config.proxy.username}:{self.config.proxy.password}@"
  701. proxy_str += f"{self.config.proxy.ip}:{self.config.proxy.port}"
  702. return VSCloudApi.Instance().create_http_session(
  703. sid, cookies_str, "", ua_str, proxy_str, page_url
  704. )