Răsfoiți Sursa

feat: update

jerry 4 săptămâni în urmă
părinte
comite
beaca00742
4 a modificat fișierele cu 705 adăugiri și 616 ștergeri
  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 uuid
 import shutil
+import socket
 from datetime import datetime
 from typing import List, Dict, Optional, Any, Callable
 from urllib.parse import urljoin, urlparse, urlencode
@@ -50,25 +51,17 @@ class TlsPlugin(IVSPlg):
         self.is_healthy = True
         self.logger = None
         
-        # 浏览器实例
-        self.page: Optional[ChromiumPage] = None
-        
+        self.page: Optional[ChromiumPage] = None    
         self.travel_group: Optional[Dict] = None
         
-        # --- [核心修改] 并发隔离与资源管理 ---
-        # 生成唯一实例 ID
         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.user_data_path = os.path.join(self.root_workspace, "user_data")
     
-        # 确保根目录存在 (子目录由具体逻辑创建)
         if not os.path.exists(self.root_workspace):
             os.makedirs(self.root_workspace)
             
-        # 持有隧道实例
         self.tunnel = None
-        
         self.session_create_time: float = 0
 
     def get_group_id(self) -> str:
@@ -115,11 +108,7 @@ class TlsPlugin(IVSPlg):
             filename = f"{self.instance_id}_{name_prefix}_{timestamp}.jpg"
             save_path = os.path.join("data", filename)
             os.makedirs("data", exist_ok=True)
-            
-            # [修改] 改为 full_page=False,防止页面结构异常导致截图失败
-            # 这样能截取到浏览器当前可视区域,最适合调试“卡住”的情况
             self.page.get_screenshot(path=save_path, full_page=False)
-            
             self._log(f"Screenshot saved to {save_path}")
         except Exception as 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})...")
         co = ChromiumOptions()
-        # -------------------------------------------------------------
-        # [核心修复] 解决 'not enough values to unpack'
-        # -------------------------------------------------------------
-        # 1. 不要用 co.auto_port(),因为它依赖解析 stdout,会被 DBus 报错干扰
-        # 2. 我们手动随机生成一个端口
-        import random
-        import socket
         
         def get_free_port():
             with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -146,38 +128,26 @@ class TlsPlugin(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_local_port(debug_port)       
         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:
@@ -186,7 +156,6 @@ class TlsPlugin(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')
@@ -210,29 +179,24 @@ class TlsPlugin(IVSPlg):
             self._log(f"Navigating: {full_login_url}")
             self.page.get(full_login_url)
             
-            # --- Cloudflare 过盾 ---
             cf = CloudflareBypasser(self.page, log=self.config.debug)
             if not cf.bypass(max_retry=15):
                 raise BizLogicError("Cloudflare bypass timeout")
             
             wait_start = time.time()
             while True:
-                # 获取页面 HTML,转小写
-                # 注意:如果此处报错 "页面被刷新",是 DrissionPage 的机制问题,
-                # 但你要求先不处理复杂错误,所以这里保持最简单的写法。
                 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.")
-                    time.sleep(30) # 截图说页面会自动刷新,所以这里只sleep,不动浏览器
+                    time.sleep(30)
                 else:
-                    # 页面里没有“等候室”的字了,说明出来了
                     break
 
             # --- 登录页面检查 ---
@@ -248,7 +212,8 @@ class TlsPlugin(IVSPlg):
             if self.page.ele('.g-recaptcha') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]'):
                 self._log("Solving ReCaptcha...")
                 rc_params = {
-                    "type": "ReCaptchaV2TaskProxyLess", "page": self.page.url,
+                    "type": "ReCaptchaV2TaskProxyLess",
+                    "page": self.page.url,
                     "siteKey": "6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0", 
                     "apiToken": self.free_config.get("capsolver_key", "")
                 }
@@ -257,7 +222,6 @@ class TlsPlugin(IVSPlg):
             username = self.config.account.username
             password = self.config.account.password
             
-            # 使用 JS 直接操作 DOM 并 click,让浏览器处理 302
             js_login = f"""
             var u = document.getElementById('email-input-field');
             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...")
-            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.page.wait.url_change('login-actions', exclude=True, timeout=45)
             
             time.sleep(3)
-            # 检查是否失败
             if "login-actions" in self.page.url or "auth" in self.page.url:
                 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")
                 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()
             for g in groups:
                 if g['location'].lower() == target_city:
@@ -305,9 +266,46 @@ class TlsPlugin(IVSPlg):
             if not self.travel_group:
                 self._save_screenshot("group_not_found")
                 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._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:
             self._log(f"Session Create Error: {e}")
@@ -317,47 +315,118 @@ class TlsPlugin(IVSPlg):
     def query(self, apt_type: AppointmentType) -> VSQueryResult:
         res = VSQueryResult()
         res.success = False
-        apt_config = self.free_config.get('apt_config', {})
         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"))
         
-        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"])
-        # 根据配置过滤
-        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
-            earliest_date = available[0]["date"]
+            earliest_date = slots[0]["date"]
             earliest_dt = datetime.strptime(earliest_date, "%Y-%m-%d")
             res.availability_status = AvailabilityStatus.Available
             res.earliest_date = earliest_dt
             date_map: dict[datetime, list[TimeSlot]] = {}
-            for s in available:
+            for s in slots:
                 date_str = s["date"]
                 dt = datetime.strptime(date_str, "%Y-%m-%d")
                 date_map.setdefault(dt, []).append(
                     TimeSlot(time=s["time"], label=str(s.get("label", "")))
                 )
             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:
             self._log("No slots available.")
             res.success = False
@@ -368,9 +437,6 @@ class TlsPlugin(IVSPlg):
         res = VSBookResult()
         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_end = user_inputs.get('expected_end_date', '')
         support_pta = user_inputs.get('support_pta', True)
@@ -379,15 +445,11 @@ class TlsPlugin(IVSPlg):
         if support_pta:
             target_labels.append('pta')
 
-        # 获取所有可用的日期字符串用于过滤
         available_dates_str =[
             da.date.strftime("%Y-%m-%d")
             for da in slot_info.availability if da.date
         ]
         
-        # ---------------------------------------------------------
-        # 第一步:过滤出符合用户日期范围要求的日期,并随机选择一个 slot
-        # ---------------------------------------------------------
         valid_dates_list = self._filter_dates(available_dates_str, exp_start, exp_end)
         if not valid_dates_list:
             raise NotFoundError(message="No dates match user constraints")
@@ -412,142 +474,76 @@ class TlsPlugin(IVSPlg):
 
         selected_slot = random.choice(all_possible_slots)
         selected_date = selected_slot["date"]
-        selected_time = selected_slot["time_obj"]  # TimeSlot 对象
+        selected_time = selected_slot["time_obj"]
         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
     
     def _get_proxy_url(self):
@@ -629,29 +625,20 @@ class TlsPlugin(IVSPlg):
             self.is_healthy = False
             raise SessionExpiredOrInvalidError()
         elif resp.status_code == 403:
-            # [关键修改] 遇到 403 Forbidden,尝试绕盾并重试
-            # 最多重试 2 次
             if retry_count < 2:
                 self._log(f"HTTP 403 Detected. Cloudflare session expired? Attempting refresh (Try {retry_count+1}/2)...")
-                
-                # 尝试刷新盾
                 if self._refresh_firewall_session():
                     self._log("Firewall session refreshed. Retrying request...")
-                    # 递归重试
                     return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
                 else:
-                    self._log("Failed to refresh firewall session.")
-            
-            # 如果重试失败,抛出异常
+                    self._log("Failed to refresh firewall session.")    
             raise PermissionDeniedError(f"HTTP 403: {resp.text[:100]}")
         elif resp.status_code == 429:
             self.is_healthy = False
             raise RateLimiteddError()
         else:
-             # 如果是 0,可能是 fetch 报错
             if resp.status_code == 0:
                  raise BizLogicError(f"Network Error: {resp.text}")
-            # TLS 业务错误
             raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
     
     def _refresh_firewall_session(self) -> bool:
@@ -727,10 +714,10 @@ class TlsPlugin(IVSPlg):
             time.sleep(3)
         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 = []
         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:
             json_str = js_match.group(1).replace(r'\"', '"')
             data = json.loads(json_str)
@@ -744,10 +731,10 @@ class TlsPlugin(IVSPlg):
             self._log('Parsed travel group page, but not found travelGroups')
         return groups
 
-    def _parse_appointment_slots(self, html: str) -> List[Dict]:
+    def _parse_appointment_slots(self, html_content) -> List[Dict]:
         slots = []
         pattern = r'"availableAppointments\\":\s*(\[.*\]),\\"showFlexiAppointment'
-        match = re.search(pattern, html, re.DOTALL)
+        match = re.search(pattern, html_content, re.DOTALL)
         
         if match:
             json_str = match.group(1).replace(r'\"', '"')
@@ -774,18 +761,16 @@ class TlsPlugin(IVSPlg):
             self.is_healthy = False
             raise SessionExpiredOrInvalidError()
         
-        # 将 html 转小写检查
         html_lower = 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
-                raise SessionExpiredOrInvalidError("Session expired")
+                raise SessionExpiredOrInvalidError()
             
     def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
         if not start_str or not end_str:

+ 45 - 29
test/test_capsolver.py

@@ -1,6 +1,7 @@
 import requests
 import time
 import json
+from urllib.parse import urlencode
 
 class CaptchaTester:
     def __init__(self, api_key):
@@ -11,8 +12,8 @@ class CaptchaTester:
     def _log(self, 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')
         if not capsolver_key:
             raise ValueError("Capsolver API key missing")
@@ -21,11 +22,9 @@ class CaptchaTester:
             "type": task_type,
             "websiteURL": page_url,
             "websiteKey": site_key,
+            "pageAction": action
         }
         
-        if action:
-            task["pageAction"] = action
-
         payload = {"clientKey": capsolver_key, "task": task}
         
         # 创建任务
@@ -35,7 +34,7 @@ class CaptchaTester:
             raise Exception(f"创建任务失败: {res.text}")
 
         task_id = resp_json.get("taskId")
-        self._log(f"任务已创建: {task_id}. 正在等待结果...")
+        self._log(f"任务已创建: {task_id}. 正在等待高分 Token...")
 
         # 轮询结果
         for i in range(20):
@@ -49,54 +48,71 @@ class CaptchaTester:
                 self._log("验证码解决成功!")
                 return data["solution"].get("gRecaptchaResponse") or data["solution"].get("token")
             
-            self._log(f"等待中... ({i+1}次)")
             time.sleep(3)
         raise Exception("Capsolver 任务超时")
 
     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:
-            # 2. 获取 Token
+            # 2. 从 Capsolver 获取 Token
             token = self.solve_captcha(
                 page_url=test_page,
-                task_type="ReCaptchaV3TaskProxyLess", # 或者使用 ReCaptchaV3Task
+                task_type="ReCaptchaV3TaskProxyLess", 
                 site_key=site_key,
                 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 = {
-                '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:
                 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:
-                print(f"评分请求失败: {response.status_code}, {response.text}")
+                print(f"验证请求失败: {response.status_code}, {response.text}")
 
         except Exception as e:
             print(f"发生错误: {e}")
 
 # --- 运行测试 ---
 if __name__ == "__main__":
+    # 请确保你的 API Key 正确
     MY_CAPSOLVER_KEY = "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A"
     tester = CaptchaTester(MY_CAPSOLVER_KEY)
     tester.test_score()

+ 430 - 343
test/tls_standalone.py

@@ -17,197 +17,6 @@ from urllib.parse import urlencode
 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:
     """模拟 requests.Response 的轻量级对象"""
     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.page = None
         self.travel_group = None
-        self.tunnel = None
 
     def _log(self, msg):
         print(f"[TLS-Bot-{self.instance_id}] {msg}")
@@ -249,24 +57,9 @@ class TlsAutoBot:
         # 2. 代理配置
         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. 反爬配置
         co.headless(False)
@@ -278,7 +71,7 @@ class TlsAutoBot:
 
         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 防止被盾识别为高风险)"""
         capsolver_key = self.config.get('capsolver_key')
         if not capsolver_key:
@@ -289,6 +82,10 @@ class TlsAutoBot:
             "websiteURL": page_url,
             "websiteKey": site_key,
         }
+        
+        if api_domain:
+            task["apiDomain"] = api_domain
+            
         if use_proxy:
             proxy = self.config['proxy']
             task["proxyType"] = proxy.get('scheme', 'http')
@@ -297,6 +94,7 @@ class TlsAutoBot:
             if proxy.get('username'):
                 task["proxyLogin"] = proxy.get('username')
                 task["proxyPassword"] = proxy.get('password')
+                
         if action:
             task["pageAction"] = action
 
@@ -425,57 +223,185 @@ class TlsAutoBot:
         
         if not self.travel_group:
             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:
-        """通过 JS Fetch 获取可用日期并解析"""
-        self._log("Querying available slots...")
+        """根据当前 UI 状态自动判断路由,并使用高鲁棒性特征提取 Slot"""
         group_num = self.travel_group['formGroupId']
         apt_config = self.config['apt_config']
         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({
-                        'date': d_str,
-                        'time': s.get('time'),
+                        'date': full_date,
+                        'time': time_str,
                         '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
 
     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:
                 valid.append(d)
         return valid
-
+    
     def book(self, all_slots: list) -> bool:
         """执行预定流程"""
         if not all_slots:
@@ -501,11 +427,10 @@ class TlsAutoBot:
         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 = [
+        possible_slots =[
             s for s in all_slots 
             if s['date'] in valid_dates and s['label'] in target_labels
         ]
@@ -524,100 +449,262 @@ class TlsAutoBot:
 
         group_num = self.travel_group['formGroupId']
         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'
 
-        # 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
-        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):
         self._log("Cleaning up resources...")
@@ -637,7 +724,7 @@ if __name__ == "__main__":
     MY_CONFIG = {
         # 账号信息
         "account": {
-            "username": "zhangsan06@gmail-app.com",
+            "username": "mayun06@gmail-app.com",
             "password": "Visafly@111"
         },
         # 目标签证中心信息 (例如广州 TLS: cnCNG2fr)
@@ -648,11 +735,11 @@ if __name__ == "__main__":
         },
         # 代理配置
         "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_key": "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A",
@@ -665,7 +752,7 @@ if __name__ == "__main__":
         "expected_end_date": "2026-06-30",
         
         # 目标标签: '' 是普通号, 'pta' 是 Prime 黄金时间号
-        "target_labels": ["", "pta"] 
+        "target_labels": [""] 
     }
 
     bot = TlsAutoBot(config=MY_CONFIG)

+ 1 - 0
vs_types.py

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