de_plugin.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. import time
  2. import json
  3. import random
  4. import re
  5. import os
  6. import base64
  7. from datetime import datetime
  8. from typing import List, Dict, Optional, Any, Callable
  9. from urllib.parse import urljoin
  10. from curl_cffi import requests, const
  11. from bs4 import BeautifulSoup
  12. from vs_plg import IVSPlg
  13. from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, TimeSlot, DateAvailability, AvailabilityStatus, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  14. from toolkit.vs_cloud_api import VSCloudApi
  15. from toolkit.ocr_engine import DddOcrEngine
  16. def to_yyyymmdd(data_str: str, date_str_format: str, target_format: str="%Y-%m-%d"):
  17. # 转换日期到YYYY-MM-DD 固定格式
  18. dt = datetime.strptime(data_str, date_str_format)
  19. return dt.strftime("%Y-%m-%d")
  20. def get_alias_email(email: str, new_domain: str = "gmail-app.com") -> str:
  21. """
  22. 将邮箱域名替换为指定域名(默认 gmail-app.com)
  23. """
  24. if "@" not in email:
  25. raise ValueError(f"Invalid email: {email}")
  26. local_part, _ = email.rsplit("@", 1)
  27. return f"{local_part}@{new_domain}"
  28. class DePlugin(IVSPlg):
  29. """
  30. Germany (Visametric) 签证预约插件
  31. 适配 Visametric Ireland -> Germany 流程
  32. """
  33. def __init__(self, group_id: str):
  34. self.group_id = group_id
  35. self.config: Optional[VSPlgConfig] = None
  36. self.free_config: Dict[str, Any] = {}
  37. self.logger = None
  38. self.session: Optional[requests.Session] = None
  39. # 状态
  40. self.is_healthy = True
  41. # 关键上下文变量 (从页面提取)
  42. self.base_url = "https://ie-appointment.visametric.com"
  43. self.csrf_token = ""
  44. self.personal_info_val = ""
  45. self.email_val_control = ""
  46. # 默认 OCR 服务地址
  47. self.session_create_time: float = 0
  48. def get_group_id(self) -> str:
  49. return self.group_id
  50. def set_log(self, logger: Callable[[str], None]) -> None:
  51. self.logger = logger
  52. def set_config(self, config: VSPlgConfig):
  53. self.config = config
  54. self.free_config = config.free_config or {}
  55. if self.free_config.get("base_url"):
  56. self.base_url = self.free_config["base_url"].rstrip('/')
  57. def health_check(self) -> bool:
  58. if not self.is_healthy:
  59. return False
  60. if self.session is None:
  61. return False
  62. if self.config.session_max_life > 0:
  63. current_time = time.time()
  64. elapsed_time = current_time - self.session_create_time
  65. if elapsed_time > self.config.session_max_life * 60:
  66. self._log(f"Session Life ({int(elapsed_time)}s) out of max life limit ({self.config.session_max_life * 60}s), mark as unhealth session")
  67. return False
  68. return True
  69. def create_session(self):
  70. """
  71. 初始化会话:过盾 -> 获取 CSRF -> 识别图片验证码 -> 提交验证码 -> 获取上下文
  72. """
  73. # 1. 初始化 Session
  74. curlopt = {
  75. const.CurlOpt.MAXAGE_CONN: 1800,
  76. const.CurlOpt.MAXLIFETIME_CONN: 1800,
  77. const.CurlOpt.VERBOSE: self.config.debug,
  78. }
  79. self.session = requests.Session(
  80. proxy=self._get_proxy_url(),
  81. impersonate="chrome124",
  82. curl_options=curlopt,
  83. use_thread_local_curl=False,
  84. http_version=const.CurlHttpVersion.V2TLS
  85. )
  86. self.ocr_engine = DddOcrEngine()
  87. # 2. 访问首页,获取 CSRF 和 Captcha 图片
  88. # Visametric 首页通常有 Cloudflare
  89. url_home = f"{self.base_url}/en"
  90. # 尝试过盾
  91. self._solve_cloudflare5S_challenge()
  92. default_headers = self._get_headers()
  93. default_headers.pop("X-Requested-With")
  94. resp = self._perform_request('GET', url_home, headers=default_headers)
  95. if self.config.debug:
  96. self._save_debug_html(resp.text, prefix="VisaMetric_Home_Page")
  97. html = resp.text
  98. soup = BeautifulSoup(html, 'html.parser')
  99. meta = soup.find('meta', {'name': 'csrf-token'})
  100. if not meta:
  101. raise NotFoundError(message='Missing csrf-token in html')
  102. self.csrf_token = meta.get('content', '')
  103. # 提取验证码图片 Base64, 正则匹配: "data:image/png;base64," + "..."
  104. match = re.search(r'"data:image/png;base64,"\s*\+\s*"(.*?)"', html)
  105. if not match:
  106. raise NotFoundError(message="Captcha image not found")
  107. captcha_b64 = base64.b64decode(match.group(1))
  108. captcha_code = self.ocr_engine.inference_captcha(captcha_b64)
  109. self._log(f"Captcha recognized: {captcha_code}")
  110. # 4. 提交验证码 (/appointment-form)
  111. # 这一步是为了让服务器验证 Session,并返回包含 personalinfo 的页面
  112. self._submit_captcha(captcha_code)
  113. self.session_create_time = time.time()
  114. self._log("Session created successfully.")
  115. def query(self, apt_type: AppointmentType) -> VSQueryResult:
  116. """
  117. 查询可用日期 (/getdate)
  118. """
  119. res = VSQueryResult()
  120. # 构造 Payload (参考 get_slot_day) 这里的 ID 需要根据实际情况配置
  121. consular_id = self.free_config.get("consularid", "1") # 1=Ireland?
  122. max_retries = self.free_config.get("slot_query_max_retries", 2)
  123. url = f"{self.base_url}/en/getdate"
  124. payload = {
  125. "consularid": consular_id,
  126. "exitid": "1",
  127. "servicetypeid": "1",
  128. "calendarType": "2",
  129. "totalperson": "1"
  130. }
  131. default_headers = self._get_headers()
  132. default_headers['X-CSRF-TOKEN'] = self.csrf_token
  133. for attempt in range(1, max_retries + 1):
  134. try:
  135. resp = self._perform_request('POST', url, data=payload, headers=default_headers)
  136. break # ✅ 请求成功,跳出重试循环
  137. except PermissionDeniedError:
  138. self._log(f"Getdate blocked (403), attempt {attempt}/{max_retries}")
  139. # 最后一次就不再绕盾了
  140. if attempt >= max_retries:
  141. raise PermissionDeniedError()
  142. self._solve_cloudflare5S_challenge()
  143. self._log("Cloudflare bypass success, retrying...")
  144. continue
  145. # Visametric 返回 JSON: {"getDateEnable": ["15-01-2026", "16-01-2026"]}
  146. j = resp.json()
  147. dates = j.get("getDateEnable", [])
  148. if dates:
  149. res.success = True
  150. res.availability_status = AvailabilityStatus.Available
  151. earliest_date = dates[0]
  152. earliest_dt = datetime.strptime(earliest_date, "%d-%m-%Y")
  153. # Visametric 返回 DD-MM-YYYY → 标准化为 YYYY-MM-DD
  154. res.earliest_date = earliest_dt
  155. res.availability = [
  156. DateAvailability(date=datetime.strptime(d, "%d-%m-%Y"), times=[])
  157. for d in dates
  158. ]
  159. else:
  160. # 查询成功,但没有可用日期
  161. res.success = False
  162. res.availability_status = AvailabilityStatus.NoneAvailable
  163. return res
  164. def book(self, slot_info: VSQueryResult, user_inputs: Dict) -> VSBookResult:
  165. """
  166. 执行预约:选择日期 -> 选择时间 -> 发邮件 -> 填表 -> 提交
  167. """
  168. res = VSBookResult()
  169. # 1. 筛选日期
  170. available_dates = [da.date for da in slot_info.availability]
  171. exp_start = user_inputs.get('expected_start_date', '')
  172. exp_end = user_inputs.get('expected_end_date', '')
  173. available_dates_str = [
  174. da.date.strftime("%Y-%m-%d")
  175. for da in slot_info.availability
  176. ]
  177. valid_dates = self._filter_dates(available_dates_str, exp_start, exp_end)
  178. if not valid_dates:
  179. raise NotFoundError(message="No dates match user constraints")
  180. target_date = random.choice(valid_dates)
  181. self._log(f"Selected date: {target_date}")
  182. # 2. 获取时间 (/senddate)
  183. time_slot = self._get_slot_time(target_date)
  184. self._log(f"Selected time: {time_slot['time']}")
  185. # 3. 触发邮件流程 (Step 1: /jky45fgd)
  186. alias_email = get_alias_email(user_inputs.get("email"), new_domain='gmail-app.com')
  187. self._send_email_step1(alias_email)
  188. # 4. 触发邮件流程 (Step 2: /confirmCodeSendMail) 这一步会发送包含验证码的邮件 根据原代码逻辑: send_email("0") 触发发送
  189. self._send_email_step2("0")
  190. # 5. 读取 OTP
  191. # Visametric 邮件发送者
  192. recipient = alias_email
  193. otp_code = self._read_otp_email(recipient)
  194. # 6. 提交验证码并确认 (/personal/appointment/create)
  195. book_res_html = self._confirm_appointment(target_date, time_slot, user_inputs, otp_code, alias_email)
  196. if "complete all required fields" in book_res_html.lower():
  197. raise BizLogicError(message='Comfirm appointment response <complete all required fields>')
  198. # 7. 提取结果
  199. match = re.search(r'https:\/\/checkout\.stripe\.com\/c\/pay\/[^\s"]+', book_res_html)
  200. res.success = True
  201. res.fee_amount = 3000
  202. res.fee_currency = 'EUR'
  203. res.book_date = target_date
  204. res.book_time = time_slot['time']
  205. if match:
  206. res.payment_link = match.group(0)
  207. self._log(f"Payment Link Found: {res.payment_link}")
  208. return res
  209. # ---------------------------------------------------------
  210. # 辅助方法
  211. # ---------------------------------------------------------
  212. def _log(self, message):
  213. if self.logger:
  214. self.logger(f'[DePlugin] [{self.group_id}] {message}')
  215. def _get_headers(self) -> Dict[str, str]:
  216. """基础 Header"""
  217. return {
  218. "Accept": "*/*",
  219. "Accept-Language": "en,zh-CN;q=0.9,zh;q=0.8",
  220. "Origin": self.base_url,
  221. "Referer": f"{self.base_url}/en/appointment-form", # 默认 Referer
  222. "X-Requested-With": "XMLHttpRequest"
  223. }
  224. def _get_proxy_url(self):
  225. # 构造代理
  226. proxy_url = ""
  227. if self.config.proxy.ip:
  228. s = self.config.proxy
  229. if s.username:
  230. proxy_url = f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
  231. else:
  232. proxy_url = f"{s.scheme}://{s.ip}:{s.port}"
  233. return proxy_url
  234. def _save_debug_html(self, content: str, prefix: str = "debug"):
  235. save_dir = "debug_pages"
  236. if not os.path.exists(save_dir):
  237. os.makedirs(save_dir)
  238. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  239. filename = f"{save_dir}/{prefix}_{timestamp}.html"
  240. with open(filename, "w", encoding="utf-8") as f:
  241. f.write(content)
  242. self._log(f"HTML saved to: {filename}")
  243. def _submit_captcha(self, code):
  244. url = f"{self.base_url}/en/appointment-form"
  245. payload = {
  246. '_token': self.csrf_token,
  247. 'cpJvnsControl': '',
  248. 'mailConfirmCode': code
  249. }
  250. headers = self._get_headers()
  251. headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
  252. resp = self._perform_request('POST', url, data=payload, headers=headers)
  253. if self.config.debug:
  254. self._save_debug_html(resp.text, prefix="VisaMetric_Make_Appointment_Page")
  255. # 关键:提交验证码后,返回的 HTML 中包含了后续需要的加密参数
  256. match_pi = re.search(r"personalinfo:\s*'([^']*)'", resp.text)
  257. if match_pi:
  258. self.personal_info_val = match_pi.group(1)
  259. # emailValControl: '...'
  260. match_ev = re.search(r"emailValControl:\s*'([^']*)'", resp.text)
  261. if match_ev:
  262. self.email_val_control = match_ev.group(1)
  263. if not self.personal_info_val:
  264. raise NotFoundError(message="Personalinfo not found in captcha response")
  265. soup = BeautifulSoup(resp.text, 'html.parser')
  266. meta = soup.find('meta', {'name': 'csrf-token'})
  267. if not meta:
  268. raise NotFoundError(message='Missing csrf-token in html')
  269. self.csrf_token = meta.get('content', '')
  270. def _get_slot_time(self, date) -> Optional[Dict]:
  271. url = f"{self.base_url}/en/senddate"
  272. dt_m = datetime.strptime(date, "%Y-%m-%d")
  273. converted_date = dt_m.strftime("%d-%m-%Y")
  274. payload = {
  275. "fulldate": converted_date,
  276. "totalperson": "1",
  277. "set_new_consular_id": self.free_config.get("consularid", "1"),
  278. "set_new_exit_office_id": "1",
  279. "calendarType": "2",
  280. "set_new_service_type_id": "1",
  281. "personalinfo": self.personal_info_val
  282. }
  283. headers = self._get_headers()
  284. headers['X-CSRF-TOKEN'] = self.csrf_token # 这里需要 CSRF
  285. headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
  286. resp = self._perform_request('POST', url, data=payload, headers=headers)
  287. soup = BeautifulSoup(resp.text, 'html.parser')
  288. buttons = soup.find_all('button')
  289. slots = []
  290. for btn in buttons:
  291. i_tag = btn.find('i')
  292. if i_tag:
  293. time_val = i_tag.next_sibling.strip()
  294. slots.append({
  295. 'time': time_val,
  296. 'data_id': btn.get('data-id'),
  297. 'data_all': btn.get('data-all')
  298. })
  299. if slots:
  300. return random.choice(slots)
  301. else:
  302. raise NotFoundError(message='Not slot time available')
  303. def _send_email_step1(self, email):
  304. url = f"{self.base_url}/en/jky45fgd"
  305. payload = {
  306. "emailCheck": email,
  307. "personalinfo": self.personal_info_val
  308. }
  309. headers = self._get_headers()
  310. headers['X-CSRF-TOKEN'] = self.csrf_token
  311. headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
  312. self._perform_request('POST', url, data=payload, headers=headers)
  313. def _send_email_step2(self, code_val):
  314. url = f"{self.base_url}/en/confirmCodeSendMail"
  315. payload = {
  316. "confirmCode": code_val,
  317. "emailValControl": self.email_val_control
  318. }
  319. headers = self._get_headers()
  320. headers['X-CSRF-TOKEN'] = self.csrf_token
  321. headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
  322. self._perform_request('POST', url, data=payload, headers=headers)
  323. def _read_otp_email(self, recipient) -> str:
  324. """
  325. 读取 OTP 邮件
  326. """
  327. master_email = "visafly666@gmail.com"
  328. sender = 'Visametric - verify at visametric.com'
  329. subject_keywords = 'Verification Code'
  330. body_keywords = 'Verification code'
  331. now_utc = datetime.utcnow()
  332. formatted_utc_time = now_utc.strftime("%Y-%m-%d %H:%M:%S")
  333. self._log(f"Waiting for OTP email sent after {formatted_utc_time}...")
  334. # 3. 轮询查收
  335. for i in range(12):
  336. content_out = VSCloudApi.Instance().fetch_mail_content(
  337. master_email,
  338. sender,
  339. recipient,
  340. subject_keywords,
  341. body_keywords,
  342. formatted_utc_time,
  343. 300
  344. )
  345. if content_out:
  346. match = re.search(r'\b\d{6}\b', content_out)
  347. if match:
  348. otp = match.group(0)
  349. self._log(f"OTP code found: {otp}")
  350. return otp
  351. time.sleep(5)
  352. raise NotFoundError(message="OTP email not found (timeout)")
  353. def _confirm_appointment(self, date, slot_data, user_inputs, otp, alias_email):
  354. url = f"{self.base_url}/en/personal/appointment/create"
  355. # 处理日期格式 YYYY-MM-DD
  356. def _get_dob(d_str):
  357. try: return datetime.strptime(d_str[:10], "%Y-%m-%d")
  358. except: return datetime.now()
  359. dob = _get_dob(user_inputs.get('birthday', ''))
  360. payload = {
  361. "_token": self.csrf_token,
  362. "country": str(self.free_config.get("consularid", "1")),
  363. "visitingcountry": str(self.free_config.get("consularid", "1")),
  364. "city": "6", # Dublin? 需配置
  365. "office": "1",
  366. "officetype": "1",
  367. "totalPerson": "1",
  368. "name1": user_inputs.get('first_name', '').upper(),
  369. "surname1": user_inputs.get('last_name', '').upper(),
  370. "nationality1": "2", # 假设值
  371. "birthday1": str(dob.day),
  372. "birthmonth1": str(dob.month),
  373. "birthyear1": str(dob.year),
  374. "passport1": user_inputs.get('passport_no'),
  375. # 原代码 passport_expried 是 DD-MM-YYYY
  376. "passportExpirationDate1": datetime.strptime(user_inputs.get('passport_expiry_date', '')[:10], "%Y-%m-%d").strftime("%d-%m-%Y"),
  377. "email1": alias_email,
  378. "phone1": user_inputs.get('phone_no'),
  379. "alternativephone1": "",
  380. # 其他 person 留空
  381. "name2": "", "surname2": "", "nationality2": "0", "birthday2": "0", "birthmonth2": "0", "birthyear2": "0", "passport2": "", "passportExpirationDate2": "", "email2": alias_email, "phone2": user_inputs.get('phone_no'), "alternativephone2": "",
  382. "name3": "", "surname3": "", "nationality3": "0", "birthday3": "0", "birthmonth3": "0", "birthyear3": "0", "passport3": "", "passportExpirationDate3": "", "email3": alias_email, "phone3": user_inputs.get('phone_no'), "alternativephone3": "",
  383. "name4": "", "surname4": "", "nationality4": "0", "birthday4": "0", "birthmonth4": "0", "birthyear4": "0", "passport4": "", "passportExpirationDate4": "", "email4": alias_email, "phone4": user_inputs.get('phone_no'), "alternativephone4": "",
  384. "mailConfirmCode": otp,
  385. "ctval": slot_data['data_id'],
  386. "qtallvert": slot_data['data_all'],
  387. "oldofficetype": "1",
  388. "oldtotalperson": "1",
  389. "rePaymentControl": "0",
  390. # 关键:View Set
  391. "view_set_app_country": "Schengen - Tourism/Family&Friend Visit/Transit Visa/Other Purposes", # 需配置
  392. "view_set_app_office": "Dublin",
  393. "view_set_app_service_type": "NORMAL",
  394. "cargoactive": "0",
  395. "setnewcalendarstatus": "2",
  396. "availableDaycontrol": "0",
  397. "travelStartDate": datetime.strptime(user_inputs.get('travel_date', '')[:10], "%Y-%m-%d").strftime("%d-%m-%Y"),
  398. "personalapproveTerms": "1"
  399. }
  400. headers = self._get_headers()
  401. headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
  402. resp = self._perform_request('POST', url, data=payload, headers=headers)
  403. return resp.text
  404. def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
  405. """
  406. 根据用户的期望范围筛选可用日期
  407. :param dates: API 返回的可用日期列表 (YYYY-MM-DD)
  408. :param start_str: 用户期望开始日期 (YYYY-MM-DD)
  409. :param end_str: 用户期望结束日期 (YYYY-MM-DD)
  410. :return: 符合要求的日期列表
  411. """
  412. # 如果没有设置范围,则不过滤,返回所有日期
  413. if not start_str or not end_str:
  414. return dates
  415. valid_dates = []
  416. # 截取前10位以防带有时分秒
  417. s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
  418. e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
  419. for date_str in dates:
  420. curr_date = datetime.strptime(date_str, "%Y-%m-%d")
  421. # 比较范围 (闭区间)
  422. if s_date <= curr_date <= e_date:
  423. valid_dates.append(date_str)
  424. random.shuffle(valid_dates)
  425. return valid_dates
  426. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
  427. """
  428. 统一 HTTP 请求封装,严格复刻 C++ 逻辑:
  429. 1. 发送 OPTIONS 请求
  430. 2. 发送实际请求
  431. """
  432. resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
  433. if self.config.debug:
  434. self._log(f'[perform request] Response={resp.text}\nMethod={method}, Url={url}, Data={data}, JsonData={json_data}, Params={params}')
  435. if resp.status_code == 200:
  436. return resp
  437. elif resp.status_code in [401, 419]:
  438. self.is_healthy = False
  439. raise SessionExpiredOrInvalidError()
  440. elif resp.status_code == 403:
  441. raise PermissionDeniedError()
  442. elif resp.status_code == 429:
  443. self.is_healthy = False
  444. raise RateLimiteddError()
  445. else:
  446. raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
  447. def _solve_cloudflare5S_challenge(self):
  448. """
  449. 解决 Cloudflare 5s 盾
  450. """
  451. self._log(f"Solving Cloudflare 5s...")
  452. website_url = f'{self.base_url}/en'
  453. # 1. 格式化代理字符串, 这里的接口要求格式通常是: host:port:user:pass (根据你的脚本示例)
  454. p = self.config.proxy
  455. if p.username:
  456. proxy_str = f"{p.ip}:{p.port}:{p.username}:{p.password}"
  457. else:
  458. proxy_str = f"{p.ip}:{p.port}"
  459. # 2. 提交任务
  460. task_id = VSCloudApi.Instance().create_task(
  461. command="AntiCloudflareTask",
  462. args={
  463. "proxy": proxy_str,
  464. "websiteUrl": website_url
  465. }
  466. )
  467. result_data = VSCloudApi.Instance().get_task_result(task_id, timeout=60)
  468. task_result = result_data.get("result", {})
  469. cookies_list = task_result.get('cookies', [])
  470. for cookie in cookies_list:
  471. if cookie['name'] in ['__cf_bm', 'cf_clearance']:
  472. self.session.cookies.set(
  473. cookie['name'],
  474. cookie['value'],
  475. domain=cookie['domain'],
  476. path='/'
  477. )
  478. self.session.headers['User-Agent'] = task_result.get('userAgent')
  479. self._log("Cloudflare 5s challenge solved.")