Jelajahi Sumber

feat: update

jerry 3 minggu lalu
induk
melakukan
d16bdfe3c4
7 mengubah file dengan 80 tambahan dan 173 penghapusan
  1. 12 0
      booker_builtin.py
  2. 12 0
      booker_order.py
  3. 24 9
      plugins/grc_plugin.py
  4. 7 1
      plugins/tls_plugin.py
  5. 20 159
      plugins/vfs_plugin.py
  6. 1 0
      tls_registration_bot.py
  7. 4 4
      vs_plg.py

+ 12 - 0
booker_builtin.py

@@ -185,6 +185,18 @@ class BuiltinBookerGCO:
                     "payment_link": book_res.payment_link
                 }
                 VSCloudApi.Instance().update_vas_task(task_id, {"status": "grabbed", "grabbed_history": grab_info})
+                push_content = (
+                    f"🎉 【预定成功通知】\n"
+                    f"━━━━━━━━━━━━━━━\n"
+                    f"订单编号: {order_id}\n"
+                    f"预约账号: {book_res.account}\n"
+                    f"预约日期: {book_res.book_date}\n"
+                    f"预约时间: {book_res.book_time}\n"
+                    f"预约编号: {book_res.urn}\n"
+                    f"支付链接: {book_res.payment_link if book_res.payment_link else '无需支付/暂无'}\n"
+                    f"━━━━━━━━━━━━━━━\n"
+                )
+                VSCloudApi.Instance().push_weixin_text(push_content)
                 self.redis_client.zrem(self.m_tracker_key, task_id)
                 
                 # === 核心:成功次数判断 ===

+ 12 - 0
booker_order.py

@@ -199,6 +199,18 @@ class OrderBookerGCO:
                     "payment_link": book_res.payment_link
                 }
                 VSCloudApi.Instance().update_vas_task(task_id, {"status": "grabbed", "grabbed_history": grab_info})
+                push_content = (
+                    f"🎉 【预定成功通知】\n"
+                    f"━━━━━━━━━━━━━━━\n"
+                    f"订单编号: {order_id}\n"
+                    f"预约账号: {book_res.account}\n"
+                    f"预约日期: {book_res.book_date}\n"
+                    f"预约时间: {book_res.book_time}\n"
+                    f"预约编号: {book_res.urn}\n"
+                    f"支付链接: {book_res.payment_link if book_res.payment_link else '无需支付/暂无'}\n"
+                    f"━━━━━━━━━━━━━━━\n"
+                )
+                VSCloudApi.Instance().push_weixin_text(push_content)
                 self.redis_client.zrem(self.m_tracker_key, task_id)
                 
                 with self.m_lock:

+ 24 - 9
plugins/grc_plugin.py

@@ -11,6 +11,20 @@ from curl_cffi import requests, const
 from vs_plg import IVSPlg
 from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 
+MODERN_BROWSERS: List[str] = [
+    # Chrome (124+)
+    "chrome124", "chrome131", "chrome133a", "chrome136", "chrome142", "chrome145", "chrome146",
+    "chrome131_android",
+    
+    # Safari (18+)
+    "safari180", "safari184", "safari260", "safari2601",
+    "safari180_ios", "safari184_ios", "safari260_ios",
+    
+    # Firefox (133+)
+    "firefox133", "firefox135", "firefox144", "firefox147",
+    
+    "tor145"
+]
 
 class GrcPlugin(IVSPlg):
     """
@@ -42,10 +56,7 @@ class GrcPlugin(IVSPlg):
         self.free_config = config.free_config or {}
         
     def keep_alive(self):
-        home_page = "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas"
-        resp = self._perform_request("GET", home_page)
-        if f'Signed in as {self.config.account.username.lower()}' not in resp.text:
-            self.is_healthy = False
+        pass
 
     def health_check(self) -> bool:
         if not self.is_healthy:
@@ -68,9 +79,12 @@ class GrcPlugin(IVSPlg):
             const.CurlOpt.VERBOSE: self.config.debug,
         }
         
+        chosen_browser = random.choice(MODERN_BROWSERS)
+        self._log(f"Using browser fingerprint: {chosen_browser}")
+
         self.session = requests.Session(
             proxy=self._get_proxy_url(),
-            impersonate="chrome124",
+            impersonate=chosen_browser,
             curl_options=curlopt,
             use_thread_local_curl=False,
             http_version=const.CurlHttpVersion.V2TLS
@@ -321,7 +335,9 @@ class GrcPlugin(IVSPlg):
         res = VSBookResult()
         res.success = False
 
-        # --- 1. 筛选并收集所有可用 Slot ---
+        # 重新登录一次,避免session expired
+        self.create_session()
+        
         exp_start = user_inputs.get('expected_start_date', '')
         exp_end = user_inputs.get('expected_end_date', '')
         
@@ -412,6 +428,7 @@ class GrcPlugin(IVSPlg):
                     self._log(f"Slot {start_time_str} unavailable ({err_reason}). Trying next...")
                     continue
 
+                time.sleep(random.uniform(10, 30))
                 # ==========================
                 # Request step 2: 提交表单
                 # ==========================
@@ -525,6 +542,4 @@ class GrcPlugin(IVSPlg):
             self.is_healthy = False
             raise RateLimiteddError()
         else:
-            raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
-            
-    #[修复点 3]:删除了原有代码最下方重复的 def _filter_dates 方法
+            raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")

+ 7 - 1
plugins/tls_plugin.py

@@ -85,7 +85,13 @@ class TlsPlugin(IVSPlg):
         self.free_config = config.free_config or {}
         
     def keep_alive(self):
-        pass
+        try:
+            resp = self._perform_request("GET", self.page.url, retry_count=1)
+            self._check_page_is_session_expired_or_invalid('Book your appointment', html = resp.text)
+        except SessionExpiredOrInvalidError as e:
+            self.is_healthy = False
+        except Exception as e:
+            pass
 
     def health_check(self) -> bool:
         if not self.is_healthy:

+ 20 - 159
plugins/vfs_plugin.py

@@ -8,15 +8,14 @@ import base64
 import uuid
 import shutil
 import re
+import socket
 import urllib.parse
 from datetime import datetime
 from typing import Dict, Any, Optional, List, Tuple, Callable
 
-# DrissionPage 核心引入
 from DrissionPage import ChromiumPage, ChromiumOptions
 from DrissionPage.common import Settings
 
-# 加密库
 from cryptography.hazmat.primitives import serialization, hashes
 from cryptography.hazmat.primitives.asymmetric import padding
 from cryptography.hazmat.backends import default_backend
@@ -28,8 +27,6 @@ from toolkit.proxy_tunnel import ProxyTunnel
 from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
 
 
-# ----------------- 静态常量与辅助数据 -----------------
-
 VFS_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
 MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuupFgB+lYIOtSxrRoHzc
 LmCZKJ6+oSbgqgOPzFMM0TasOeLw0NXEn1XfIzXdx75+tegNKwyIZumoh0yhubKs
@@ -123,7 +120,6 @@ class VfsPlugin(IVSPlg):
         self.free_config: Dict[str, Any] = {}
         self.logger = None
         
-        # 替换 requests.Session 为 DrissionPage
         self.page: Optional[ChromiumPage] = None
         
         self.jwt_token: str = ""
@@ -208,14 +204,7 @@ class VfsPlugin(IVSPlg):
         4. JS fetch 登录
         """
         self._log(f"Initializing Session (ID: {self.instance_id})...")
-        
-        # 0. 配置浏览器
         co = ChromiumOptions()
-        # -------------------------------------------------------------
-        # [核心修复] 解决 'not enough values to unpack'
-        # -------------------------------------------------------------
-        # 1. 不要用 co.auto_port(),因为它依赖解析 stdout,会被 DBus 报错干扰
-        # 2. 我们手动随机生成一个端口
         import random
         import socket
         
@@ -227,38 +216,24 @@ class VfsPlugin(IVSPlg):
         debug_port = get_free_port()
         self._log(f"Assigned Debug Port: {debug_port}")
         
-        # 3. 强制指定端口,DrissionPage 就会直接连接,不再解析日志
         co.set_local_port(debug_port)
-        
-        # --- [关键配置] 设置独立的用户数据目录 ---
-        # 这样每个实例的 Cache, Cookies, LocalStorage 都是完全隔离的
-        # 同时也防止了多进程争抢同一个 Default 文件夹导致的崩溃
         co.set_user_data_path(self.user_data_path)
         
-        # --- 1. 指定浏览器路径 (适配 Docker) ---
         chrome_path = os.getenv("CHROME_BIN")
         if chrome_path and os.path.exists(chrome_path):
             co.set_paths(browser_path=chrome_path)
         
-        # --- [核心修改] 代理配置 ---
         if self.config.proxy and self.config.proxy.ip:
             p = self.config.proxy
             
             if p.username and p.password:
                 self._log(f"Starting Proxy Tunnel for {p.ip}...")
-                
-                # 1. 启动本地隧道
                 self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
                 local_proxy = self.tunnel.start()
-                
                 self._log(f"Tunnel started at {local_proxy}")
-                
-                # 2. Chrome 连接本地免密端口
-                # 必须使用 --proxy-server 强制指定,绝对稳健
                 co.set_argument(f'--proxy-server={local_proxy}')
                 
             else:
-                # 无密码代理,直接用
                 proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
                 co.set_argument(f'--proxy-server={proxy_str}')
         else:
@@ -267,16 +242,13 @@ class VfsPlugin(IVSPlg):
         co.headless(False) 
         co.set_argument('--no-sandbox')
         co.set_argument('--disable-gpu')
-        # Docker 默认 /dev/shm 只有 64MB,Chromium 很容易爆内存崩溃
         co.set_argument('--disable-dev-shm-usage') 
-        
         co.set_argument('--window-size=1920,1080')
         co.set_argument('--disable-blink-features=AutomationControlled')
 
         try:
             self.page = ChromiumPage(co)
             
-            # 1. 导航到登录页面
             mission = self.free_config.get("mission_code", "")
             country = self.free_config.get("country_code", "")
             lang = self.free_config.get("language", "en")
@@ -289,24 +261,14 @@ class VfsPlugin(IVSPlg):
             
             self.page.get(login_page_url)
             
-            # -------------------------------------------------------------
-            # [核心修改] 2. 智能 Cloudflare 过盾逻辑
-            # -------------------------------------------------------------
             self._log("Handling Cloudflare challenge...")
             
-            # 初始化过盾助手
             cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
             cf_token = ""
             
-            # 循环检测 (40秒超时)
             for i in range(40):
                 time.sleep(1)
-                
-                # A. 优先处理 Cookie 遮挡 (VFS 必须步骤)
-                # 如果不关掉 cookie banner,验证码可能点不到
                 self._handle_cookie_banner()
-                
-                # B. 尝试从 DOM 获取 Token (无感验证可能自动通过)
                 try:
                     ele = self.page.ele('@name=cf-turnstile-response')
                     if ele and ele.value:
@@ -316,28 +278,21 @@ class VfsPlugin(IVSPlg):
                 except:
                     pass
                 
-                # C. 如果前 3 秒没自动出 Token,开始尝试点击
                 if i > 2:
                     try:
-                        # 开启 DFS 深度搜索模式 (防止 Shadow DOM 嵌套太深找不到)
-                        # 在第 10 秒后开启深度搜索,前期用快速搜索
                         use_dfs = False
                         cf_bypasser.click_verification_button(is_dfs=use_dfs)
                     except Exception as e:
-                        # 点击错误忽略,继续下一轮
                         pass
                 
-                # D. 检查是否已经看到了登录框 (有时候 Token 提取慢了,但页面已经变了)
                 if self.page.ele('tag:form') or self.page.ele('#mat-input-0'):
                     self._log("Login form detected.")
-                    # 继续尝试提取一次 Token,如果实在没有也不要死循环
                     if i > 5 and not cf_token:
                         self._log("Form visible but token not found yet...")
             
             # -------------------------------------------------------------
             
             if not cf_token:
-                # 最后尝试一次强取
                 try:
                     cf_token = self.page.ele('@name=cf-turnstile-response').value
                 except:
@@ -347,7 +302,6 @@ class VfsPlugin(IVSPlg):
                 self._log("[WARN] Could not extract Turnstile token.")
                 raise BizLogicError(f"Could not extract Turnstile token.")
 
-            # 3. 准备登录 API 参数
             email = self.config.account.username
             password = self.config.account.password
             enc_password = self._encrypt_password(password)
@@ -378,16 +332,12 @@ class VfsPlugin(IVSPlg):
             resp = self._perform_request("POST", url, headers=headers, json_data=data)
             resp_json = resp.json()
 
-            # 分支 1: 登录成功
             if resp_json.get('accessToken'):
                 self.jwt_token = resp_json["accessToken"]
                 self._log("Login successful, JWT obtained.")
             
-            # 分支 2: OTP
             elif resp_json.get("enableOTPAuthentication"):
                 self._log("Login requires OTP.")
-                # 注意:_submit_login_otp 内部也会调用 _refresh_turnstile_token
-                # 所以这里旧的 cf_token 其实用处不大,传过去也没事
                 otp = self._read_otp_email(sent_at=sent_at)
                 self._submit_login_otp(cf_token, otp)
             
@@ -442,19 +392,11 @@ class VfsPlugin(IVSPlg):
         if not self.page:
             raise BizLogicError("Browser session not initialized")
 
-        # ---------------------------------------------------------
-        # 1. 预处理 URL (构造最终请求地址)
-        # ---------------------------------------------------------
         req_url = url
         if params:
-            # 确保引用了 urllib
-            import urllib.parse
             sep = '&' if '?' in req_url else '?'
             req_url += sep + urllib.parse.urlencode(params)
 
-        # ---------------------------------------------------------
-        # 2. 构造 Body 和 Fetch 选项
-        # ---------------------------------------------------------
         final_headers = headers or {}
         
         fetch_options = {
@@ -463,7 +405,6 @@ class VfsPlugin(IVSPlg):
             "credentials": "include" # 关键:带上浏览器 Cookie
         }
         
-        # 用于日志记录的 Body 内容(字符串形式)
         log_body = "None"
 
         if json_data:
@@ -473,7 +414,6 @@ class VfsPlugin(IVSPlg):
             log_body = json_str
         elif data:
             if isinstance(data, dict):
-                import urllib.parse
                 encoded_data = urllib.parse.urlencode(data)
                 fetch_options['body'] = encoded_data
                 fetch_options['headers']['Content-Type'] = 'application/x-www-form-urlencoded'
@@ -482,16 +422,10 @@ class VfsPlugin(IVSPlg):
                 fetch_options['body'] = data
                 log_body = str(data)
 
-        # ---------------------------------------------------------
-        # [日志] 记录请求数据
-        # ---------------------------------------------------------
         self._log(f"┌── [TRAFFIC REQUEST] {method} {req_url}")
         self._log(f"├── Headers: {json.dumps(final_headers)}")
         self._log(f"└── Body: {log_body}")
 
-        # ---------------------------------------------------------
-        # 3. 注入 JS 执行 Fetch
-        # ---------------------------------------------------------
         js_script = f"""
         const url = "{req_url}";
         const options = {json.dumps(fetch_options)};
@@ -525,8 +459,6 @@ class VfsPlugin(IVSPlg):
         """
         
         try:
-            # run_js 直接返回 return 的对象
-            # 适当增加超时时间,防止网络慢导致 Python 侧报错
             res_dict = self.page.run_js(js_script, timeout=60)
         except Exception as e:
             self._log(f"[TRAFFIC ERROR] JS Execution failed: {e}")
@@ -534,20 +466,12 @@ class VfsPlugin(IVSPlg):
 
         resp = BrowserResponse(res_dict)
         
-        # ---------------------------------------------------------
-        # [日志] 记录响应数据
-        # ---------------------------------------------------------
         duration = res_dict.get('duration', 0)
-        # 截取过长的响应体,避免日志文件爆炸 (保留前 1000 字符)
-        # 如果需要完整分析,可以去掉 [:1000]
         resp_preview = resp.text[:1000] + "..." if len(resp.text) > 1000 else resp.text
         
         self._log(f"┌── [TRAFFIC RESPONSE] Status: {resp.status_code} | Time: {duration}ms")
         self._log(f"└── Body: {resp_preview}")
 
-        # ---------------------------------------------------------
-        # 4. 统一处理状态码
-        # ---------------------------------------------------------
         if resp.status_code == 200:
             return resp
             
@@ -556,25 +480,20 @@ class VfsPlugin(IVSPlg):
             raise SessionExpiredOrInvalidError(f"401 Unauthorized: {resp.text[:100]}")
             
         elif resp.status_code == 403:
-            # 检查是否是 Cloudflare 拦截
             if "Just a moment" in resp.text or "cloudflare" in resp.text.lower():
                 self._log(f"[TRAFFIC] HTTP 403 (Cloudflare) detected. Re-verifying (Try {retry_count+1}/3)...")
                 
                 if retry_count < 3:
-                    # 调用过盾逻辑
                     new_token = self._refresh_turnstile_token()
                     
                     if new_token:
                         self._log("[TRAFFIC] In-page verification success. Retrying...")
                         
-                        # 如果原请求包含验证码字段,更新它
                         if json_data and "captcha_api_key" in json_data:
                             json_data["captcha_api_key"] = new_token
                             
-                        # 递归重试
                         return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
             
-            # 如果不是 CF 或者重试耗尽
             raise PermissionDeniedError(f"HTTP 403 Forbidden: {resp.text[:100]}")
             
         elif resp.status_code == 429:
@@ -585,11 +504,9 @@ class VfsPlugin(IVSPlg):
             raise BizLogicError(f"Network Error (Fetch Failed): {resp.text}")
             
         else:
-            # 允许 400 业务错误通过,交给上层解析 (例如登录失败)
             if url.endswith("/login") and resp.status_code == 400:
                 return resp
             
-            # 其他错误视为业务逻辑异常
             raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
 
     def _handle_cookie_banner(self):
@@ -638,20 +555,15 @@ class VfsPlugin(IVSPlg):
         不带 Origin: visa.vfsglobal.com,也不带 credentials,符合 ipify 规则。
         """
         try:
-            # 1. 新建一个标签页 (后台静默打开)
             tab = self.page.new_tab("https://api.ipify.org/?format=json")
             
-            # 2. 获取页面内容 (DrissionPage 会自动等待页面加载)
-            # ipify 返回的是纯 JSON 文本,通常在 body 或 pre 标签里
             if tab.ele('tag:pre'):
                 json_text = tab.ele('tag:pre').text
             else:
                 json_text = tab.ele('tag:body').text
             
-            # 3. 提取 IP
             ip = json.loads(json_text)['ip']
             
-            # 4. 务必关闭标签页,释放资源
             tab.close()
             
             self._log(f"Real Network IP: {ip}")
@@ -659,7 +571,6 @@ class VfsPlugin(IVSPlg):
             
         except Exception as e:
             self._log(f"[WARN] Failed to check IP via new tab: {e}")
-            # 尝试清理可能没关掉的标签页
             try:
                 if self.page.tabs_count > 1:
                     tab.close()
@@ -677,14 +588,9 @@ class VfsPlugin(IVSPlg):
         
         h = {
             "accept": "application/json, text/plain, */*",
-            # "origin": ... 浏览器自动处理
-            # "referer": ... 浏览器自动处理
             "route": route
         }
         
-        # 即使是浏览器环境,VFS 也需要这两个加密参数
-        # 注意:这里可能需要从 JS 获取,或者保持 Python 生成
-        # 如果 Python 生成的总是报错,可以考虑把加密逻辑移到 JS 里跑
         h["clientsource"] = self._get_client_source()
         
         if with_auth and self.jwt_token:
@@ -725,9 +631,6 @@ class VfsPlugin(IVSPlg):
             "payCode": ""
         }
         headers = self._get_common_headers(with_auth=True)
-        # fetch 不需要显式 content-type application/json,json_data会自动处理
-        
-        # DrissionPage 不需要手动处理 403 绕盾,因为浏览器本身就在盾后面
         resp = self._perform_request("POST", url, headers=headers, json_data=data, retry_count=2)
         
         if "WaitList" in resp.text:
@@ -819,8 +722,6 @@ class VfsPlugin(IVSPlg):
     def _submit_login_otp(self, old_cf_token: str, otp: str):
         self._log("Submitting Login OTP...")
         
-        # --- [新增] 必须刷新 Token ---
-        # 旧的 old_cf_token 已经在第一步登录时失效了
         new_cf_token = self._refresh_turnstile_token()
         # ---------------------------
 
@@ -859,7 +760,6 @@ class VfsPlugin(IVSPlg):
             self._log("OTP Login successful.")
             return
 
-        # 增加错误详情日志
         error_desc = resp_json.get("description", resp.text)
         raise PermissionDeniedError(message=f"OTP Login Failed: {error_desc}")
     
@@ -869,7 +769,6 @@ class VfsPlugin(IVSPlg):
         """
         self._log("Refreshing Cloudflare Turnstile token...")
         
-        # 1. JS 强制重置 (保持不变)
         js_reset = """
         try {
             var input = document.querySelector('input[name="cf-turnstile-response"]');
@@ -881,16 +780,11 @@ class VfsPlugin(IVSPlg):
         """
         self.page.run_js(js_reset)
         
-        # 2. 初始化过盾助手
-        # 假设 CloudflareBypasser 类已在当前文件中定义
         cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
-        
-        # 3. 轮询等待 (30秒)
+
         for i in range(60): 
             time.sleep(0.5)
             
-            # A. 检查 Token 是否已生成
-            # 使用 DrissionPage 的方式获取 value 比较稳定
             try:
                 ele = self.page.ele('@name=cf-turnstile-response')
                 if ele and ele.value:
@@ -899,18 +793,11 @@ class VfsPlugin(IVSPlg):
             except:
                 pass
             
-            # B. 尝试点击验证框
-            # 策略:前2秒等待,之后开始尝试点击
             if i > 4: 
-                # [重要] VFS 经常有 Cookie 弹窗遮挡,先尝试清理一下
                 self._handle_cookie_banner()
                 
                 try:
-                    # 使用 CloudflareBypasser 的高级点击逻辑
-                    # is_dfs=True 表示如果普通搜索找不到,就递归搜索 iframe (更耗时但更强)
-                    # 我们在尝试 10 次 (5秒) 后开启 DFS 模式
-                    use_dfs = (i > 14)
-                    
+                    use_dfs = (i > 14) 
                     cf_bypasser.click_verification_button(is_dfs=use_dfs)
                 except Exception as e:
                     # 点击过程报错不要中断主循环
@@ -924,14 +811,11 @@ class VfsPlugin(IVSPlg):
         """
         self._log("Starting booking process...")
         
-        # 1. 准备数据
         user_email = user_inputs.get('email')
-        # 生成别名邮箱 (防止邮箱被 VFS 黑名单)
         user_inputs['alias_email'] = get_alias_email(user_email, new_domain="gmail-app.com")
         
         res = VSBookResult()
         app_type = slot_info.apt_type
-        # 如果没有 earliest_date,默认从今天开始
         from_date = slot_info.earliest_date.strftime("%Y-%m-%d") if slot_info.earliest_date else datetime.now().strftime("%Y-%m-%d")
         
         apt_config = self.free_config.get("apt_configs", {}).get(app_type.routing_key)
@@ -939,14 +823,11 @@ class VfsPlugin(IVSPlg):
         if not apt_config:
             raise NotFoundError(message="Book: Config missing for this routing key.")
 
-        # 确保配置已加载 (SubCategory 等)
         self._fetch_configurations(apt_config)
 
         sub_cc = apt_config.get("subcategory_code")
         sub_conf = self.subcategory_conf.get(sub_cc, {})
 
-        # 3. OCR 识别 / 文档上传 (如果需要)
-        # 上传结果存入 user_inputs 供后续使用
         ocr_enabled = sub_conf.get("isOCREnable", False)
         if ocr_enabled:
             self._log("OCR Enabled, uploading documents...")
@@ -957,7 +838,6 @@ class VfsPlugin(IVSPlg):
 
         enable_reference_number = sub_conf.get("enableReferenceNumber", False)
 
-        # 4. 添加申请人 (核心步骤 1)
         final_urn = None
         is_waitlist = (slot_info.availability_status == AvailabilityStatus.Waitlist)
         
@@ -966,23 +846,23 @@ class VfsPlugin(IVSPlg):
             time.sleep(20)
             self.booking_wait_applied = True
         
-        # 重试机制:添加申请人有时候会因为并发冲突失败
-        MAX_RETRY = 4
+        MAX_RETRY = 2
         for i in range(MAX_RETRY):
             try:
                 final_urn = self._add_primary_applicant(apt_config, user_inputs, is_waitlist, ocr_enabled, enable_reference_number)
-                if final_urn:
-                    break
-            except Exception as e:
-                self._log(f"Add Applicant retry {i+1}/{MAX_RETRY}: {e}")
-            time.sleep(5.0)
+                break
+            except BizLogicError as e:
+                err_msg = str(e)
+                self._log(f"Add Applicant retry {i+1}/{MAX_RETRY}: {err_msg}")
+                if 'Capping has exceeded' in err_msg:
+                    raise e
+            time.sleep(10.0)
         
         if not final_urn:
             raise BizLogicError(message="Failed to add primary applicant (Slot likely taken or API error)")
 
         self._log(f"Applicant Added. URN: {final_urn}")
 
-        # 5. 申请人 OTP 验证 (核心步骤 2 - 视配置而定)
         otp_enabled = sub_conf.get("isApplicantOTPEnabled", False)
         if otp_enabled:
             self._log("Applicant OTP Required.")
@@ -991,12 +871,10 @@ class VfsPlugin(IVSPlg):
             if not self._applicant_otp_send(apt_config, final_urn):
                 raise BizLogicError(message='Applicant OTP send failed')
             
-            # 复用之前的读邮件逻辑
             otp_code = self._read_otp_email(sent_at=sent_at)
             if not self._applicant_otp_verify(apt_config, final_urn, otp_code):
                 raise BizLogicError(message='Applicant OTP verify failed')
 
-        # 6. Waitlist 模式直接返回
         if is_waitlist:
             if self._confirm_waitlist(apt_config, final_urn):
                 res.success = True
@@ -1006,11 +884,9 @@ class VfsPlugin(IVSPlg):
                 return res
             raise BizLogicError(message='Confirm waitlist failed')
 
-        # 7. 寻找具体的时间槽 (核心步骤 3)
         expected_start = user_inputs.get("expected_start_date", "")
         expected_end = user_inputs.get("expected_end_date", "")
         
-        # 计算需要扫描的月份
         months = self._get_filtered_covered_months(expected_start, expected_end, from_date)
         self._log(f"Scanning months: {months} (Start looking from: {from_date})")
         
@@ -1024,40 +900,31 @@ class VfsPlugin(IVSPlg):
         
         for m_str in months:
             self._log(f"Checking calendar for {m_str}...")
-            # 查询日历
             ads = self._query_slot_calendar(apt_config, final_urn, m_str)
             
-            # 去重
             new_ads = [d for d in ads if d not in all_ads]
             all_ads.update(new_ads)
             
-            # 尝试选中一个日期
-            # 这里做一个简单循环,如果选中日期没时间了,就换一个日期
             for _ in range(3):
                 avail_candidates = [d for d in list(all_ads) if d not in forbidden_dates]
-                # 根据用户期望过滤
                 sel_dates = self._filter_dates(avail_candidates, expected_start, expected_end)
                 
                 if not sel_dates:
-                    break # 当前月没有符合要求的日期,去下一个月
-                
-                tmp_date = sel_dates[0] # 取第一个(通常 _filter_dates 里已经 shuffle 过了)
-                forbidden_dates.add(tmp_date) # 标记为已尝试
+                    break
                 
-                # 关键:Audit Log (锁定日期)
-                # VFS 要求在查 timeslot 之前必须先发这个请求
+                tmp_date = sel_dates[0]
+                forbidden_dates.add(tmp_date)
+
                 if not self._saveuseractionaudit(apt_config, final_urn, tmp_date):
                     self._log(f"Audit failed for {tmp_date}, skipping...")
                     time.sleep(1)
                     continue
                 
-                # 查询具体时间
                 ats = self._query_slot_time(apt_config, final_urn, tmp_date)
                 if not ats:
                     self._log(f"No timeslots for {tmp_date}")
                     continue
                 
-                # 随机选一个时间
                 sel_tm = random.choice(ats)
                 
                 selected_slot_id = sel_tm.get("allocationId")
@@ -1077,11 +944,9 @@ class VfsPlugin(IVSPlg):
 
         self._log(f"Slot Selected: {selected_slot_date} {selected_slot_time_range} (ID: {selected_slot_id})")
 
-        # 8. 服务与费用 (核心步骤 4)
         self._submit_no_addition_service(final_urn)
         amount, currency = self._query_fee(apt_config, final_urn)
         
-        # 9. 最终提交
         self._log("Submitting schedule...")
         schedule_res = self._schedule(apt_config, final_urn, amount, currency, selected_slot_id)
         
@@ -1090,7 +955,6 @@ class VfsPlugin(IVSPlg):
             res.success = False
             return res
              
-        # 10. 构造成功结果
         res.success = True
         res.account = self.config.account.username
         res.book_date = selected_slot_date
@@ -1099,7 +963,6 @@ class VfsPlugin(IVSPlg):
         res.fee_amount = int(amount * 100)
         res.fee_currency = currency
         
-        # 11. 处理支付链接
         if schedule_res.get("IsPaymentRequired", False):
             payload = schedule_res.get("payLoad", "")
             if payload:
@@ -1131,7 +994,6 @@ class VfsPlugin(IVSPlg):
         if not passport_url:
             raise NotFoundError(message="Missing passport_image_url")
 
-        # 下载图片 (不走代理或走系统代理,不使用 DrissionPage,因为是外部链接)
         try:
             img_resp = standard_requests.get(passport_url, timeout=30)
             if img_resp.status_code != 200:
@@ -1141,7 +1003,6 @@ class VfsPlugin(IVSPlg):
             raise BizLogicError(message=f"Image download error: {e}")
   
         headers = self._get_common_headers(with_auth=True)
-        # DrissionPage fetch 不需要显式 content-type application/json,json_data会自动处理
         
         data = {
             "missioncode": self.free_config.get("mission_code"),
@@ -1157,7 +1018,6 @@ class VfsPlugin(IVSPlg):
         resp = self._perform_request("POST", url, headers=headers, json_data=data)
         result = resp.json()
         
-        # 补充返回数据供后续使用
         result["passportImageFilename"] = "passport_img.jpg"
         result["passportImageFileBytes"] = b64_str
         return result
@@ -1277,7 +1137,11 @@ class VfsPlugin(IVSPlg):
         }
 
         resp = self._perform_request("POST", url, headers=headers, json_data=payload)
-        return resp.json().get("urn")
+        urn = resp.json().get("urn")
+        if not urn:
+            err_msg = resp.json().get('error')
+            raise BizLogicError(message=str(err_msg))
+        return urn
     
     def _applicant_otp_send(self, apt_config, urn) -> bool:
         url = "https://lift-api.vfsglobal.com/appointment/applicantotp"
@@ -1392,7 +1256,6 @@ class VfsPlugin(IVSPlg):
             "urn": urn,
             "applicants": []
         }
-        # 只要不报错即可
         self._perform_request("POST", url, headers=headers, json_data=data)
 
     def _query_fee(self, apt_config, urn) -> Tuple[float, str]:
@@ -1442,7 +1305,6 @@ class VfsPlugin(IVSPlg):
         """
         解析支付重定向 URL (DrissionPage 新标签页版)
         """
-        # 初始 URL,通常是一个 Redirect 接口
         start_url = f"https://online.vfsglobal.com/PG-Component/Payment/PayRequest?payLoad={payload}"
         final_url = ""
         
@@ -1458,7 +1320,6 @@ class VfsPlugin(IVSPlg):
             final_url = pay_tab.url
             self._log(f"Payment URL resolved: {final_url}")
             
-            # 关闭标签页
             pay_tab.close()
             
         except Exception as e:

+ 1 - 0
tls_registration_bot.py

@@ -32,6 +32,7 @@ def generate_random_account_detail() -> Dict:
         "location": "London",
         "visa_type": "Short stay (<90 days) - Tourism",
         "travel_purpose": "Tourism / Private visit",
+        # FRA1LO20260411910
         "application_form_id": "FRA" + "".join(str(random.randint(0, 9)) for _ in range(14)),
         "last_name": "Smith",
         "first_name": "James",

+ 4 - 4
vs_plg.py

@@ -55,21 +55,21 @@ class IVSPlg(ABC):
     @abstractmethod
     def keep_alive(self):
         """
-        @brief 会话保活
+        @brief 会话保活, 该函数不允许抛异常
         """
         pass
 
     @abstractmethod
     def health_check(self) -> bool:
         """
-        @brief 健康检查,用于检测 API 服务是否正常
-        @return true 表示健康状态良好false 表示存在问题
+        @brief 健康检查,用于检测 API 服务是否正常, 该函数不允许抛异常
+        @return true 表示健康状态良好,false 表示存在问题
         """
         pass
     
     @abstractmethod
     def set_log(self, logger: Callable[[str], None]) -> None:
         """
-        @brief 设置日志输出工具
+        @brief 设置日志输出工具, 该函数不允许抛异常
         """
         pass