Jelajahi Sumber

feat: update

jerry 4 minggu lalu
induk
melakukan
beaca00742
4 mengubah file dengan 705 tambahan dan 616 penghapusan
  1. 229 244
      plugins/tls_plugin.py
  2. 45 29
      test/test_capsolver.py
  3. 430 343
      test/tls_standalone.py
  4. 1 0
      vs_types.py

+ 229 - 244
plugins/tls_plugin.py

@@ -5,6 +5,7 @@ import re
 import os
 import os
 import uuid
 import uuid
 import shutil
 import shutil
+import socket
 from datetime import datetime
 from datetime import datetime
 from typing import List, Dict, Optional, Any, Callable
 from typing import List, Dict, Optional, Any, Callable
 from urllib.parse import urljoin, urlparse, urlencode
 from urllib.parse import urljoin, urlparse, urlencode
@@ -50,25 +51,17 @@ class TlsPlugin(IVSPlg):
         self.is_healthy = True
         self.is_healthy = True
         self.logger = None
         self.logger = None
         
         
-        # 浏览器实例
-        self.page: Optional[ChromiumPage] = None
-        
+        self.page: Optional[ChromiumPage] = None    
         self.travel_group: Optional[Dict] = None
         self.travel_group: Optional[Dict] = None
         
         
-        # --- [核心修改] 并发隔离与资源管理 ---
-        # 生成唯一实例 ID
         self.instance_id = uuid.uuid4().hex[:8]
         self.instance_id = uuid.uuid4().hex[:8]
         self.root_workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"{self.group_id}.{self.instance_id}"))
         self.root_workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"{self.group_id}.{self.instance_id}"))
-        # 定义子目录:代理插件目录 & 浏览器用户数据目录
         self.user_data_path = os.path.join(self.root_workspace, "user_data")
         self.user_data_path = os.path.join(self.root_workspace, "user_data")
     
     
-        # 确保根目录存在 (子目录由具体逻辑创建)
         if not os.path.exists(self.root_workspace):
         if not os.path.exists(self.root_workspace):
             os.makedirs(self.root_workspace)
             os.makedirs(self.root_workspace)
             
             
-        # 持有隧道实例
         self.tunnel = None
         self.tunnel = None
-        
         self.session_create_time: float = 0
         self.session_create_time: float = 0
 
 
     def get_group_id(self) -> str:
     def get_group_id(self) -> str:
@@ -115,11 +108,7 @@ class TlsPlugin(IVSPlg):
             filename = f"{self.instance_id}_{name_prefix}_{timestamp}.jpg"
             filename = f"{self.instance_id}_{name_prefix}_{timestamp}.jpg"
             save_path = os.path.join("data", filename)
             save_path = os.path.join("data", filename)
             os.makedirs("data", exist_ok=True)
             os.makedirs("data", exist_ok=True)
-            
-            # [修改] 改为 full_page=False,防止页面结构异常导致截图失败
-            # 这样能截取到浏览器当前可视区域,最适合调试“卡住”的情况
             self.page.get_screenshot(path=save_path, full_page=False)
             self.page.get_screenshot(path=save_path, full_page=False)
-            
             self._log(f"Screenshot saved to {save_path}")
             self._log(f"Screenshot saved to {save_path}")
         except Exception as e:
         except Exception as e:
             self._log(f"Failed to save screenshot: {e}")
             self._log(f"Failed to save screenshot: {e}")
@@ -130,13 +119,6 @@ class TlsPlugin(IVSPlg):
         """
         """
         self._log(f"Initializing Session (ID: {self.instance_id})...")
         self._log(f"Initializing Session (ID: {self.instance_id})...")
         co = ChromiumOptions()
         co = ChromiumOptions()
-        # -------------------------------------------------------------
-        # [核心修复] 解决 'not enough values to unpack'
-        # -------------------------------------------------------------
-        # 1. 不要用 co.auto_port(),因为它依赖解析 stdout,会被 DBus 报错干扰
-        # 2. 我们手动随机生成一个端口
-        import random
-        import socket
         
         
         def get_free_port():
         def get_free_port():
             with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
             with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -146,38 +128,26 @@ class TlsPlugin(IVSPlg):
         debug_port = get_free_port()
         debug_port = get_free_port()
         self._log(f"Assigned Debug Port: {debug_port}")
         self._log(f"Assigned Debug Port: {debug_port}")
         
         
-        # 3. 强制指定端口,DrissionPage 就会直接连接,不再解析日志
-        co.set_local_port(debug_port)
-        
-        # --- [关键配置] 设置独立的用户数据目录 ---
-        # 这样每个实例的 Cache, Cookies, LocalStorage 都是完全隔离的
-        # 同时也防止了多进程争抢同一个 Default 文件夹导致的崩溃
+        co.set_local_port(debug_port)       
         co.set_user_data_path(self.user_data_path)
         co.set_user_data_path(self.user_data_path)
         
         
-        # --- 1. 指定浏览器路径 (适配 Docker) ---
         chrome_path = os.getenv("CHROME_BIN")
         chrome_path = os.getenv("CHROME_BIN")
         if chrome_path and os.path.exists(chrome_path):
         if chrome_path and os.path.exists(chrome_path):
             co.set_paths(browser_path=chrome_path)
             co.set_paths(browser_path=chrome_path)
         
         
-        # --- [核心修改] 代理配置 ---
         if self.config.proxy and self.config.proxy.ip:
         if self.config.proxy and self.config.proxy.ip:
             p = self.config.proxy
             p = self.config.proxy
             
             
             if p.username and p.password:
             if p.username and p.password:
                 self._log(f"Starting Proxy Tunnel for {p.ip}...")
                 self._log(f"Starting Proxy Tunnel for {p.ip}...")
                 
                 
-                # 1. 启动本地隧道
                 self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
                 self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
                 local_proxy = self.tunnel.start()
                 local_proxy = self.tunnel.start()
                 
                 
                 self._log(f"Tunnel started at {local_proxy}")
                 self._log(f"Tunnel started at {local_proxy}")
-                
-                # 2. Chrome 连接本地免密端口
-                # 必须使用 --proxy-server 强制指定,绝对稳健
                 co.set_argument(f'--proxy-server={local_proxy}')
                 co.set_argument(f'--proxy-server={local_proxy}')
                 
                 
             else:
             else:
-                # 无密码代理,直接用
                 proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
                 proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
                 co.set_argument(f'--proxy-server={proxy_str}')
                 co.set_argument(f'--proxy-server={proxy_str}')
         else:
         else:
@@ -186,7 +156,6 @@ class TlsPlugin(IVSPlg):
         co.headless(False)
         co.headless(False)
         co.set_argument('--no-sandbox')
         co.set_argument('--no-sandbox')
         co.set_argument('--disable-gpu')
         co.set_argument('--disable-gpu')
-        # Docker 默认 /dev/shm 只有 64MB,Chromium 很容易爆内存崩溃
         co.set_argument('--disable-dev-shm-usage')
         co.set_argument('--disable-dev-shm-usage')
         co.set_argument('--window-size=1920,1080')
         co.set_argument('--window-size=1920,1080')
         co.set_argument('--disable-blink-features=AutomationControlled')
         co.set_argument('--disable-blink-features=AutomationControlled')
@@ -210,29 +179,24 @@ class TlsPlugin(IVSPlg):
             self._log(f"Navigating: {full_login_url}")
             self._log(f"Navigating: {full_login_url}")
             self.page.get(full_login_url)
             self.page.get(full_login_url)
             
             
-            # --- Cloudflare 过盾 ---
             cf = CloudflareBypasser(self.page, log=self.config.debug)
             cf = CloudflareBypasser(self.page, log=self.config.debug)
             if not cf.bypass(max_retry=15):
             if not cf.bypass(max_retry=15):
                 raise BizLogicError("Cloudflare bypass timeout")
                 raise BizLogicError("Cloudflare bypass timeout")
             
             
             wait_start = time.time()
             wait_start = time.time()
             while True:
             while True:
-                # 获取页面 HTML,转小写
-                # 注意:如果此处报错 "页面被刷新",是 DrissionPage 的机制问题,
-                # 但你要求先不处理复杂错误,所以这里保持最简单的写法。
                 html = self.page.html.lower()
                 html = self.page.html.lower()
-                
-                # 检查是否在排队室 (法语或英语)
-                if "file d'attente" in html or "waiting room" in html:
-                    # 如果等太久(比如1小时),就强制停止
-                    if time.time() - wait_start > 6 * 60:
-                        self._log("Waiting room timeout (1h).")
-                        break
+                cloudflare_waitingroom_indicators = [
+                    "file d'attente" in html,
+                    'waiting room' in html
+                ]
+                if any(cloudflare_waitingroom_indicators):
+                    if time.time() - wait_start > 60 * 60:
+                        raise BizLogicError(message="Cloudflare waiting room timeout (1h).")
                         
                         
                     self._log("In Waiting Room... Waiting for auto-refresh.")
                     self._log("In Waiting Room... Waiting for auto-refresh.")
-                    time.sleep(30) # 截图说页面会自动刷新,所以这里只sleep,不动浏览器
+                    time.sleep(30)
                 else:
                 else:
-                    # 页面里没有“等候室”的字了,说明出来了
                     break
                     break
 
 
             # --- 登录页面检查 ---
             # --- 登录页面检查 ---
@@ -248,7 +212,8 @@ class TlsPlugin(IVSPlg):
             if self.page.ele('.g-recaptcha') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]'):
             if self.page.ele('.g-recaptcha') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]'):
                 self._log("Solving ReCaptcha...")
                 self._log("Solving ReCaptcha...")
                 rc_params = {
                 rc_params = {
-                    "type": "ReCaptchaV2TaskProxyLess", "page": self.page.url,
+                    "type": "ReCaptchaV2TaskProxyLess",
+                    "page": self.page.url,
                     "siteKey": "6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0", 
                     "siteKey": "6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0", 
                     "apiToken": self.free_config.get("capsolver_key", "")
                     "apiToken": self.free_config.get("capsolver_key", "")
                 }
                 }
@@ -257,7 +222,6 @@ class TlsPlugin(IVSPlg):
             username = self.config.account.username
             username = self.config.account.username
             password = self.config.account.password
             password = self.config.account.password
             
             
-            # 使用 JS 直接操作 DOM 并 click,让浏览器处理 302
             js_login = f"""
             js_login = f"""
             var u = document.getElementById('email-input-field');
             var u = document.getElementById('email-input-field');
             if(u) {{ u.value = "{username}"; u.dispatchEvent(new Event('input', {{bubbles:true}})); }}
             if(u) {{ u.value = "{username}"; u.dispatchEvent(new Event('input', {{bubbles:true}})); }}
@@ -273,29 +237,26 @@ class TlsPlugin(IVSPlg):
             """
             """
             
             
             self._log("Submitting Login via JS...")
             self._log("Submitting Login via JS...")
-            if not self.page.run_js(js_login): raise BizLogicError("Login button missing")
+            if not self.page.run_js(js_login):
+                raise BizLogicError("Login button missing")
 
 
-            # --- 等待跳转 ---
             self._log("Waiting for redirect...")
             self._log("Waiting for redirect...")
             self.page.wait.url_change('login-actions', exclude=True, timeout=45)
             self.page.wait.url_change('login-actions', exclude=True, timeout=45)
             
             
             time.sleep(3)
             time.sleep(3)
-            # 检查是否失败
             if "login-actions" in self.page.url or "auth" in self.page.url:
             if "login-actions" in self.page.url or "auth" in self.page.url:
                 err = "Unknown Login Error"
                 err = "Unknown Login Error"
-                if "Invalid username" in self.page.html: err = "Invalid Credentials"
+                if "Invalid username" in self.page.html:
+                    err = "Invalid Credentials"
                 self._save_screenshot("login_submit_fail")
                 self._save_screenshot("login_submit_fail")
                 raise BizLogicError(f"Login Failed: {err}")
                 raise BizLogicError(f"Login Failed: {err}")
-
-            # --- 提取 Dashboard 信息 ---
-            self._log("Waiting for dashboard...")
-            self.page.wait.load_start()
-            time.sleep(5)
-            
-            html = self.page.html
-            self._check_page_is_session_expired_or_invalid("My travel group", html)
-            groups = self._parse_travel_groups(html)
             
             
+            self._log("Waiting for dashboard...")
+            btn_selector = 'xpath://button[.//span[@data-testid="btn-create-new-travel-group"]]'
+            if not self.page.wait.ele_displayed(btn_selector, timeout=10):
+                raise BizLogicError(message=f"Waiting for selector={btn_selector} timeout")
+            html_content = self.page.html
+            groups = self._parse_travel_groups(html_content) 
             target_city = apt_config['city'].lower()
             target_city = apt_config['city'].lower()
             for g in groups:
             for g in groups:
                 if g['location'].lower() == target_city:
                 if g['location'].lower() == target_city:
@@ -305,9 +266,46 @@ class TlsPlugin(IVSPlg):
             if not self.travel_group:
             if not self.travel_group:
                 self._save_screenshot("group_not_found")
                 self._save_screenshot("group_not_found")
                 raise NotFoundError(f"Group not found for {target_city}")
                 raise NotFoundError(f"Group not found for {target_city}")
+        
+            formgroup_id = self.travel_group.get('group_number')   
+            btn_selector = f'tag:button@@name=formGroupId@@value={formgroup_id}'            
+            self._log(f"Select group_id={formgroup_id} via JS...")
+            self.page.ele(btn_selector).click(by_js=True)
+            
+            self._log("Waiting for url redirect...")
+            self.page.wait.url_change('travel-groups', exclude=True, timeout=45)
+            time.sleep(2)
+
+            if "travel-groups" in self.page.url or "auth" in self.page.url:
+                raise BizLogicError(message="Redirect to service-level Failed!")
             
             
+            no_applicant_indicators = [
+                "Add a new applicant" in self.page.html,
+                "You have not yet added an applicant. Please click the button below to add one." in self.page.html,
+                "applicants-information" in self.page.url
+            ]
+            if any(no_applicant_indicators):
+                raise BizLogicError(message=f"No applicant added")
+            
+            btn_selector = '#book-appointment-btn'
+            self._log(f"Waiting for selector={btn_selector} to render...")
+            if not self.page.wait.ele_displayed(btn_selector, timeout=15):
+                raise BizLogicError(message=f"Waiting for selector={btn_selector} timeout")
+            self.page.ele(btn_selector).click(by_js=True)
+
+            self._log("Waiting for url redirect...")
+            self.page.wait.url_change('service-level', exclude=True, timeout=45)
+            time.sleep(2)
+
+            if "service-level" in self.page.url or "auth" in self.page.url:
+                raise BizLogicError(message="Redirect to appointment-booking Failed!")
+
+            btn_selector = 'tag:button@text():Book your appointment'            
+            if not self.page.wait.ele_displayed(btn_selector, timeout=10):
+                raise BizLogicError(message=f"Waiting for selector={btn_selector} timeout")
+                
             self.session_create_time = time.time()
             self.session_create_time = time.time()
-            self._log(f"Session Ready. Group: {self.travel_group['group_number']}")
+            self._log(f"✅ Login & Navigation Success! Target Group ID: {formgroup_id}")
 
 
         except Exception as e:
         except Exception as e:
             self._log(f"Session Create Error: {e}")
             self._log(f"Session Create Error: {e}")
@@ -317,47 +315,118 @@ class TlsPlugin(IVSPlg):
     def query(self, apt_type: AppointmentType) -> VSQueryResult:
     def query(self, apt_type: AppointmentType) -> VSQueryResult:
         res = VSQueryResult()
         res = VSQueryResult()
         res.success = False
         res.success = False
-        apt_config = self.free_config.get('apt_config', {})
         group_num = self.travel_group['group_number']
         group_num = self.travel_group['group_number']
+        apt_config = self.free_config.get('apt_config', {})
         interest_month = self.free_config.get("interest_month", time.strftime("%m-%Y"))
         interest_month = self.free_config.get("interest_month", time.strftime("%m-%Y"))
         
         
-        url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
-        params = {
-            'location': apt_config["code"],
-            'month': interest_month,
-        }
+        target_date_obj = datetime.strptime(interest_month, "%m-%Y")
+        target_month_text = target_date_obj.strftime("%B %Y")
+        target_year = target_date_obj.year
+        target_month_num = target_date_obj.month
         
         
-        # DrissionPage 自动处理 Cloudflare,直接 fetch 即可
-        try:
-            resp = self._perform_request("GET", url, params=params, retry_count=1)
-        except Exception as e:
-            self._log(f"Query request failed: {e}")
-            raise e
+        slots = []
+        all_slots = []
+        
+        current_selected_ele = self.page.ele('@data-testid=btn-current-month-available')
+        current_month_text = current_selected_ele.text.strip() if current_selected_ele else ""
 
 
-        self._check_page_is_session_expired_or_invalid('Book your appointment', resp.text)
+        is_on_target_month = (current_month_text.lower() == target_month_text.lower())
+
+        if not is_on_target_month:
+            self._log(f"Current is '{current_month_text}', navigating to '{target_month_text}'...")
+            for _ in range(12):
+                target_btn_xpath = f'xpath://a[contains(@href, "month={interest_month}")]'
+                target_btn = self.page.ele(target_btn_xpath)
+                
+                if target_btn:
+                    target_btn.click(by_js=True)
+                    time.sleep(3) 
+                    break
+                
+                next_btn = self.page.ele('@data-testid=btn-next-month-available')
+                if next_btn:
+                    next_btn.click(by_js=True)
+                    time.sleep(2) 
+                else:
+                    self._log("Warning: Cannot find target month or 'Next Month' button.")
+                    break
+
+            self._log("Extracting slots from DOM using robust data-testid features...")
+            
+            day_blocks_xpath = '//div[p and div//button[contains(@data-testid, "slot")]]'
+            day_blocks = self.page.eles(f'xpath:{day_blocks_xpath}')
+            
+            for block in day_blocks:
+                # 1. 提取日期:只要是这个 block 下的 p 标签,必定是 "Mon 01" 这种
+                p_ele = block.ele('tag:p')
+                if not p_ele:
+                    continue
+                
+                # 直接从 p 标签的纯文本里抽取出数字,忽略前面的字母
+                day_match = re.search(r'\d+', p_ele.text)
+                if not day_match:
+                    continue
+                day_str = day_match.group()
+                
+                full_date = f"{target_year}-{target_month_num:02d}-{int(day_str):02d}"
+                
+                # 2. 提取可用按钮:利用 data-testid 前缀匹配
+                # 完美过滤掉 btn-unavailable-slot (灰色的不可用按钮)
+                available_btns = block.eles('xpath:.//button[starts-with(@data-testid, "btn-available-slot")]')
+                
+                for btn in available_btns:
+                    # 提取时间:无视内部各种 span 的变动,只要 html 里有 00:00 这种格式就被截取
+                    time_match = re.search(r'\d{2}:\d{2}', btn.html)
+                    if not time_match:
+                        continue
+                    time_str = time_match.group()
+                    
+                    # 提取 Label:完全依赖测试工程师留下的 testid
+                    test_id = btn.attr('data-testid') or ""
+                    if 'prime' in test_id and 'weekend' in test_id:
+                        lbl = 'ptaw'
+                    elif 'prime' in test_id:
+                        lbl = 'pta'
+                    else:
+                        lbl = '' 
+                        
+                    all_slots.append({
+                        'date': full_date,
+                        'time': time_str,
+                        'label': lbl
+                    })
 
 
-        # 解析 Slots
-        all_slots = self._parse_appointment_slots(resp.text)
+        else:
+            self._log(f"Already on '{target_month_text}'. Executing silent JS fetch...") 
+            url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
+            params = {
+                'location': apt_config["code"],
+                'month': interest_month
+            }
+            
+            resp = self._perform_request("GET", url, params=params, retry_count=1)
+            html_content = resp.text
+            self._check_page_is_session_expired_or_invalid('Book your appointment', html_content)
+            all_slots = self._parse_appointment_slots(html_content)
 
 
         target_labels = self.free_config.get("target_labels", ["", "pta"])
         target_labels = self.free_config.get("target_labels", ["", "pta"])
-        # 根据配置过滤
-        available = [s for s in all_slots if s.get("label") in target_labels]
+        slots = [s for s in all_slots if s.get("label") in target_labels]
         
         
-        if available:
+        if slots:
             res.success = True
             res.success = True
-            earliest_date = available[0]["date"]
+            earliest_date = slots[0]["date"]
             earliest_dt = datetime.strptime(earliest_date, "%Y-%m-%d")
             earliest_dt = datetime.strptime(earliest_date, "%Y-%m-%d")
             res.availability_status = AvailabilityStatus.Available
             res.availability_status = AvailabilityStatus.Available
             res.earliest_date = earliest_dt
             res.earliest_date = earliest_dt
             date_map: dict[datetime, list[TimeSlot]] = {}
             date_map: dict[datetime, list[TimeSlot]] = {}
-            for s in available:
+            for s in slots:
                 date_str = s["date"]
                 date_str = s["date"]
                 dt = datetime.strptime(date_str, "%Y-%m-%d")
                 dt = datetime.strptime(date_str, "%Y-%m-%d")
                 date_map.setdefault(dt, []).append(
                 date_map.setdefault(dt, []).append(
                     TimeSlot(time=s["time"], label=str(s.get("label", "")))
                     TimeSlot(time=s["time"], label=str(s.get("label", "")))
                 )
                 )
             res.availability = [DateAvailability(date=d, times=slots) for d, slots in date_map.items()]
             res.availability = [DateAvailability(date=d, times=slots) for d, slots in date_map.items()]
-            self._log(f"Slot Found! -> {available}")
+            self._log(f"Slot Found! -> {slots}")
         else:
         else:
             self._log("No slots available.")
             self._log("No slots available.")
             res.success = False
             res.success = False
@@ -368,9 +437,6 @@ class TlsPlugin(IVSPlg):
         res = VSBookResult()
         res = VSBookResult()
         res.success = False
         res.success = False
         
         
-        apt_config = self.free_config.get('apt_config', {})
-        group_num = self.travel_group['group_number']
-        
         exp_start = user_inputs.get('expected_start_date', '')
         exp_start = user_inputs.get('expected_start_date', '')
         exp_end = user_inputs.get('expected_end_date', '')
         exp_end = user_inputs.get('expected_end_date', '')
         support_pta = user_inputs.get('support_pta', True)
         support_pta = user_inputs.get('support_pta', True)
@@ -379,15 +445,11 @@ class TlsPlugin(IVSPlg):
         if support_pta:
         if support_pta:
             target_labels.append('pta')
             target_labels.append('pta')
 
 
-        # 获取所有可用的日期字符串用于过滤
         available_dates_str =[
         available_dates_str =[
             da.date.strftime("%Y-%m-%d")
             da.date.strftime("%Y-%m-%d")
             for da in slot_info.availability if da.date
             for da in slot_info.availability if da.date
         ]
         ]
         
         
-        # ---------------------------------------------------------
-        # 第一步:过滤出符合用户日期范围要求的日期,并随机选择一个 slot
-        # ---------------------------------------------------------
         valid_dates_list = self._filter_dates(available_dates_str, exp_start, exp_end)
         valid_dates_list = self._filter_dates(available_dates_str, exp_start, exp_end)
         if not valid_dates_list:
         if not valid_dates_list:
             raise NotFoundError(message="No dates match user constraints")
             raise NotFoundError(message="No dates match user constraints")
@@ -412,142 +474,76 @@ class TlsPlugin(IVSPlg):
 
 
         selected_slot = random.choice(all_possible_slots)
         selected_slot = random.choice(all_possible_slots)
         selected_date = selected_slot["date"]
         selected_date = selected_slot["date"]
-        selected_time = selected_slot["time_obj"]  # TimeSlot 对象
+        selected_time = selected_slot["time_obj"]
         selected_label = selected_slot["label"]
         selected_label = selected_slot["label"]
 
 
-        self._log(f"Found {len(all_possible_slots)} valid slots. Randomly selected: {selected_date} {selected_time.time}")
-        
-        # 基础 URL 和路由状态 (Next.js 专用)
-        base_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
-        router_state = f'%5B%22%22%2C%7B%22children%22%3A%5B%5B%22lang%22%2C%22en-us%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%5B%22groupId%22%2C%22{group_num}%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%22workflow%22%2C%7B%22children%22%3A%5B%22appointment-booking%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D'
-
-        # ---------------------------------------------------------
-        # 第二步:调用 getBasketCost 获取订单金额 (预定前置条件)
-        # ---------------------------------------------------------
-        self._log("Fetching basket cost...")
-        getBasketCost_ACTION_ID = "40124cc90acef520d4fd2daf60ad3c8e21fc2c11d8"
-        
-        payload =[{
-            "groupId": str(group_num),
-            "lang": "en-us",
-            "labels": [selected_label]
-        }]
-        body_data_str = json.dumps(payload)
-        
-        getBasketCost_js_script = f"""
-        const url = "{base_url}";
-        const headers = {{
-            'Next-Action': '{getBasketCost_ACTION_ID}',
-            'Next-Router-State-Tree': decodeURIComponent('{router_state}'),
-            'Accept': 'text/x-component',
-            'Accept-Language': 'zh-CN,zh;q=0.9',
-            'Content-Type': 'text/plain;charset=UTF-8'
-        }};
-        const bodyData = '{body_data_str}';
-        
-        return fetch(url, {{ method: 'POST', headers: headers, body: bodyData }})
-            .then(async response => {{
-                const text = await response.text();
-                const headers = {{}};
-                response.headers.forEach((value, key) => headers[key] = value);
-                return {{ status: response.status, body: text, headers: headers, url: response.url }};
-            }}).catch(err => {{
-                return {{ status: 0, body: err.toString(), headers: {{}}, url: url }};
-            }});
+        self._log(f"Found {len(all_possible_slots)} valid slots. selected slot: {selected_date} {selected_time.time} {selected_label}")
+        
+        js_inject_and_click = f"""
+        try {{
+            const form = document.querySelector('form');
+            if (!form) return 'Form not found';
+
+            function setReactValue(input, value) {{
+                if (!input) return;
+                input.value = value;
+                input.dispatchEvent(new Event('input', {{ bubbles: true }}));
+                input.dispatchEvent(new Event('change', {{ bubbles: true }}));
+            }}
+            setReactValue(form.querySelector('input[name="date"]'), '{selected_date}');
+            setReactValue(form.querySelector('input[name="time"]'), '{selected_time.time}');
+            setReactValue(form.querySelector('input[name="appointmentLabel"]'), '{selected_label}');
+            const submitBtn = form.querySelector('button[type="submit"]');
+            if (submitBtn) {{
+                submitBtn.removeAttribute('disabled');
+                submitBtn.classList.remove('opacity-50', 'cursor-not-allowed'); 
+                submitBtn.click();
+                return 'clicked';
+            }} else {{
+                return 'Submit button not found';
+            }}
+        }} catch (e) {{
+            return e.toString();
+        }}
         """
         """
         
         
-        cost_res_dict = self.page.run_js(getBasketCost_js_script)
-        cost_resp = BrowserResponse(cost_res_dict)
-
-        if cost_resp.status_code != 200:
-            self._log(f"Failed to get basket cost! Status: {cost_resp.status_code}. Aborting booking.")
-            return res
-
-        # 尝试解析并打印金额信息,例如总价和币种
-        cost_match = re.search(r'"total":"([^"]+)","currency":"([^"]+)"', cost_resp.text)
-        if cost_match:
-            total_cost, currency = cost_match.groups()
-            self._log(f"Basket cost checked successfully: {total_cost} {currency}")
-        else:
-            self._log("Basket cost checked successfully (could not parse exact amount).")
-
-        # ---------------------------------------------------------
-        # 第三步:解决 ReCaptcha V3
-        # ---------------------------------------------------------
-        self._log("Solving ReCaptcha V3...")
-        page_url = f'{base_url}?location={apt_config["code"]}&month={selected_date[:7]}'
-        api_token = self.free_config.get("capsolver_key", "")
-        
-        rc_params = {
-            "type": "ReCaptchaV3TaskProxyLess",
-            "page": page_url,
-            "action": "book", 
-            "siteKey": "6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
-            "apiToken": api_token,
-            "proxy": self._get_proxy_url() 
-        }
-        g_token = self._solve_recaptcha(rc_params)
-
-        # ---------------------------------------------------------
-        # 第四步:提交正式的 Appointment Booking 请求
-        # ---------------------------------------------------------
-        self._log("Submitting booking request via JS Fetch...")
-        bookAppointment_ACTION_ID = "6043cfd107081bc817cbb11a8c0db17d3a063401be"
-        
-        bookAppointment_js_script = f"""
-        const url = "{base_url}";
-        const formData = new FormData();
-        
-        formData.append('1_formGroupId', '{group_num}');
-        formData.append('1_lang', 'en-us');
-        formData.append('1_process', 'APPOINTMENT');
-        formData.append('1_location', '{apt_config["code"]}');
-        formData.append('1_date', '{selected_date}');
-        formData.append('1_time', '{selected_time.time}');
-        formData.append('1_appointmentLabel', '{selected_label}');
-        formData.append('1_captchaToken', '{g_token}');
-        formData.append('0', '[{{"status":"IDLE"}},"$K1"]');
-        
-        const headers = {{
-            'Next-Action': '{bookAppointment_ACTION_ID}',
-            'Next-Router-State-Tree': decodeURIComponent('{router_state}'),
-            'Accept': 'text/x-component'
-        }};
+        inject_res = self.page.run_js(js_inject_and_click)
+        self._log(f"Form submission triggered: {inject_res}")
         
         
-        return fetch(url, {{ method: 'POST', headers: headers, body: formData }})
-            .then(async response => {{
-                const text = await response.text();
-                const headers = {{}};
-                response.headers.forEach((value, key) => headers[key] = value);
-                return {{ status: response.status, body: text, headers: headers, url: response.url }};
-            }}).catch(err => {{
-                return {{ status: 0, body: err.toString(), headers: {{}}, url: url }};
-            }});
-        """
-        
-        book_res_dict = self.page.run_js(bookAppointment_js_script)
-        resp = BrowserResponse(book_res_dict)
-
-        # ---------------------------------------------------------
-        # 第五步:结果判定
-        # ---------------------------------------------------------
-        if resp.status_code == 303 or (resp.status_code == 200 and "appointment-confirmation" in resp.url):
-            self._log(f"Booking Success! URL: {resp.url}")
-            res.success = True
-            res.book_date = selected_date
-            res.book_time = selected_time.time
-            return res
-
-        if resp.status_code == 200:
-            if "APPOINTMENT_LIMIT_REACHED" in resp.text:
-                self._log("Failed: Appointment Limit Reached")
-            elif "Invalid captcha" in resp.text:
-                self._log("Failed: Invalid Captcha")
-            else:
-                self._log(f"Booking Failed (Unknown 200): {resp.text[:200]}")
-        else:
-            self._log(f"Booking Failed. Status: {resp.status_code}")
+        if inject_res != 'clicked':
+            raise BizLogicError(message="Failed to inject form or click the submit button")
 
 
+        self._log("Waiting for Next.js to process the form submission...")
+        for _ in range(10):
+            try:
+                current_page_url = self.page.url
+                current_page_html = self.page.html
+                appointment_confirmation_indicators = [
+                    "order-summary" in current_page_url,
+                    "partner-services" in current_page_url,
+                    "appointment-confirmation" in current_page_url,
+                    "Change my appointment" in current_page_html,
+                    "Book a new appointment" in current_page_html,
+                ]
+                
+                if any(appointment_confirmation_indicators):
+                    self._log(f"✅ BOOKING SUCCESS! Redirected to: {current_page_url}")
+                    res.success = True
+                    res.label = selected_label
+                    res.book_date = selected_date
+                    res.book_time = selected_time.time
+                    self._save_screenshot("book_slot_success")
+                    break
+                
+                toast_selector = 'tag:div@role=alert'
+                toast_ele = self.page.ele(toast_selector, timeout=0.5)
+                if toast_ele:
+                    error_msg = toast_ele.text
+                    self._log(f"❌ BOOKING FAILED! Detected popup: {error_msg}")
+                    break
+                time.sleep(0.5)
+            except Exception:
+                pass
         return res
         return res
     
     
     def _get_proxy_url(self):
     def _get_proxy_url(self):
@@ -629,29 +625,20 @@ class TlsPlugin(IVSPlg):
             self.is_healthy = False
             self.is_healthy = False
             raise SessionExpiredOrInvalidError()
             raise SessionExpiredOrInvalidError()
         elif resp.status_code == 403:
         elif resp.status_code == 403:
-            # [关键修改] 遇到 403 Forbidden,尝试绕盾并重试
-            # 最多重试 2 次
             if retry_count < 2:
             if retry_count < 2:
                 self._log(f"HTTP 403 Detected. Cloudflare session expired? Attempting refresh (Try {retry_count+1}/2)...")
                 self._log(f"HTTP 403 Detected. Cloudflare session expired? Attempting refresh (Try {retry_count+1}/2)...")
-                
-                # 尝试刷新盾
                 if self._refresh_firewall_session():
                 if self._refresh_firewall_session():
                     self._log("Firewall session refreshed. Retrying request...")
                     self._log("Firewall session refreshed. Retrying request...")
-                    # 递归重试
                     return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
                     return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
                 else:
                 else:
-                    self._log("Failed to refresh firewall session.")
-            
-            # 如果重试失败,抛出异常
+                    self._log("Failed to refresh firewall session.")    
             raise PermissionDeniedError(f"HTTP 403: {resp.text[:100]}")
             raise PermissionDeniedError(f"HTTP 403: {resp.text[:100]}")
         elif resp.status_code == 429:
         elif resp.status_code == 429:
             self.is_healthy = False
             self.is_healthy = False
             raise RateLimiteddError()
             raise RateLimiteddError()
         else:
         else:
-             # 如果是 0,可能是 fetch 报错
             if resp.status_code == 0:
             if resp.status_code == 0:
                  raise BizLogicError(f"Network Error: {resp.text}")
                  raise BizLogicError(f"Network Error: {resp.text}")
-            # TLS 业务错误
             raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
             raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
     
     
     def _refresh_firewall_session(self) -> bool:
     def _refresh_firewall_session(self) -> bool:
@@ -727,10 +714,10 @@ class TlsPlugin(IVSPlg):
             time.sleep(3)
             time.sleep(3)
         raise BizLogicError(message="Capsolver task timeout")
         raise BizLogicError(message="Capsolver task timeout")
 
 
-    def _parse_travel_groups(self, html: str) -> List[Dict]:
+    def _parse_travel_groups(self, html_content) -> List[Dict]:
         groups = []
         groups = []
         js_pattern = r'\\"travelGroups\\":\s*(\[.*?\]),\\"availableCountriesToCreateGroups'
         js_pattern = r'\\"travelGroups\\":\s*(\[.*?\]),\\"availableCountriesToCreateGroups'
-        js_match = re.search(js_pattern, html, re.DOTALL)
+        js_match = re.search(js_pattern, html_content, re.DOTALL)
         if js_match:
         if js_match:
             json_str = js_match.group(1).replace(r'\"', '"')
             json_str = js_match.group(1).replace(r'\"', '"')
             data = json.loads(json_str)
             data = json.loads(json_str)
@@ -744,10 +731,10 @@ class TlsPlugin(IVSPlg):
             self._log('Parsed travel group page, but not found travelGroups')
             self._log('Parsed travel group page, but not found travelGroups')
         return groups
         return groups
 
 
-    def _parse_appointment_slots(self, html: str) -> List[Dict]:
+    def _parse_appointment_slots(self, html_content) -> List[Dict]:
         slots = []
         slots = []
         pattern = r'"availableAppointments\\":\s*(\[.*\]),\\"showFlexiAppointment'
         pattern = r'"availableAppointments\\":\s*(\[.*\]),\\"showFlexiAppointment'
-        match = re.search(pattern, html, re.DOTALL)
+        match = re.search(pattern, html_content, re.DOTALL)
         
         
         if match:
         if match:
             json_str = match.group(1).replace(r'\"', '"')
             json_str = match.group(1).replace(r'\"', '"')
@@ -774,18 +761,16 @@ class TlsPlugin(IVSPlg):
             self.is_healthy = False
             self.is_healthy = False
             raise SessionExpiredOrInvalidError()
             raise SessionExpiredOrInvalidError()
         
         
-        # 将 html 转小写检查
         html_lower = html.lower()
         html_lower = html.lower()
         if keyword.lower() not in html_lower: 
         if keyword.lower() not in html_lower: 
-            if 'redirected automatically' in html_lower:
-                self.is_healthy = False
-                raise SessionExpiredOrInvalidError("Redirected automatically")
-            if 'login' in html_lower and 'password' in html_lower:
-                self.is_healthy = False
-                raise SessionExpiredOrInvalidError("Redirected to login")
-            if 'session expired' in html_lower:
+            session_expire_or_invalid_indicators = [
+                'redirected automatically' in html_lower,
+                'login' in html_lower and 'password' in html_lower,
+                'session expired' in html_lower
+            ]
+            if any(session_expire_or_invalid_indicators):
                 self.is_healthy = False
                 self.is_healthy = False
-                raise SessionExpiredOrInvalidError("Session expired")
+                raise SessionExpiredOrInvalidError()
             
             
     def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
     def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
         if not start_str or not end_str:
         if not start_str or not end_str:

+ 45 - 29
test/test_capsolver.py

@@ -1,6 +1,7 @@
 import requests
 import requests
 import time
 import time
 import json
 import json
+from urllib.parse import urlencode
 
 
 class CaptchaTester:
 class CaptchaTester:
     def __init__(self, api_key):
     def __init__(self, api_key):
@@ -11,8 +12,8 @@ class CaptchaTester:
     def _log(self, message):
     def _log(self, message):
         print(f"[{time.strftime('%H:%M:%S')}] {message}")
         print(f"[{time.strftime('%H:%M:%S')}] {message}")
 
 
-    def solve_captcha(self, page_url: str, task_type: str, site_key: str, use_proxy=False, action: str = None) -> str:
-        """通用解决验证码"""
+    def solve_captcha(self, page_url: str, task_type: str, site_key: str, action: str = None) -> str:
+        """调用 Capsolver 获取 ReCaptcha V3 Token"""
         capsolver_key = self.config.get('capsolver_key')
         capsolver_key = self.config.get('capsolver_key')
         if not capsolver_key:
         if not capsolver_key:
             raise ValueError("Capsolver API key missing")
             raise ValueError("Capsolver API key missing")
@@ -21,11 +22,9 @@ class CaptchaTester:
             "type": task_type,
             "type": task_type,
             "websiteURL": page_url,
             "websiteURL": page_url,
             "websiteKey": site_key,
             "websiteKey": site_key,
+            "pageAction": action
         }
         }
         
         
-        if action:
-            task["pageAction"] = action
-
         payload = {"clientKey": capsolver_key, "task": task}
         payload = {"clientKey": capsolver_key, "task": task}
         
         
         # 创建任务
         # 创建任务
@@ -35,7 +34,7 @@ class CaptchaTester:
             raise Exception(f"创建任务失败: {res.text}")
             raise Exception(f"创建任务失败: {res.text}")
 
 
         task_id = resp_json.get("taskId")
         task_id = resp_json.get("taskId")
-        self._log(f"任务已创建: {task_id}. 正在等待结果...")
+        self._log(f"任务已创建: {task_id}. 正在等待高分 Token...")
 
 
         # 轮询结果
         # 轮询结果
         for i in range(20):
         for i in range(20):
@@ -49,54 +48,71 @@ class CaptchaTester:
                 self._log("验证码解决成功!")
                 self._log("验证码解决成功!")
                 return data["solution"].get("gRecaptchaResponse") or data["solution"].get("token")
                 return data["solution"].get("gRecaptchaResponse") or data["solution"].get("token")
             
             
-            self._log(f"等待中... ({i+1}次)")
             time.sleep(3)
             time.sleep(3)
         raise Exception("Capsolver 任务超时")
         raise Exception("Capsolver 任务超时")
 
 
     def test_score(self):
     def test_score(self):
-        # 1. 参数配置
-        test_page = "https://antcpt.com"
-        site_key = "6LcR_okUAAAAAPYrPe-HK_0RULO1aZM15ENyM-Mf"
-        action = "homepage"
+        # 1. 目标网站配置 (基于你提供的 curl 信息)
+        test_page = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php"
+        # 这是该演示页面的公钥
+        site_key = "6LdKlZEpAAAAAAOQjzC2v_d36tWxCl6dWsozdSy9" 
+        action = "examples/v3scores"
         
         
         try:
         try:
-            # 2. 获取 Token
+            # 2. 从 Capsolver 获取 Token
             token = self.solve_captcha(
             token = self.solve_captcha(
                 page_url=test_page,
                 page_url=test_page,
-                task_type="ReCaptchaV3TaskProxyLess", # 或者使用 ReCaptchaV3Task
+                task_type="ReCaptchaV3TaskProxyLess", 
                 site_key=site_key,
                 site_key=site_key,
                 action=action
                 action=action
             )
             )
 
 
-            # 3. 发送到评分网站
-            self._log("正在提交 Token 到 ar1n.xyz 进行评分...")
-            score_url = 'https://ar1n.xyz/recaptcha3ScoreTest'
+            # 3. 构造 GET 请求 (按照你提供的 curl 格式)
+            self._log("正在提交 Token 到谷歌演示服务器进行评分...")
+            
+            verify_base_url = "https://recaptcha-demo.appspot.com/recaptcha-v3-verify.php"
+            params = {
+                "action": action,
+                "token": token
+            }
+            # 拼接成最终的 URL: verify.php?action=...&token=...
+            full_verify_url = f"{verify_base_url}?{urlencode(params)}"
+            
             headers = {
             headers = {
-                'Accept': 'application/json, text/javascript, */*; q=0.01',
-                'Content-Type': 'application/json; charset=UTF-8',
-                'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
-                'Origin': 'https://antcpt.com',
-                'Referer': 'https://antcpt.com/'
+                'accept': '*/*',
+                'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+                'referer': 'https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php',
+                'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
+                'sec-ch-ua-platform': '"macOS"'
             }
             }
-            # 注意:这里的 key 必须是 g-recaptcha-reponse (网站特定的拼写错误)
-            post_data = {"g-recaptcha-reponse": token}
             
             
-            response = requests.post(score_url, headers=headers, json=post_data)
+            # 发起 GET 请求
+            response = requests.get(full_verify_url, headers=headers)
             
             
             if response.status_code == 200:
             if response.status_code == 200:
                 result = response.json()
                 result = response.json()
-                print("\n" + "="*30)
-                print("【评分结果】")
-                print(json.dumps(result, indent=4))
-                print("="*30)
+                print("\n" + "="*40)
+                print("🚀 【谷歌官方 V3 评分结果】")
+                print(f"状态: {'成功' if result.get('success') else '失败'}")
+                print(f"分数 (Score): {result.get('score')}")
+                print(f"动作 (Action): {result.get('action')}")
+                if 'hostname' in result:
+                    print(f"域名 (Hostname): {result.get('hostname')}")
+                print("="*40)
+                
+                if result.get('score', 0) < 0.7:
+                    print("⚠️ 警告: 分数过低,可能会被目标网站拦截。")
+                else:
+                    print("✅ 分数理想,可以用于预约。")
             else:
             else:
-                print(f"评分请求失败: {response.status_code}, {response.text}")
+                print(f"验证请求失败: {response.status_code}, {response.text}")
 
 
         except Exception as e:
         except Exception as e:
             print(f"发生错误: {e}")
             print(f"发生错误: {e}")
 
 
 # --- 运行测试 ---
 # --- 运行测试 ---
 if __name__ == "__main__":
 if __name__ == "__main__":
+    # 请确保你的 API Key 正确
     MY_CAPSOLVER_KEY = "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A"
     MY_CAPSOLVER_KEY = "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A"
     tester = CaptchaTester(MY_CAPSOLVER_KEY)
     tester = CaptchaTester(MY_CAPSOLVER_KEY)
     tester.test_score()
     tester.test_score()

+ 430 - 343
test/tls_standalone.py

@@ -17,197 +17,6 @@ from urllib.parse import urlencode
 from DrissionPage import ChromiumPage, ChromiumOptions
 from DrissionPage import ChromiumPage, ChromiumOptions
 
 
 
 
-class ProxyTunnel:
-    """
-    【修复优化版】管理本地代理隧道
-    1. 启用 TCP_NODELAY 消除握手延迟 (关键修复)
-    2. 开启 KeepAlive 防止链路中断
-    3. 修复非阻塞模式下 sendall 导致的数据丢失问题
-    """
-    def __init__(self, upstream_ip, upstream_port, username, password):
-        self.upstream_ip = upstream_ip
-        self.upstream_port = int(upstream_port)
-        self.username = username
-        self.password = password
-        
-        # 预先计算 Proxy-Authorization 头
-        auth_str = f"{username}:{password}"
-        b64_auth = base64.b64encode(auth_str.encode()).decode()
-        self.auth_header = f"Proxy-Authorization: Basic {b64_auth}\r\n"
-        
-        self.server_socket = None
-        self.local_port = 0
-        self.running = False
-        self.listen_thread = None
-
-    def start(self):
-        try:
-            self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-            self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-            
-            self.server_socket.bind(('127.0.0.1', 0))
-            self.local_port = self.server_socket.getsockname()[1]
-            self.server_socket.listen(128) # 增加连接队列长度
-            
-            self.running = True
-            
-            self.listen_thread = threading.Thread(target=self._accept_loop, daemon=True)
-            self.listen_thread.start()
-            
-            return f"127.0.0.1:{self.local_port}"
-        except Exception as e:
-            self.stop()
-            raise RuntimeError(f"Failed to start tunnel: {e}")
-
-    def stop(self):
-        self.running = False
-        if self.server_socket:
-            try:
-                self.server_socket.close()
-            except Exception:
-                pass
-        self.server_socket = None
-
-    def _accept_loop(self):
-        while self.running:
-            try:
-                if self.server_socket:
-                    # 使用 select 替代 settimeout,减少 CPU 空转
-                    r, _, _ = select.select([self.server_socket], [], [], 1.0)
-                    if r:
-                        try:
-                            client_sock, _ = self.server_socket.accept()
-                            t = threading.Thread(target=self._handle_client, args=(client_sock,), daemon=True)
-                            t.start()
-                        except OSError:
-                            break
-            except Exception:
-                continue
-
-    def _optimize_socket(self, sock):
-        """核心优化:设置 Socket 选项"""
-        try:
-            # 1. 禁用 Nagle 算法:数据包立即发送,不等待填满缓冲区
-            # 这是解决 "HttpClient Timeout" 的关键
-            sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
-            
-            # 2. 开启 KeepAlive:防止防火墙切断空闲连接
-            sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
-            
-            # 3. 增大缓冲区 (Linux/Mac 可选,Windows 一般自动管理)
-            sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 32*1024)
-            sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 32*1024)
-        except Exception:
-            pass
-
-    def _handle_client(self, client_sock):
-        upstream_sock = None
-        try:
-            self._optimize_socket(client_sock)
-            client_sock.settimeout(30) 
-            
-            # 1. 读取首包 (32KB 缓冲区)
-            try:
-                first_packet = client_sock.recv(32768)
-            except socket.timeout:
-                return # 客户端连上但不发数据
-                
-            if not first_packet:
-                return
-
-            # 2. 连接上游
-            upstream_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-            self._optimize_socket(upstream_sock) # 同样优化上游 Socket
-            
-            upstream_sock.settimeout(10) # 连接超时 10s
-            upstream_sock.connect((self.upstream_ip, self.upstream_port))
-            
-            # 连接建立后,将超时设为 None (阻塞模式),交由 select 控制
-            upstream_sock.settimeout(None)
-            client_sock.settimeout(None)
-            
-            # 3. 注入 Header
-            sep = b'\r\n'
-            idx = first_packet.find(sep)
-            if idx != -1:
-                new_packet = first_packet[:idx+2] + self.auth_header.encode() + first_packet[idx+2:]
-            else:
-                new_packet = first_packet
-
-            # 4. 发送首包 (阻塞式发送,保证数据完整)
-            upstream_sock.sendall(new_packet)
-            
-            # 5. 双向转发
-            self._pipe_sockets(client_sock, upstream_sock)
-
-        except Exception:
-            pass
-        finally:
-            self._close_socket(client_sock)
-            self._close_socket(upstream_sock)
-
-    def _pipe_sockets(self, sock1, sock2):
-        """
-        修复后的转发逻辑:
-        保持 Socket 为阻塞模式,利用 select 监听可读状态。
-        """
-        sockets = [sock1, sock2]
-        last_activity = time.time()
-        IDLE_TIMEOUT = 120 # 延长空闲超时
-        
-        while self.running:
-            try:
-                # 监听可读事件
-                r, _, x = select.select(sockets, [], sockets, 1.0)
-                
-                if x: break # Socket 异常
-                
-                if not r:
-                    if time.time() - last_activity > IDLE_TIMEOUT:
-                        break
-                    continue
-                
-                for s in r:
-                    try:
-                        # 尝试读取
-                        data = s.recv(32768)
-                    except ConnectionResetError:
-                        data = None
-                    
-                    if not data:
-                        return # 连接关闭
-                    
-                    # 确定发送目标
-                    target = sock2 if s is sock1 else sock1
-                    
-                    # 关键修改:使用阻塞式 sendall
-                    # 如果网络卡顿,线程会在这里暂停等待,而不是抛出错误或丢包
-                    try:
-                        target.sendall(data)
-                    except BrokenPipeError:
-                        return
-                        
-                    last_activity = time.time()
-                    
-            except Exception:
-                break
-
-    def _close_socket(self, sock):
-        """优雅关闭 Socket"""
-        if sock:
-            try:
-                # 发送 FIN 包,通知对端数据发送完毕
-                sock.shutdown(socket.SHUT_RDWR)
-            except Exception:
-                pass
-            try:
-                sock.close()
-            except Exception:
-                pass
-
-    def __del__(self):
-        self.stop()
-
 class BrowserResponse:
 class BrowserResponse:
     """模拟 requests.Response 的轻量级对象"""
     """模拟 requests.Response 的轻量级对象"""
     def __init__(self, result_dict):
     def __init__(self, result_dict):
@@ -227,7 +36,6 @@ class TlsAutoBot:
         self.workspace = os.path.abspath(os.path.join("data", f"tls_session_{self.instance_id}"))
         self.workspace = os.path.abspath(os.path.join("data", f"tls_session_{self.instance_id}"))
         self.page = None
         self.page = None
         self.travel_group = None
         self.travel_group = None
-        self.tunnel = None
 
 
     def _log(self, msg):
     def _log(self, msg):
         print(f"[TLS-Bot-{self.instance_id}] {msg}")
         print(f"[TLS-Bot-{self.instance_id}] {msg}")
@@ -249,24 +57,9 @@ class TlsAutoBot:
         # 2. 代理配置
         # 2. 代理配置
         proxy_cfg = self.config.get('proxy', {})
         proxy_cfg = self.config.get('proxy', {})
 
 
-            
-        if proxy_cfg.get("username") and proxy_cfg.get("password"):
-            self._log(f"Starting Proxy Tunnel for {proxy_cfg.get('ip')}...")
-            
-            # 1. 启动本地隧道
-            self.tunnel = ProxyTunnel(proxy_cfg.get('ip'), proxy_cfg.get('port'), proxy_cfg.get('username'), proxy_cfg.get('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"{proxy_cfg.get('schema')}://{proxy_cfg.get('ip')}:{proxy_cfg.get('port')}"
-            co.set_argument(f'--proxy-server={proxy_str}')
+        proxy_str = f"{proxy_cfg.get('schema')}://{proxy_cfg.get('ip')}:{proxy_cfg.get('port')}"
+        print(f'set proxy={proxy_str}')
+        co.set_argument(f'--proxy-server={proxy_str}')
 
 
         # 3. 反爬配置
         # 3. 反爬配置
         co.headless(False)
         co.headless(False)
@@ -278,7 +71,7 @@ class TlsAutoBot:
 
 
         self.page = ChromiumPage(co)
         self.page = ChromiumPage(co)
 
 
-    def solve_captcha(self, page_url: str, task_type: str, site_key: str, use_proxy = False, action: str = None) -> str:
+    def solve_captcha(self, page_url: str, task_type: str, site_key: str, use_proxy = False, action: str = None, api_domain: str = None) -> str:
         """通用解决验证码 (同步 User-Agent 防止被盾识别为高风险)"""
         """通用解决验证码 (同步 User-Agent 防止被盾识别为高风险)"""
         capsolver_key = self.config.get('capsolver_key')
         capsolver_key = self.config.get('capsolver_key')
         if not capsolver_key:
         if not capsolver_key:
@@ -289,6 +82,10 @@ class TlsAutoBot:
             "websiteURL": page_url,
             "websiteURL": page_url,
             "websiteKey": site_key,
             "websiteKey": site_key,
         }
         }
+        
+        if api_domain:
+            task["apiDomain"] = api_domain
+            
         if use_proxy:
         if use_proxy:
             proxy = self.config['proxy']
             proxy = self.config['proxy']
             task["proxyType"] = proxy.get('scheme', 'http')
             task["proxyType"] = proxy.get('scheme', 'http')
@@ -297,6 +94,7 @@ class TlsAutoBot:
             if proxy.get('username'):
             if proxy.get('username'):
                 task["proxyLogin"] = proxy.get('username')
                 task["proxyLogin"] = proxy.get('username')
                 task["proxyPassword"] = proxy.get('password')
                 task["proxyPassword"] = proxy.get('password')
+                
         if action:
         if action:
             task["pageAction"] = action
             task["pageAction"] = action
 
 
@@ -425,57 +223,185 @@ class TlsAutoBot:
         
         
         if not self.travel_group:
         if not self.travel_group:
             raise Exception(f"Travel Group not found for city: {target_city}")
             raise Exception(f"Travel Group not found for city: {target_city}")
+        
+        formgroup_id = self.travel_group.get('formGroupId')
+        self._log(f"Waiting for group button to render: {formgroup_id}")
+        
+        # 隐患修复:确保按钮渲染出来后再点,防止 JS 找不到元素
+        btn_selector = f'tag:button@@name=formGroupId@@value={formgroup_id}'
+        self.page.wait.ele_displayed(btn_selector, timeout=15)
+        
+        self._log(f"Select group_id={formgroup_id} via JS...")
+        # 替代繁琐的 run_js,直接用内置的 by_js=True 触发
+        self.page.ele(btn_selector).click(by_js=True)
+        
+        self._log("Waiting for service-level redirect...")
+        self.page.wait.url_change('travel-groups', exclude=True, timeout=45)
+        time.sleep(2) # 页面跳转后给个短缓冲
+
+        if "travel-groups" in self.page.url or "auth" in self.page.url:
+            raise Exception("Redirect to service-level Failed!")
+
+        # ==========================================
+        # 2. 点击进入 Appointment Booking
+        # ==========================================
+        self._log("Waiting for book-appointment button to render...")
+        
+        # 隐患修复:同样必须等待 Continue 按钮渲染完成
+        self.page.wait.ele_displayed('#book-appointment-btn', timeout=15)
+        
+        self._log("Clicking 'Continue' to appointment booking via JS...")
+        self.page.ele('#book-appointment-btn').click(by_js=True)
 
 
-        self._log(f"Login Success! Target Group ID: {self.travel_group['formGroupId']}")
+        self._log("Waiting for appointment-booking redirect...")
+        self.page.wait.url_change('service-level', exclude=True, timeout=45)
+        time.sleep(2)
+
+        if "service-level" in self.page.url or "auth" in self.page.url:
+            raise Exception("Redirect to appointment-booking Failed!")
+
+        self._log("Waiting for appointment-booking page to fully load...")
+        self.page.wait.load_start()
+        time.sleep(3)
+
+        self._log(f"✅ Login & Navigation Success! Target Group ID: {formgroup_id}")
 
 
     def query_slots(self) -> list:
     def query_slots(self) -> list:
-        """通过 JS Fetch 获取可用日期并解析"""
-        self._log("Querying available slots...")
+        """根据当前 UI 状态自动判断路由,并使用高鲁棒性特征提取 Slot"""
         group_num = self.travel_group['formGroupId']
         group_num = self.travel_group['formGroupId']
         apt_config = self.config['apt_config']
         apt_config = self.config['apt_config']
         interest_month = self.config.get("interest_month", time.strftime("%m-%Y"))
         interest_month = self.config.get("interest_month", time.strftime("%m-%Y"))
         
         
-        url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
-        params = {'location': apt_config["code"], 'month': interest_month}
-        
-        # 组装完整的 query url
-        query_url = f"{url}?{urlencode(params)}"
+        # 转换工具
+        target_date_obj = datetime.strptime(interest_month, "%m-%Y")
+        target_month_text = target_date_obj.strftime("%B %Y")
+        target_year = target_date_obj.year
+        target_month_num = target_date_obj.month
         
         
-        js_script = f"""
-        return fetch("{query_url}", {{ credentials: "include" }})
-            .then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
-            .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
-        """
-        res_dict = self.page.run_js(js_script)
-        resp = BrowserResponse(res_dict)
+        slots = []
 
 
-        if resp.status_code != 200:
-            raise Exception(f"Query Failed: {resp.status_code} - {resp.text[:100]}")
+        # 获取选中的月份:通过 data-testid 找到当前月份按钮并提取文本
+        # 注意:这里改用 data-testid 寻找当前月份,更稳健
+        current_selected_ele = self.page.ele('@data-testid=btn-current-month-available')
+        current_month_text = current_selected_ele.text.strip() if current_selected_ele else ""
 
 
-        # 解析正则 (保持原样)
-        slots = []
-        pattern = r'"availableAppointments\\":\s*(\[.*?\]),\\"showFlexiAppointment'
-        match = re.search(pattern, resp.text, re.DOTALL)
-        if match:
-            json_str = match.group(1).replace(r'\"', '"')
-            data = json.loads(json_str)
-            for day in data:
-                d_str = day.get('day')
-                for s in day.get('slots', []):
-                    labels = s.get('labels', [])
-                    if not labels:
-                        continue # 空数组说明 unavailable
-                    
-                    lbl = ""
-                    if 'pta' in labels: lbl = 'pta'
-                    elif 'ptaw' in labels: lbl = 'ptaw'
-                    elif '' in labels: lbl = ''
+        is_on_target_month = (current_month_text.lower() == target_month_text.lower())
+
+        if not is_on_target_month:
+            # ==========================================
+            # 模式 A: 不在目标月份 (UI 路由 + HTML 强鲁棒性解析)
+            # ==========================================
+            self._log(f"Current is '{current_month_text}', navigating to '{target_month_text}'...")
+            
+            for _ in range(12):
+                target_btn_xpath = f'xpath://a[contains(@href, "month={interest_month}")]'
+                target_btn = self.page.ele(target_btn_xpath)
+                
+                if target_btn:
+                    target_btn.click(by_js=True)
+                    time.sleep(3) 
+                    break
+                
+                next_btn = self.page.ele('@data-testid=btn-next-month-available')
+                if next_btn:
+                    next_btn.click(by_js=True)
+                    time.sleep(2) 
+                else:
+                    self._log("Warning: Cannot find target month or 'Next Month' button.")
+                    break
+
+            self._log("Extracting slots from DOM using robust data-testid features...")
+            
+            # 【高鲁棒性提取】
+            # 特征定义:寻找所有的“日期块”。
+            # 什么是一个日期块?它是一个 div,里面直接包含一个 p 标签(放日期),
+            # 且同时包含另一个 div,其内部有带有 'slot' 字样的按钮。
+            day_blocks_xpath = '//div[p and div//button[contains(@data-testid, "slot")]]'
+            day_blocks = self.page.eles(f'xpath:{day_blocks_xpath}')
+            
+            for block in day_blocks:
+                # 1. 提取日期:只要是这个 block 下的 p 标签,必定是 "Mon 01" 这种
+                p_ele = block.ele('tag:p')
+                if not p_ele: continue
+                
+                # 直接从 p 标签的纯文本里抽取出数字,忽略前面的字母
+                day_match = re.search(r'\d+', p_ele.text)
+                if not day_match: continue
+                day_str = day_match.group()
+                
+                full_date = f"{target_year}-{target_month_num:02d}-{int(day_str):02d}"
+                
+                # 2. 提取可用按钮:利用 data-testid 前缀匹配
+                # 完美过滤掉 btn-unavailable-slot (灰色的不可用按钮)
+                available_btns = block.eles('xpath:.//button[starts-with(@data-testid, "btn-available-slot")]')
+                
+                for btn in available_btns:
+                    # 提取时间:无视内部各种 span 的变动,只要 html 里有 00:00 这种格式就被截取
+                    time_match = re.search(r'\d{2}:\d{2}', btn.html)
+                    if not time_match: continue
+                    time_str = time_match.group()
                     
                     
+                    # 提取 Label:完全依赖测试工程师留下的 testid
+                    test_id = btn.attr('data-testid') or ""
+                    if 'prime' in test_id and 'weekend' in test_id:
+                        lbl = 'ptaw'
+                    elif 'prime' in test_id:
+                        lbl = 'pta'
+                    else:
+                        lbl = '' 
+                        
                     slots.append({
                     slots.append({
-                        'date': d_str,
-                        'time': s.get('time'),
+                        'date': full_date,
+                        'time': time_str,
                         'label': lbl
                         'label': lbl
                     })
                     })
+
+        else:
+            # ==========================================
+            # 模式 B: 已经在目标月份 (JS Fetch + 正则 JSON 解析)
+            # ==========================================
+            self._log(f"Already on '{target_month_text}'. Executing silent JS fetch...")
+            
+            base_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
+            params = {'location': apt_config["code"], 'month': interest_month}
+            query_url = f"{base_url}?{urlencode(params)}"
+            
+            js_script = f"""
+            return fetch("{query_url}", {{ credentials: "include" }})
+                .then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
+                .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
+            """
+            res_dict = self.page.run_js(js_script)
+            resp = BrowserResponse(res_dict)
+
+            if resp.status_code != 200:
+                raise Exception(f"Silent Query Failed: {resp.status_code}")
+            
+            self._log("Extracting slots from JSON response...")
+            pattern = r'"availableAppointments\\":\s*(\[.*?\]),\\"showFlexiAppointment'
+            match = re.search(pattern, resp.text, re.DOTALL)
+            
+            if match:
+                json_str = match.group(1).replace(r'\"', '"')
+                data = json.loads(json_str)
+                for day in data:
+                    d_str = day.get('day')
+                    for s in day.get('slots', []):
+                        labels = s.get('labels', [])
+                        if not labels: continue 
+                        
+                        lbl = ""
+                        if 'pta' in labels: lbl = 'pta'
+                        elif 'ptaw' in labels: lbl = 'ptaw'
+                        elif '' in labels: lbl = ''
+                        
+                        slots.append({
+                            'date': d_str,
+                            'time': s.get('time'),
+                            'label': lbl
+                        })
+
+        self._log(f"Found {len(slots)} valid slots.")
         return slots
         return slots
 
 
     def _filter_dates(self, available_dates: list, start_str: str, end_str: str) -> list:
     def _filter_dates(self, available_dates: list, start_str: str, end_str: str) -> list:
@@ -489,7 +415,7 @@ class TlsAutoBot:
             if s_date <= curr <= e_date:
             if s_date <= curr <= e_date:
                 valid.append(d)
                 valid.append(d)
         return valid
         return valid
-
+    
     def book(self, all_slots: list) -> bool:
     def book(self, all_slots: list) -> bool:
         """执行预定流程"""
         """执行预定流程"""
         if not all_slots:
         if not all_slots:
@@ -501,11 +427,10 @@ class TlsAutoBot:
         exp_start = self.config.get('expected_start_date', '')
         exp_start = self.config.get('expected_start_date', '')
         exp_end = self.config.get('expected_end_date', '')
         exp_end = self.config.get('expected_end_date', '')
 
 
-        # 提取唯一的可用日期列表
         unique_dates = list(set([s['date'] for s in all_slots]))
         unique_dates = list(set([s['date'] for s in all_slots]))
         valid_dates = self._filter_dates(unique_dates, exp_start, exp_end)
         valid_dates = self._filter_dates(unique_dates, exp_start, exp_end)
         
         
-        possible_slots = [
+        possible_slots =[
             s for s in all_slots 
             s for s in all_slots 
             if s['date'] in valid_dates and s['label'] in target_labels
             if s['date'] in valid_dates and s['label'] in target_labels
         ]
         ]
@@ -524,100 +449,262 @@ class TlsAutoBot:
 
 
         group_num = self.travel_group['formGroupId']
         group_num = self.travel_group['formGroupId']
         apt_config = self.config['apt_config']
         apt_config = self.config['apt_config']
-        
-        base_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
-        # [关键修复] Next.js Action 必须带上正确的 Query 参数
-        year, month, day = sel_date.split('-')
-        formatted_month = f"{month}-{year}"
-        
-        full_url = f'{base_url}?location={apt_config["code"]}&month={formatted_month}'
-        
+        current_url = self.page.url
         router_state = f'%5B%22%22%2C%7B%22children%22%3A%5B%5B%22lang%22%2C%22en-us%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%5B%22groupId%22%2C%22{group_num}%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%22workflow%22%2C%7B%22children%22%3A%5B%22appointment-booking%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D'
         router_state = f'%5B%22%22%2C%7B%22children%22%3A%5B%5B%22lang%22%2C%22en-us%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%5B%22groupId%22%2C%22{group_num}%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%22workflow%22%2C%7B%22children%22%3A%5B%22appointment-booking%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D'
 
 
-        # 3. 获取金额 (Basket Cost)
-        self._log("Fetching basket cost...")
-        cost_payload = [{"groupId": str(group_num), "lang": "en-us", "labels": [sel_label]}]
-        cost_body_json = json.dumps(cost_payload)
-        
-        js_cost = f"""
-        return fetch("{full_url}", {{
-            method: 'POST',
-            headers: {{
-                'Next-Action': '40124cc90acef520d4fd2daf60ad3c8e21fc2c11d8',
-                'Next-Router-State-Tree': '{router_state}',
-                'Accept': 'text/x-component',
-                'Content-Type': 'text/plain;charset=UTF-8'
-            }},
-            body: `{cost_body_json}`
-        }}).then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
-          .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
-        """
-        cost_res = BrowserResponse(self.page.run_js(js_cost))
-        if cost_res.status_code != 200:
-            self._log(f"Basket cost check failed: {cost_res.status_code}")
-            return False
+        # 3. 获取金额 (Basket Cost) 
+        # self._log("Fetching basket cost...")
+        # cost_payload =[{"groupId": str(group_num), "lang": "en-us", "labels": [sel_label]}]
+        # cost_body_json = json.dumps(cost_payload)
+        
+        # js_cost = f"""
+        # return fetch("{current_url}", {{
+        #     method: 'POST',
+        #     headers: {{
+        #         'Next-Action': '40124cc90acef520d4fd2daf60ad3c8e21fc2c11d8',
+        #         'Next-Router-State-Tree': '{router_state}',
+        #         'Accept': 'text/x-component',
+        #         'Content-Type': 'text/plain;charset=UTF-8'
+        #     }},
+        #     body: `{cost_body_json}`
+        # }}).then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
+        #   .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
+        # """
+        # cost_res = BrowserResponse(self.page.run_js(js_cost))
+        # if cost_res.status_code != 200:
+        #     self._log(f"Basket cost check failed: {cost_res.status_code}")
+        #     return False
 
 
         # 4. 解决 ReCaptcha V3
         # 4. 解决 ReCaptcha V3
-        self._log("Solving Booking ReCaptcha V3...")
-        g_token = self.solve_captcha(
-            page_url=full_url,
-            task_type="ReCaptchaV3Task",
-            site_key="6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
-            use_proxy=True,
-            action="book"
-        )
-
-        # 5. 提交 Booking
-        self._log("Submitting final booking request...")
-        js_book = f"""
-        const formData = new FormData();
-        formData.append('1_formGroupId', '{group_num}');
-        formData.append('1_lang', 'en-us');
-        formData.append('1_process', 'APPOINTMENT');
-        formData.append('1_location', '{apt_config["code"]}');
-        formData.append('1_date', '{sel_date}');
-        formData.append('1_time', '{sel_time}');
-        formData.append('1_appointmentLabel', '{sel_label}');
-        formData.append('1_captchaToken', '{g_token}');
-        formData.append('0', '[{{"status":"IDLE"}},"$K1"]');
-        
-        return fetch("{full_url}", {{
-            method: 'POST',
-            headers: {{
-                'Next-Action': '6043cfd107081bc817cbb11a8c0db17d3a063401be',
-                'Next-Router-State-Tree': '{router_state}',
-                'Accept': 'text/x-component'
-            }},
-            body: formData
-        }}).then(async r => {{
-            const hdrs = {{}};
-            r.headers.forEach((v, k) => hdrs[k] = v);
-            return {{ status: r.status, body: await r.text(), headers: hdrs, url: r.url }};
-        }}).catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
+        # self._log("Solving Booking ReCaptcha V3...")
+        # g_token = self.solve_captcha(
+        #     page_url=current_url,
+        #     task_type="ReCaptchaV3TaskProxyLess",
+        #     site_key="6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
+        #     use_proxy=False,
+        #     action="book",
+        #     api_domain="recaptcha.net"
+        # )
+
+        # 5. 注入 Hook 并点击表单
+        # self._log("Injecting reCAPTCHA hook and modifying form...")
+        
+        # # 5.1 注入你提供的劫持 JS
+        # hook_js = f"""
+        #     // 1. 填充可能存在的标准隐藏域
+        #     var input = document.getElementById('g-recaptcha-response');
+        #     if(input) {{
+        #         input.value = "{g_token}";
+        #         // 派发React识别的事件
+        #         input.dispatchEvent(new Event('input', {{ bubbles: true }}));
+        #         input.dispatchEvent(new Event('change', {{ bubbles: true }}));
+        #     }}
+
+        #     // 2. 劫持 grecaptcha.execute
+        #     var mockExecute = function() {{
+        #         console.log("[Hook] Recaptcha execution intercepted!");
+        #         return Promise.resolve("{g_token}");
+        #     }};
+
+        #     // 无论网页是否已加载完毕,保证对象存在并被劫持
+        #     if (!window.grecaptcha) {{
+        #         window.grecaptcha = {{}};
+        #     }}
+        #     window.grecaptcha.execute = mockExecute;
+            
+        #     if (!window.grecaptcha.enterprise) {{
+        #         window.grecaptcha.enterprise = {{}};
+        #     }}
+        #     window.grecaptcha.enterprise.execute = mockExecute;
+        # """
+        # self.page.run_js(hook_js)
+
+        # 5.2 注入表单数据并原样点击
+        js_inject_and_click = f"""
+        try {{
+            const form = document.querySelector('form');
+            if (!form) return 'Form not found';
+
+            function setReactValue(input, value) {{
+                if (!input) return;
+                input.value = value;
+                input.dispatchEvent(new Event('input', {{ bubbles: true }}));
+                input.dispatchEvent(new Event('change', {{ bubbles: true }}));
+            }}
+
+            // 填入抢单日期、时间、标签
+            setReactValue(form.querySelector('input[name="date"]'), '2026-06-01');
+            setReactValue(form.querySelector('input[name="time"]'), '12:00');
+            setReactValue(form.querySelector('input[name="appointmentLabel"]'), '{sel_label}');
+
+            // 解禁并点击 Submit 按钮
+            const submitBtn = form.querySelector('button[type="submit"]');
+            if (submitBtn) {{
+                submitBtn.removeAttribute('disabled');
+                submitBtn.classList.remove('opacity-50', 'cursor-not-allowed'); 
+                submitBtn.click();
+                return 'clicked';
+            }} else {{
+                return 'Submit button not found';
+            }}
+        }} catch (e) {{
+            return e.toString();
+        }}
         """
         """
         
         
-        book_res_dict = self.page.run_js(js_book)
-        book_resp = BrowserResponse(book_res_dict)
+        inject_res = self.page.run_js(js_inject_and_click)
+        self._log(f"Form submission triggered: {inject_res}")
+        
+        if inject_res != 'clicked':
+            self._log("❌ Failed to inject form or click the submit button.")
+            return False
+
+        # 6. 验证是否抢单成功 (轮询页面跳转)
+        self._log("Waiting for Next.js to process the form submission...")
+        
+        for _ in range(15):
+            time.sleep(1.0)
+            current_page_url = self.page.url
+            
+            # Next.js 跳转进入确认页
+            if "appointment-confirmation" in current_page_url:
+                self._log(f"✅ BOOKING SUCCESS! Redirected to: {current_page_url}")
+                return True
+                
+            try:
+                body_text = str(self.page.run_js("return document.body.innerText || '';"))
+                if "APPOINTMENT_LIMIT_REACHED" in body_text or "appointment limit" in body_text.lower():
+                    self._log("❌ BOOKING FAILED! Reason: Appointment Limit Reached")
+                    return False
+            except Exception:
+                pass 
+                
+        self._log("❌ BOOKING FAILED! Timeout waiting for redirect confirmation.")
+        return False
+
+    # def book(self, all_slots: list) -> bool:
+    #     """执行预定流程"""
+    #     if not all_slots:
+    #         self._log("No slots provided to book.")
+    #         return False
+
+    #     # 1. 过滤日期 & 筛选标签
+    #     target_labels = self.config.get('target_labels', [''])
+    #     exp_start = self.config.get('expected_start_date', '')
+    #     exp_end = self.config.get('expected_end_date', '')
+
+    #     # 提取唯一的可用日期列表
+    #     unique_dates = list(set([s['date'] for s in all_slots]))
+    #     valid_dates = self._filter_dates(unique_dates, exp_start, exp_end)
+        
+    #     possible_slots = [
+    #         s for s in all_slots 
+    #         if s['date'] in valid_dates and s['label'] in target_labels
+    #     ]
+
+    #     if not possible_slots:
+    #         self._log("No slots match target dates and labels.")
+    #         return False
+
+    #     # 2. 随机选择一个 Slot
+    #     selected = random.choice(possible_slots)
+    #     sel_date = selected['date']
+    #     sel_time = selected['time']
+    #     sel_label = selected['label']
         
         
-        # 6. 解析结果 (判定 Next.js 跳转)
-        headers_lower = {str(k).lower(): v for k, v in book_resp.headers.items()}
-        action_redirect = headers_lower.get('x-action-redirect', '')
+    #     self._log(f"Selected Slot -> Date: {sel_date}, Time: {sel_time}, Label: {sel_label or 'standard'}")
 
 
-        is_success = (
-            book_resp.status_code == 303 or 
-            (book_resp.status_code == 200 and ("appointment-confirmation" in action_redirect or "appointment-confirmation" in book_resp.url))
-        )
+    #     group_num = self.travel_group['formGroupId']
+    #     apt_config = self.config['apt_config']
+        
+    #     current_url = self.page.url
+    #     router_state = f'%5B%22%22%2C%7B%22children%22%3A%5B%5B%22lang%22%2C%22en-us%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%5B%22groupId%22%2C%22{group_num}%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%22workflow%22%2C%7B%22children%22%3A%5B%22appointment-booking%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D'
 
 
-        if is_success:
-            self._log(f"✅ BOOKING SUCCESS! Redirected to: {action_redirect or book_resp.url}")
-            return True
-        else:
-            self._log(f"❌ BOOKING FAILED! Status: {book_resp.status_code}")
-            if "APPOINTMENT_LIMIT_REACHED" in book_resp.text:
-                self._log("-> Reason: Appointment Limit Reached")
-            else:
-                self._log(f"-> Response Body: {book_resp.text[:300]}")
-            return False
+    #     # 3. 获取金额 (Basket Cost)
+    #     self._log("Fetching basket cost...")
+    #     cost_payload = [{"groupId": str(group_num), "lang": "en-us", "labels": [sel_label]}]
+    #     cost_body_json = json.dumps(cost_payload)
+        
+    #     js_cost = f"""
+    #     return fetch("{current_url}", {{
+    #         method: 'POST',
+    #         headers: {{
+    #             'Next-Action': '40124cc90acef520d4fd2daf60ad3c8e21fc2c11d8',
+    #             'Next-Router-State-Tree': '{router_state}',
+    #             'Accept': 'text/x-component',
+    #             'Content-Type': 'text/plain;charset=UTF-8'
+    #         }},
+    #         body: `{cost_body_json}`
+    #     }}).then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
+    #       .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
+    #     """
+    #     cost_res = BrowserResponse(self.page.run_js(js_cost))
+    #     if cost_res.status_code != 200:
+    #         self._log(f"Basket cost check failed: {cost_res.status_code}")
+    #         return False
+
+    #     # 4. 解决 ReCaptcha V3
+    #     self._log("Solving Booking ReCaptcha V3...")
+    #     g_token = self.solve_captcha(
+    #         page_url=current_url,
+    #         task_type="ReCaptchaV3M1TaskProxyLess",
+    #         site_key="6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
+    #         use_proxy=False,
+    #         action="book",
+    #         api_domain="recaptcha.net"
+    #     )
+
+    #     # 5. 提交 Booking
+    #     self._log("Submitting final booking request...")
+    #     js_book = f"""
+    #     const formData = new FormData();
+    #     formData.append('1_formGroupId', '{group_num}');
+    #     formData.append('1_lang', 'en-us');
+    #     formData.append('1_process', 'APPOINTMENT');
+    #     formData.append('1_location', '{apt_config["code"]}');
+    #     formData.append('1_date', '{sel_date}');
+    #     formData.append('1_time', '{sel_time}');
+    #     formData.append('1_appointmentLabel', '{sel_label}');
+    #     formData.append('1_captchaToken', '{g_token}');
+    #     formData.append('0', '[{{"status":"IDLE"}},"$K1"]');
+        
+    #     return fetch("{current_url}", {{
+    #         method: 'POST',
+    #         headers: {{
+    #             'Next-Action': '6043cfd107081bc817cbb11a8c0db17d3a063401be',
+    #             'Next-Router-State-Tree': '{router_state}',
+    #             'Accept': 'text/x-component'
+    #         }},
+    #         body: formData
+    #     }}).then(async r => {{
+    #         const hdrs = {{}};
+    #         r.headers.forEach((v, k) => hdrs[k] = v);
+    #         return {{ status: r.status, body: await r.text(), headers: hdrs, url: r.url }};
+    #     }}).catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
+    #     """
+        
+    #     book_res_dict = self.page.run_js(js_book)
+    #     book_resp = BrowserResponse(book_res_dict)
+        
+    #     # 6. 解析结果 (判定 Next.js 跳转)
+    #     headers_lower = {str(k).lower(): v for k, v in book_resp.headers.items()}
+    #     action_redirect = headers_lower.get('x-action-redirect', '')
+
+    #     is_success = (
+    #         book_resp.status_code == 303 or 
+    #         (book_resp.status_code == 200 and ("appointment-confirmation" in action_redirect or "appointment-confirmation" in book_resp.url))
+    #     )
+
+    #     if is_success:
+    #         self._log(f"✅ BOOKING SUCCESS! Redirected to: {action_redirect or book_resp.url}")
+    #         return True
+    #     else:
+    #         self._log(f"❌ BOOKING FAILED! Status: {book_resp.status_code}")
+    #         if "APPOINTMENT_LIMIT_REACHED" in book_resp.text:
+    #             self._log("-> Reason: Appointment Limit Reached")
+    #         else:
+    #             self._log(f"-> Response Body: {book_resp.text[:300]}")
+    #         return False
 
 
     def cleanup(self):
     def cleanup(self):
         self._log("Cleaning up resources...")
         self._log("Cleaning up resources...")
@@ -637,7 +724,7 @@ if __name__ == "__main__":
     MY_CONFIG = {
     MY_CONFIG = {
         # 账号信息
         # 账号信息
         "account": {
         "account": {
-            "username": "zhangsan06@gmail-app.com",
+            "username": "mayun06@gmail-app.com",
             "password": "Visafly@111"
             "password": "Visafly@111"
         },
         },
         # 目标签证中心信息 (例如广州 TLS: cnCNG2fr)
         # 目标签证中心信息 (例如广州 TLS: cnCNG2fr)
@@ -648,11 +735,11 @@ if __name__ == "__main__":
         },
         },
         # 代理配置
         # 代理配置
         "proxy": {
         "proxy": {
-            "scheme": "http",
-            "ip": "95.135.130.10",   
-            "port": "46107",      
-            "username": "Iz1WuKKwt1KUzEe",      
-            "password": "G7syngmdyGURblY"
+            "schema": "http",
+            "ip": "127.0.0.1",   
+            "port": "7890",      
+            "username": "",      
+            "password": ""
         },
         },
         # Capsolver API Key
         # Capsolver API Key
         "capsolver_key": "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A",
         "capsolver_key": "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A",
@@ -665,7 +752,7 @@ if __name__ == "__main__":
         "expected_end_date": "2026-06-30",
         "expected_end_date": "2026-06-30",
         
         
         # 目标标签: '' 是普通号, 'pta' 是 Prime 黄金时间号
         # 目标标签: '' 是普通号, 'pta' 是 Prime 黄金时间号
-        "target_labels": ["", "pta"] 
+        "target_labels": [""] 
     }
     }
 
 
     bot = TlsAutoBot(config=MY_CONFIG)
     bot = TlsAutoBot(config=MY_CONFIG)

+ 1 - 0
vs_types.py

@@ -188,6 +188,7 @@ class VSBookResult(BaseModel):
     session_id: str = ""
     session_id: str = ""
     urn: str = ""
     urn: str = ""
     account: str = ""
     account: str = ""
+    label: str = ""
     book_date: str = ""
     book_date: str = ""
     book_time: str = ""
     book_time: str = ""
     payment_link: str = ""
     payment_link: str = ""