de_plugin.py 21 KB

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