de_plugin.py 22 KB

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