jerry hace 3 meses
padre
commit
b3cad754d1
Se han modificado 9 ficheros con 1311 adiciones y 281 borrados
  1. 42 3
      config/groups.json
  2. 6 7
      gco.py
  3. 475 0
      plugins/grc_plugin.py
  4. 36 22
      plugins/ita_plugin.py
  5. 0 249
      toolkit/account_manager.py
  6. 18 0
      toolkit/vs_cloud_api.py
  7. 111 0
      upload_accounts.py
  8. 133 0
      utils/mouse_helper.py
  9. 490 0
      vfs_registration_bot.py

+ 42 - 3
config/groups.json

@@ -783,7 +783,7 @@
     {
         "identifier": "TLS_GB_FR",
         "debug": false,
-        "enable": true,
+        "enable": false,
         "need_account": true,
         "local_account_pool": "gb_fr",
         "need_proxy": true,
@@ -882,10 +882,10 @@
         "need_account": true,
         "local_account_pool": "ie_it",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "local",
         "proxy_lock_interval": 5,
         "target_instances": 1,
-        "account_login_interval": 30,
+        "account_login_interval": 0,
         "order_account_routing": "",
         "order_account_online_limit": 0,
         "account_bind_applicant": false,
@@ -915,5 +915,44 @@
         "free_config": {
             "capsolver_key": "03db1d1ff2f4a33e84ef1da99bd83336bed3710153525"
         }
+    },
+    {
+        "identifier": "GreekEmbassy_IE_GR",
+        "debug": false,
+        "enable": true,
+        "need_account": true,
+        "local_account_pool": "ie_gr",
+        "need_proxy": true,
+        "proxy_pool": "isp_proxy",
+        "proxy_lock_interval": 5,
+        "target_instances": 1,
+        "account_login_interval": 1,
+        "order_account_routing": "",
+        "order_account_online_limit": 0,
+        "account_bind_applicant": false,
+        "session_max_life": 15,
+        "query_wait": {
+            "mode": "Random",
+            "fixed_wait": 10,
+            "random_min": 60,
+            "random_max": 300
+        },
+        "plugin_config": {
+            "lib_path": "plugins",
+            "plugin_name": "grc_plugin",
+            "plugin_bin": "grc_plugin.py",
+            "plugin_proto": "IVSPlg"
+        },
+        "appointment_types": [
+            {
+                "weight": 10,
+                "routing_key": "slot.dub.gr.tourist",
+                "city": "Dublin",
+                "visa_type": "Tourist",
+                "country": "Greece"
+            }
+        ],
+        "website": "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas",
+        "free_config": {}
     }
 ]

+ 6 - 7
gco.py

@@ -11,7 +11,6 @@ from concurrent.futures import wait
 # 导入所有依赖
 from vs_types import GroupConfig, QueryWaitMode, VSPlgConfig, VSQueryResult, Task 
 from vs_plg_factory import VSPlgFactory 
-from toolkit.account_manager import AccountManager 
 from toolkit.proxy_manager import ProxyManager 
 from toolkit.thread_pool import ThreadPool 
 from toolkit.vs_cloud_api import VSCloudApi
@@ -162,11 +161,11 @@ class GCO:
                         self._log(f"🔥 Slot Found by [{task.instance.get_group_id()}]! Triggering BATCH BOOKING for {len(tasks_to_process)} workers.")
                        
                         # 上报Slot Snapshot
-                        # query_payload = result.to_snapshot_payload()
-                        # query_payload["website"] = self.m_cfg.website
-                        # query_payload["snapshot_source"] = 'worker'
-                        # query_payload["snapshot_at"] = datetime.now(timezone.utc).isoformat()
-                        # VSCloudApi.Instance().slot_snapshot_report(query_payload)
+                        query_payload = result.to_snapshot_payload()
+                        query_payload["website"] = self.m_cfg.website
+                        query_payload["snapshot_source"] = 'worker'
+                        query_payload["snapshot_at"] = datetime.now(timezone.utc).isoformat()
+                        VSCloudApi.Instance().slot_snapshot_report(query_payload)
                         
                         # === [核心修改]:一人发现,全员出击 ===
                         # 1. 准备并发任务
@@ -382,7 +381,7 @@ class GCO:
             # A. 优先补充内置账号 (只要 target_instances 还有缺口)
             if need_builtin:
                 # 获取并锁定账号
-                account = AccountManager.Instance().next(
+                account = VSCloudApi.Instance().get_next_account(
                     pool_name, 
                     lock_duration=self.m_cfg.account_login_interval * 60
                 )

+ 475 - 0
plugins/grc_plugin.py

@@ -0,0 +1,475 @@
+import time
+import json
+import random
+import re
+import os
+from datetime import datetime, timezone, timedelta
+from typing import List, Dict, Any, Callable, Optional
+
+from curl_cffi import requests, const
+
+from vs_plg import IVSPlg
+from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
+
+
+class GrcPlugin(IVSPlg):
+    """
+    https://www.supersaas.com/schedule/login/GreekEmbassyInDublin/Visas
+    签证预约插件
+    适配爱尔兰希腊签证 (GR) 流程
+    """
+
+    def __init__(self, group_id: str):
+        self.group_id = group_id
+        self.config: Optional[VSPlgConfig] = None
+        self.free_config: Dict[str, Any] = {}
+        self.is_healthy = True
+        self.logger = None
+        self.session: Optional[requests.Session] = None
+        self.resource_id = '1123832'
+        self.session_create_time: float = 0
+
+    def get_group_id(self) -> str:
+        return self.group_id
+    
+    def set_log(self, logger: Callable[[str], None]) -> None:
+        self.logger = logger
+
+    def set_config(self, config: VSPlgConfig):
+        self.config = config
+        self.free_config = config.free_config or {}
+
+    def health_check(self) -> bool:
+        if not self.is_healthy:
+            return False
+        if self.session is None:
+            return False
+        if self.config.session_max_life > 0:
+            current_time = time.time()
+            elapsed_time = current_time - self.session_create_time
+            if elapsed_time > self.config.session_max_life * 60:
+                self._log(f"Session Life ({int(elapsed_time)}s) out of max life limit ({self.config.session_max_life * 60}s), mark as unhealth session")
+                return False
+        return True
+
+    def create_session(self):   
+        # 1. 初始化 Session
+        curlopt = {
+            const.CurlOpt.MAXAGE_CONN: 1800,
+            const.CurlOpt.MAXLIFETIME_CONN: 1800,
+            const.CurlOpt.VERBOSE: self.config.debug,
+        }
+        
+        self.session = requests.Session(
+            proxy=self._get_proxy_url(),
+            impersonate="chrome124",
+            curl_options=curlopt,
+            use_thread_local_curl=False,
+            http_version=const.CurlHttpVersion.V2TLS
+        )
+
+        login_url = "https://www.supersaas.com/schedule/login/GreekEmbassyInDublin/Visas"
+        self._perform_request("GET", login_url)
+        headers = {
+            "Referer": login_url,
+            "Origin": "https://www.supersaas.com",
+            "Content-Type": "application/x-www-form-urlencoded"
+        }
+        
+        data = {
+            "name": self.config.account.username,
+            "password": self.config.account.password,
+            "remember": "K",
+            "cookie_fix": "1",
+            "button": ""
+        }
+        
+        resp = self._perform_request('POST', login_url, headers=headers, data=data)
+        if "Sign out" in resp.text or "Signed in as" in resp.text:
+            self.session_create_time = time.time()
+            self._log(f"Session created successfully. (User: {self.config.account.username})")
+        
+        # 如果登录失败,SuperSaaS 通常会留在当前页面并显示错误信息
+        elif "Invalid email or password" in resp.text:
+            self._save_debug_html(resp.text, prefix='login_auth_fail')
+            raise BizLogicError(message='Login failed: Invalid email or password')
+            
+        else:
+            # 其他未知错误
+            self._save_debug_html(resp.text, prefix='login_unknown_fail')
+            # 打印 URL 辅助调试,看是否跳转了
+            self._log(f"Login check failed. Current URL: {resp.url}")
+            raise BizLogicError(message='Login failed: Unknown response')
+        
+    def _get_daily_schedule(self, open_times, date_obj):
+        """根据 open_times 获取当天的开始和结束分钟数"""
+        if not open_times:
+            return None, None
+        weekday_py = date_obj.weekday()
+        if weekday_py >= 4: 
+            return None, None
+        js_day_index = (date_obj.weekday() + 1) % 7 
+        start_min = open_times[js_day_index]
+        end_min = open_times[js_day_index + 7]
+        return start_min, end_min
+    
+    def _is_blocked_by_ecache(self, ecache_blocked, timestamp):
+        """检查某个时间点是否在临时关闭范围内 (如节假日)"""
+        for block in ecache_blocked:
+            if block[0] <= timestamp < block[1]:
+                return True
+        return False
+
+    def query(self, apt_type: AppointmentType) -> VSQueryResult:
+        res = VSQueryResult()
+        res.success = False
+        
+        url = "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas"
+        headers = {
+            "Referer": "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas",
+            "Origin": "https://www.supersaas.com",
+        }
+
+        resp = self._perform_request("GET", url, headers=headers)
+        if self.config.debug:
+            self._save_debug_html(resp.text, prefix='Grc_Query_Slot_Page')
+
+        if 'Log into Visas schedule' in resp.text:
+            self.is_healthy = False
+            raise SessionExpiredOrInvalidError(message='Session expired.')
+        
+        res_id_match = re.search(r'resource\[(\d+)\]\s*=', resp.text)
+        if res_id_match:
+            self.resource_id = res_id_match.group(1)
+
+        default_length = None
+        len_match = re.search(r'default_length\s*=\s*(\d+)', resp.text)
+        if len_match:
+            default_length = int(len_match.group(1))
+            
+        # 提取每日营业时间 (open_times)
+        open_times = None
+        ot_match = re.search(r'open_times\s*=\s*\[(.*?)\]', resp.text)
+        if ot_match:
+            open_times = [int(x) for x in ot_match.group(1).split(',')]
+            
+        # 提取临时关闭/休息日 (ecache)
+        ecache_blocked = None
+        ec_match = re.search(r'var ecache\s*=\s*(\{.*?\})', resp.text)
+        if ec_match:
+            ec_data_str = ec_match.group(1)
+            data_match = re.search(r'data:\s*(\[\[.*?\]\])', ec_data_str)
+            if data_match:
+                ecache_blocked = json.loads(data_match.group(1))
+            
+        # 提取已预约数据 (app)
+        booked_timestamps = None
+        app_match = re.search(r'var app\s*=\s*(\[\[.*?\]\])', resp.text)
+        if app_match:
+            app_data = json.loads(app_match.group(1))
+            booked_timestamps = set(item[0] for item in app_data)
+            
+        # 提取 season (非常重要:学期/季度限制)
+        season_range = None
+        season_match = re.search(r'season\s*=\s*\[(\d+),(\d+)\]', resp.text)
+        if season_match:
+            season_range = [int(season_match.group(1)), int(season_match.group(2))]
+            print(f"[*] 限制范围 (Season): 截止到 {datetime.fromtimestamp(season_range[1], timezone.utc)}")
+    
+        # 确定扫描起点
+        cursor_match = re.search(r'Date\.UTC\((\d+),(\d+),(\d+),(\d+)\)', resp.text)
+        if cursor_match:
+            y, m, d, h = map(int, cursor_match.groups())
+            start_date = datetime(y, m + 1, d, h, tzinfo=timezone.utc)
+        else:
+            start_date = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0)
+        print(f"[*] 分析配置: 默认时长 {default_length/60} 分钟")
+
+
+        days_to_scan = 5 * 7
+        valid_slots_map: dict[datetime.date, list[TimeSlot]] = {}
+        for day_offset in range(days_to_scan):
+            current_day = start_date + timedelta(days=day_offset)
+            
+            start_min, end_min = self._get_daily_schedule(open_times, current_day)
+            
+            # 如果开始=结束 (比如都是 600),或者没有定义,说明当天不营业
+            if start_min is None or start_min >= end_min:
+                continue
+                
+            # 生成当天的所有 Slot, 将分钟转换为当天的具体时间 start_min 630 -> 10:30
+            current_slot_min = start_min
+            while current_slot_min + (default_length / 60) <= end_min:
+                # 计算 Slot 的具体时间对象
+                slot_hour = current_slot_min // 60
+                slot_minute = current_slot_min % 60
+                
+                slot_dt = current_day.replace(hour=int(slot_hour), minute=int(slot_minute), second=0, microsecond=0)
+                slot_ts = int(slot_dt.timestamp())
+                
+                # 下一个 slot 开始时间
+                current_slot_min += (default_length / 60)
+
+                # 检查是否过期
+                if slot_ts < time.time():
+                    continue
+                
+                # Slot 时间 必须在 Season 范围内
+                if slot_ts >= season_range[1]:
+                    # print(f"  [Skip] {slot_dt} 超出 Season 范围")
+                    continue
+
+                # 检查是否在临时关闭列表 (ecache) 中
+                if self._is_blocked_by_ecache(ecache_blocked, slot_ts + 1):
+                    # print(f"  [Skip] {slot_dt} 被 ecache (节假日/关闭) 屏蔽")
+                    continue
+
+                # 检查是否已被预约 (在 app 数组中)
+                if slot_ts in booked_timestamps:
+                    # print(f"  [Skip] {slot_dt} 已被预约")
+                    continue
+                
+                booking_payload = {
+                    "timestamp": slot_ts,
+                    "datetime": slot_dt.strftime("%Y-%m-%d %H:%M:%S")
+                }
+                
+                time_slot = TimeSlot(
+                    time=slot_dt.strftime("%H:%M"),
+                    label=json.dumps(booking_payload) # 序列化存入 label
+                )
+                
+                date_key = slot_dt.date()
+                if date_key not in valid_slots_map:
+                    valid_slots_map[date_key] = []
+                valid_slots_map[date_key].append(time_slot)
+                
+        if valid_slots_map:
+            res.success = True
+            res.availability_status = AvailabilityStatus.Available
+            
+            # 按日期排序
+            sorted_dates = sorted(valid_slots_map.keys())
+            res.earliest_date = datetime.combine(sorted_dates[0], datetime.min.time())
+            
+            res.availability = []
+            for d in sorted_dates:
+                res.availability.append(DateAvailability(
+                    date=datetime.combine(d, datetime.min.time()),
+                    times=valid_slots_map[d]
+                ))
+            
+            self._log(f"Found availability on {len(sorted_dates)} days.")
+        else:
+            self._log("No available slots found.")
+        return res
+
+    def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
+        res = VSBookResult()
+        res.success = False
+
+        # 1. 准备日期筛选参数
+        exp_start = user_inputs.get('expected_start_date', '')
+        exp_end = user_inputs.get('expected_end_date', '')
+        
+        # 将 Availability 转换为 { "YYYY-MM-DD": DateAvailabilityObj } 的映射,方便查找
+        date_map = {}
+        available_date_strs = []
+        for date_avail in slot_info.availability:
+            # datetime 转 string (YYYY-MM-DD)
+            d_str = date_avail.date.strftime("%Y-%m-%d")
+            date_map[d_str] = date_avail
+            available_date_strs.append(d_str)
+
+        # 2. 筛选符合要求的日期
+        # _filter_dates 内部已经进行了 random.shuffle,所以返回列表的第一个即为随机选中的有效日期
+        valid_dates = self._filter_dates(available_date_strs, exp_start, exp_end)
+        
+        if not valid_dates:
+            self._log(f"No available slots within the expected range ({exp_start} to {exp_end}).") 
+            return res
+
+        # 3. 选择具体的 Slot
+        target_date_str = valid_dates[0]
+        target_day_data = date_map[target_date_str]
+        
+        if not target_day_data.times:
+            res.message = f"Date {target_date_str} has no time slots."
+            return res
+
+        # 这里简单策略:选择该日期的第一个可用时间点
+        # 如果需要随机时间,可以使用 random.choice(target_day_data.times)
+        target_slot = target_day_data.times[0]
+
+        # 4. 解析 Slot Label 数据
+        # label 中存储了 {"timestamp": 123456, "datetime": "2026-02-06 10:00:00", ...}
+        try:
+            slot_data = json.loads(target_slot.label)
+            start_time_str = slot_data.get('datetime')
+        except Exception as e:
+            self._log(f"Failed to parse slot label: {e}")
+            return res
+
+        # 5. 准备预定请求数据
+        url = "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas"
+        
+        # 计算 finish_time (SuperSaaS通常需要 finish_time,默认时长1小时)
+        start_dt = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
+        finish_dt = start_dt + timedelta(hours=1)
+        finish_time_str = finish_dt.strftime("%Y-%m-%d %H:%M:%S")
+
+        # 映射用户信息 (假设 self.config.profile 包含这些字段)
+        # 根据之前的 HTML 分析:
+        # field_1_r -> Passport number
+        # field_2_r -> Reason for Visa
+        
+        first_name = user_inputs.get('first_name', '')
+        last_name = user_inputs.get('last_name', '')
+        phone_country_code = user_inputs.get('phone_country_code', '353')
+        phone_no = user_inputs.get('phone_no', '088888888')
+        address = user_inputs.get('address', "Dublin, Ireland")
+        passport_no = user_inputs.get('passport_no', "")
+        payload = {
+            "reservation[start_time]": start_time_str,
+            "reservation[finish_time]": finish_time_str,
+            "reservation[full_name]": f"{first_name} {last_name}",
+            "reservation[mobile]": f'+{phone_country_code}{phone_no}',
+            "reservation[address]": address,
+            "reservation[description]": "", 
+            
+            # 自定义必填字段
+            "reservation[field_1_r]": passport_no,
+            "reservation[field_2_r]": "Tourism",
+            
+            # 系统字段
+            "reservation[resource_id]": self.resource_id,
+            "reservation[xpos]": "",
+            "reservation[ypos]": "",
+            "button": ""
+        }
+
+        headers = {
+            "Referer": url,
+            "Origin": "https://www.supersaas.com",
+            "Content-Type": "application/x-www-form-urlencoded",
+        }
+
+        self._log(f"Attempting to book slot: {start_time_str}")
+
+        resp = self._perform_request('POST', url, data=payload, headers=headers)
+      
+        res.success = True
+        res.book_date = start_dt.strftime("%Y-%m-%d") # 格式: YYYY-mm-dd
+        res.book_time = start_dt.strftime("%H:%M")    # 格式: hh:mm
+
+        self._log(f"Booking successful for {res.book_date} at {res.book_time}")
+        return res
+
+    def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
+        """
+        根据用户的期望范围筛选可用日期
+        
+        :param dates: API 返回的可用日期列表 (YYYY-MM-DD)
+        :param start_str: 用户期望开始日期 (YYYY-MM-DD)
+        :param end_str: 用户期望结束日期 (YYYY-MM-DD)
+        :return: 符合要求的日期列表
+        """
+        # 如果没有设置范围,则不过滤,返回所有日期
+        if not start_str or not end_str:
+            # 也要打乱一下,保证随机性
+            shuffled_dates = list(dates)
+            random.shuffle(shuffled_dates)
+            return shuffled_dates
+            
+        valid_dates = []
+        try:
+            # 截取前10位以防带有时分秒
+            s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
+            e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
+            
+            for date_str in dates:
+                curr_date = datetime.strptime(date_str, "%Y-%m-%d")
+                # 比较范围 (闭区间)
+                if s_date <= curr_date <= e_date:
+                    valid_dates.append(date_str)
+        except ValueError:
+            self._log("Date format error in expected_start_date or expected_end_date. Ignoring filter.")
+            shuffled_dates = list(dates)
+            random.shuffle(shuffled_dates)
+            return shuffled_dates
+
+        random.shuffle(valid_dates)
+        return valid_dates
+
+    def _log(self, message):
+        if self.logger:
+            self.logger(f'[GrcPlugin] [{self.group_id}] {message}')
+    
+    def _save_debug_html(self, content: str, prefix: str = "debug"):
+        save_dir = "debug_pages"
+        if not os.path.exists(save_dir):
+            os.makedirs(save_dir)
+        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+        filename = f"{save_dir}/{prefix}_{timestamp}.html"
+        with open(filename, "w", encoding="utf-8") as f:
+            f.write(content)
+        self._log(f"HTML saved to: {filename}")
+            
+    def _get_proxy_url(self):
+        # 构造代理
+        proxy_url = ""
+        if self.config.proxy.ip:
+            s = self.config.proxy
+            if s.username:
+                proxy_url = f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
+            else:
+                proxy_url = f"{s.scheme}://{s.ip}:{s.port}"
+        return proxy_url
+    
+    def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
+        """
+        统一 HTTP 请求封装,严格复刻 C++ 逻辑:
+        1. 发送 OPTIONS 请求
+        2. 发送实际请求
+        """
+        resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
+        if self.config.debug:
+            self._log(f'[perform request] Response={resp.text}\nMethod={method}, Url={url}, Data={data}, JsonData={json_data}, Params={params}')
+        if resp.status_code == 200:
+            return resp
+        elif resp.status_code == 401:
+            self.is_healthy = False
+            raise SessionExpiredOrInvalidError()
+        elif resp.status_code == 403:
+            raise PermissionDeniedError()
+        elif resp.status_code == 429:
+            self.is_healthy = False
+            raise RateLimiteddError()
+        else:
+            raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
+            
+    def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
+        """
+        根据用户的期望范围筛选可用日期
+        
+        :param dates: API 返回的可用日期列表 (YYYY-MM-DD)
+        :param start_str: 用户期望开始日期 (YYYY-MM-DD)
+        :param end_str: 用户期望结束日期 (YYYY-MM-DD)
+        :return: 符合要求的日期列表
+        """
+        # 如果没有设置范围,则不过滤,返回所有日期
+        if not start_str or not end_str:
+            return dates
+            
+        valid_dates = []
+        # 截取前10位以防带有时分秒
+        s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
+        e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
+        
+        for date_str in dates:
+            curr_date = datetime.strptime(date_str, "%Y-%m-%d")
+            # 比较范围 (闭区间)
+            if s_date <= curr_date <= e_date:
+                valid_dates.append(date_str)
+        random.shuffle(valid_dates)
+        return valid_dates

+ 36 - 22
plugins/ita_plugin.py

@@ -17,6 +17,7 @@ from vs_plg import IVSPlg
 from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 from toolkit.proxy_tunnel import ProxyTunnel
 from toolkit.vs_cloud_api import VSCloudApi
+from utils.mouse_helper import HumanMouse
 
 
 class BrowserResponse:
@@ -187,13 +188,21 @@ class ItaPlugin(IVSPlg):
             # 4. [核心修改] 解决 ReCaptcha V3 Enterprise 并注入
             # Prenotami 使用的是 Enterprise V3, Action = 'LOGIN'
             self._solve_and_inject_prenotami_captcha()
+            human_mouse = HumanMouse(self.page)
+            
+            # 先定位
+            self._log("Locating Login button...")
+            login_btn = self.page.ele('@id=captcha-trigger')
 
+            self._log("Scrolling to make button visible...")
+            human_mouse.scroll_to_visible(login_btn)   
             
-            # 5. [核心修改] 提交登录
-            # 不要点击 #captcha-trigger,因为它会触发网页自带的 Google 验证逻辑
-            # 我们直接触发表单提交,因为 Token 已经由我们注入了
-            self._log("Submitting login form via JS...")
-            self.page.run_js("document.getElementById('login-form').submit()")
+            self._log("Moving mouse to Login button...")
+            human_mouse.move_to(login_btn, duration=random.uniform(0.6, 1.0))
+            time.sleep(random.uniform(0.3, 0.5))
+            self._log("Clicking Login button...")
+            login_btn.click()
+            self._log("Login button clicked.")
             
             # 等待 URL 变化或特定元素出现
             # 成功通常跳转到 /UserArea, 失败则留在 /Home
@@ -227,9 +236,6 @@ class ItaPlugin(IVSPlg):
 
             self._log("Login Successful.")
             
-            # 访问服务页保活
-            self.page.get(f"{self._host}/Services")
-            
             self.session_create_time = time.time()
 
         except Exception as e:
@@ -559,21 +565,29 @@ class ItaPlugin(IVSPlg):
         g_token = self._solve_recaptcha(rc_params)
         self._log(f"Captcha Solved. Token length: {len(g_token)}")
         
-        # 注入 Token 到表单
-        # 页面逻辑是:$('#login-form').append('<input type="hidden" name="g-recaptcha-response" value="' + token + '" />');
-        js_inject = f"""
-        var form = document.getElementById('login-form');
-        // 移除旧的 input 防止重复
-        var old = document.getElementsByName('g-recaptcha-response');
-        if(old.length > 0) old[0].remove();
-        
-        var input = document.createElement('input');
-        input.type = 'hidden';
-        input.name = 'g-recaptcha-response';
-        input.value = "{g_token}";
-        form.appendChild(input);
+        hook_js = f"""
+            // 1. 填充隐藏域 (双重保险)
+            var input = document.getElementById('g-recaptcha-response');
+            if(input) {{
+                input.value = "{g_token}";
+            }}
+
+            // 2. 劫持 grecaptcha.execute 和 grecaptcha.enterprise.execute
+            // 无论网页用哪个版本,都拦截下来
+            var mockExecute = function() {{
+                console.log("Recaptcha execution intercepted!");
+                return Promise.resolve("{g_token}");
+            }};
+
+            if (window.grecaptcha) {{
+                window.grecaptcha.execute = mockExecute;
+                if (window.grecaptcha.enterprise) {{
+                    window.grecaptcha.enterprise.execute = mockExecute;
+                }}
+            }}
         """
-        self.page.run_js(js_inject)   
+        self._log("Injecting ReCaptcha Hook...")
+        self.page.run_js(hook_js) 
     
     def _perform_request(self, method, url, headers=None, data=None, json_data=None):
         """JS Fetch Wrapper"""

+ 0 - 249
toolkit/account_manager.py

@@ -1,249 +0,0 @@
-import threading
-import time
-import json
-import os
-import random
-from typing import Optional, Dict, Any, List
-# 假设这些日志宏在你的项目中可用,如果不可用请替换为 print 或 logging
-from vs_log_macros import VSC_DEBUG, VSC_WARN, VSC_INFO, VSC_ERROR
-
-class AccountManager:
-    """
-    账户管理器 (支持数据落盘持久化 + 账号池隔离)
-    - 静态配置: config/accounts.json
-    - 动态状态: data/account_states.json
-    
-    关键特性:同一个邮箱如果存在于不同的 pool 中,其冷却时间是隔离的。
-    """
-    _instance = None
-    _lock = threading.RLock()
-
-    def __new__(cls):
-        with cls._lock:
-            if cls._instance is None:
-                cls._instance = super().__new__(cls)
-                cls._instance._init_data()
-            return cls._instance
-
-    @staticmethod
-    def Instance():
-        return AccountManager()
-
-    def _init_data(self):
-        # 静态账号数据: pool_name -> List[Dict]
-        self._accounts: Dict[str, List[Dict]] = {} 
-        
-        # 动态锁定状态: "pool_name::username" -> lock_until_timestamp
-        # 使用组合键来保证不同池子的同名账号隔离
-        self._lock_states: Dict[str, float] = {}
-        
-        self._account_lock = threading.RLock()
-        
-        # 路径配置
-        self._config_path = "config/accounts.json"
-        self._state_path = "data/account_states.json"
-        
-        # 确保 data 目录存在
-        os.makedirs(os.path.dirname(self._state_path), exist_ok=True)
-        
-        # 初始化时加载
-        self.reload_config()
-
-    def _get_state_key(self, pool_name: str, username: str) -> str:
-        """
-        生成唯一的锁定状态 Key。
-        格式: pool_name::username
-        这保证了不同池子里的同名账号互不干扰。
-        """
-        return f"{pool_name}::{username}"
-
-    def reload_config(self):
-        """(重新)加载本地配置文件,并恢复锁定状态"""
-        if not os.path.exists(self._config_path):
-            VSC_WARN("acc_mgr", f"Config file not found: {self._config_path}. Account pools are empty.")
-            return
-
-        try:
-            # 1. 先加载磁盘上的锁定状态
-            self._load_state_from_disk()
-
-            # 2. 加载静态配置
-            with open(self._config_path, 'r', encoding='utf-8') as f:
-                data = json.load(f)
-            
-            count = 0
-            with self._account_lock:
-                self._accounts.clear()
-                for pool_name, acc_list in data.items():
-                    processed_list = []
-                    for acc in acc_list:
-                        if "id" not in acc or "username" not in acc:
-                            continue
-                        
-                        # 初始化 bound_data
-                        acc.setdefault('bound_data', None)
-                        
-                        # 生成组合键,去查找之前的锁定记录
-                        username = acc["username"]
-                        state_key = self._get_state_key(pool_name, username)
-                        
-                        # 恢复锁定时间 (如果文件中没有记录,默认为0)
-                        saved_lock = self._lock_states.get(state_key, 0)
-                        acc['lock_until'] = saved_lock
-                        
-                        processed_list.append(acc)
-                        count += 1
-                    
-                    self._accounts[pool_name] = processed_list
-                    
-            VSC_INFO("acc_mgr", f"Loaded {count} accounts. Lock states restored from {self._state_path}")
-            
-        except json.JSONDecodeError:
-            VSC_ERROR("acc_mgr", f"Invalid JSON format in {self._config_path}")
-        except Exception as e:
-            VSC_ERROR("acc_mgr", f"Failed to load config: {e}")
-
-    def _load_state_from_disk(self):
-        """从磁盘读取状态文件"""
-        with self._account_lock:
-            self._lock_states.clear()
-            if not os.path.exists(self._state_path):
-                return
-            try:
-                with open(self._state_path, 'r', encoding='utf-8') as f:
-                    data = json.load(f)
-                    # 加载数据到内存
-                    for k, v in data.items():
-                        self._lock_states[str(k)] = float(v)
-            except Exception as e:
-                VSC_WARN("acc_mgr", f"Failed to load state file: {e}. Starting fresh.")
-
-    def _save_state_to_disk(self):
-        """
-        将当前内存中的锁定状态写入磁盘
-        注意:此方法必须在 self._account_lock 保护下调用
-        """
-        try:
-            now = time.time()
-            # 过滤:只保存未来还会被锁定的账号
-            # Key 是 "pool_name::username"
-            active_states = {
-                k: v for k, v in self._lock_states.items() 
-                if v > now
-            }
-            
-            # 更新内存缓存(剔除已过期的)
-            self._lock_states = active_states
-
-            with open(self._state_path, 'w', encoding='utf-8') as f:
-                json.dump(active_states, f, indent=4)
-                
-        except Exception as e:
-            VSC_ERROR("acc_mgr", f"Failed to save state to disk: {e}")
-
-    def next(self, pool_name: str, lock_duration: float = 60.0) -> Optional[Dict[str, Any]]:
-        """
-        获取下一个可用账号,并自动锁定指定时长。
-        
-        @param pool_name: 账号池名称
-        @param lock_duration: 锁定时间(秒)
-        @return: 账号信息字典 或 None
-        """
-        with self._account_lock:  # 全局锁
-            accounts = self._accounts.get(pool_name, [])
-            if not accounts:
-                # VSC_WARN("acc_mgr", "No accounts found for pool '%s'", pool_name)
-                return None
-
-            now = time.time()
-            available = []
-            
-            # 筛选逻辑
-            for acc in accounts:
-                username = acc.get("username")
-                # 生成组合键
-                state_key = self._get_state_key(pool_name, username)
-                
-                # 检查隔离的锁定状态
-                current_lock = self._lock_states.get(state_key, 0)
-                
-                if current_lock <= now:
-                    available.append(acc)
-            
-            if not available:
-                # 当前池子所有账号都在冷却中
-                return None
-
-            # 随机选择
-            selected = random.choice(available)
-            username = selected.get("username")
-            
-            # === 更新状态 ===
-            new_lock_until = now + lock_duration
-            state_key = self._get_state_key(pool_name, username)
-            
-            # 1. 更新全局隔离状态字典
-            self._lock_states[state_key] = new_lock_until
-            
-            # 2. 更新内存对象 (供 remove 等其他逻辑参考)
-            selected["lock_until"] = new_lock_until
-            
-            # 3. 立即落盘
-            self._save_state_to_disk()
-            
-            VSC_INFO("acc_mgr", "Selected %s (Pool: %s), locked for %.0fs (Until: %s)", 
-                      username, pool_name, lock_duration, 
-                      time.strftime("%H:%M:%S", time.localtime(new_lock_until)))
-            
-            # 返回深拷贝或浅拷贝,防止外部污染
-            return selected.copy()
-
-    def lock(self, pool_name: str, account_id: int, duration_seconds: int):
-        """
-        手动锁定账号 (支持隔离)
-        """
-        with self._account_lock:
-            accounts = self._accounts.get(pool_name, [])
-            target_acc = None
-            
-            # 找到账号对象
-            for acc in accounts:
-                if acc["id"] == account_id:
-                    target_acc = acc
-                    break
-            
-            if target_acc:
-                username = target_acc.get("username")
-                new_lock_until = time.time() + duration_seconds
-                state_key = self._get_state_key(pool_name, username)
-                
-                # 更新状态
-                self._lock_states[state_key] = new_lock_until
-                target_acc["lock_until"] = new_lock_until
-                
-                # 落盘
-                self._save_state_to_disk()
-                
-                VSC_INFO("acc_mgr", "Manually locked %s (Pool: %s) for %ds", 
-                         username, pool_name, duration_seconds)
-            else:
-                VSC_WARN("acc_mgr", "Account ID %d not found in pool %s to lock", account_id, pool_name)
-
-    def remove(self, pool_name: str, account_id: int, reason: str = "success", extra_data: dict = None):
-        """
-        从内存池中移除账号 (仅影响本次运行内存,不影响文件配置)
-        """
-        with self._account_lock:
-            accounts = self._accounts.get(pool_name, [])
-            target_acc = None
-            for acc in accounts:
-                if acc["id"] == account_id:
-                    target_acc = acc
-                    break
-            
-            if target_acc:
-                accounts.remove(target_acc)
-                VSC_INFO("acc_mgr", "Removed %s from memory pool %s. Reason: %s", 
-                         target_acc.get("username"), pool_name, reason)
-            else:
-                pass

+ 18 - 0
toolkit/vs_cloud_api.py

@@ -279,6 +279,24 @@ class VSCloudApi:
             return result.get("data", {})
         else:
             raise BizLogicError(message=f"Slot refresh fail biz error: {result.get('message')}")
+        
+    def get_next_account(
+        self,
+        pool_name: str,
+        lock_duration: float = 60
+    ):
+        url = 'https://visafly.top/api/account/next'
+        params = {
+            "pool_name": pool_name,
+            "lock_duration": lock_duration
+        }
+        headers = self._get_headers()
+        resp = self._perform_request('GET', url, headers=headers, params=params)
+        result = resp.json()
+        if result.get("code") == 0:
+            return result.get("data", {})
+        else:
+            raise BizLogicError(message=f"Get next account biz error: {result.get('message')}")
 
     def slot_snapshot_report(
         self,

+ 111 - 0
upload_accounts.py

@@ -0,0 +1,111 @@
+import json
+import requests
+import os
+import time
+
+# --- 配置部分 ---
+# 服务器地址 (根据你的实际情况修改)
+SERVER_URL = "http://45.137.220.138:8888"
+# API 端点
+API_ENDPOINT = f"{SERVER_URL}/api/account/add"
+# 本地账号文件路径
+ACCOUNT_FILE = "registered_accounts.json"
+
+def post_account(pool_name, username, password, extra_data):
+    """发送单个账号数据到服务器"""
+    payload = {
+        "pool_name": pool_name,
+        "username": username,
+        "password": password,
+        "extra_data": extra_data
+    }
+    headers = {
+        "authorization": "Bearer tok_e1696f7d20f14e0f9a0b30e116aab396"
+    }
+    try:
+        response = requests.post(API_ENDPOINT, headers=headers, json=payload, timeout=5)
+        if response.status_code == 200:
+            res_json = response.json()
+            print(f"✅ [{pool_name}] {username} -> {res_json.get('message', 'Success')}")
+            return True
+        else:
+            print(f"❌ [{pool_name}] {username} -> 上传失败 (HTTP {response.status_code}): {response.text}")
+            return False
+    except Exception as e:
+        print(f"❌ [{pool_name}] {username} -> 异常: {e}")
+        return False
+
+def process_dict_format(data):
+    """处理旧格式: {'pool_name': [accounts...]}"""
+    print("📋 检测到格式: 字典 (Pool -> List)")
+    count = 0
+    for pool_name, accounts in data.items():
+        for acc in accounts:
+            username = acc.get("username")
+            password = acc.get("password")
+            # 把 ID 放入 extra_data
+            extra = {"original_id": acc.get("id"), "source": "dict_upload"}
+            
+            if username and pool_name:
+                if post_account(pool_name, username, password, extra):
+                    count += 1
+    return count
+
+def process_list_format(data):
+    """处理新格式: [{'pool_name': '...', ...}, ...]"""
+    print("📋 检测到格式: 列表 (Flat List)")
+    count = 0
+    for acc in data:
+        # 1. 提取核心字段
+        pool_name = acc.get("pool_name")
+        username = acc.get("username")
+        password = acc.get("password")
+        
+        # 2. 提取剩余字段作为 extra_data (country_code, phone 等)
+        # 我们把除了上面三个字段以外的所有字段都塞进 extra_data
+        exclude_keys = {"pool_name", "username", "password"}
+        extra = {k: v for k, v in acc.items() if k not in exclude_keys}
+        
+        # 标记来源
+        extra["source"] = "list_upload"
+
+        if not pool_name:
+            print(f"⚠️  跳过数据: 缺少 pool_name -> {acc}")
+            continue
+            
+        if not username:
+            print(f"⚠️  跳过数据: 缺少 username -> {acc}")
+            continue
+
+        if post_account(pool_name, username, password, extra):
+            count += 1
+    return count
+
+def main():
+    if not os.path.exists(ACCOUNT_FILE):
+        print(f"[错误] 找不到文件: {ACCOUNT_FILE}")
+        return
+
+    try:
+        with open(ACCOUNT_FILE, 'r', encoding='utf-8') as f:
+            data = json.load(f)
+    except json.JSONDecodeError:
+        print(f"[错误] JSON 格式无效")
+        return
+
+    success_count = 0
+    
+    # 智能识别结构
+    if isinstance(data, dict):
+        success_count = process_dict_format(data)
+    elif isinstance(data, list):
+        success_count = process_list_format(data)
+    else:
+        print("❌ 未知的 JSON 结构,必须是对象或数组")
+
+    print("\n" + "="*30)
+    print(f"📊 任务完成,成功上传: {success_count} 个")
+    print("="*30)
+
+if __name__ == "__main__":
+    main()

+ 133 - 0
utils/mouse_helper.py

@@ -0,0 +1,133 @@
+import time
+import random
+import math
+
+def get_cubic_bezier_point(t, p0, p1, p2, p3):
+    x = (1-t)**3 * p0[0] + 3*(1-t)**2 * t * p1[0] + 3*(1-t) * t**2 * p2[0] + t**3 * p3[0]
+    y = (1-t)**3 * p0[1] + 3*(1-t)**2 * t * p1[1] + 3*(1-t) * t**2 * p2[1] + t**3 * p3[1]
+    return (x, y)
+
+def ease_out_quad(x):
+    return 1 - (1 - x) * (1 - x)
+
+def generate_human_path(start_x, start_y, end_x, end_y, steps=30):
+    path = []
+    dist = math.hypot(end_x - start_x, end_y - start_y)
+    offset = dist * 0.2
+    
+    p0 = (start_x, start_y)
+    p3 = (end_x, end_y)
+    
+    p1 = (
+        start_x + (end_x - start_x) * 0.3 + random.uniform(-offset, offset),
+        start_y + (end_y - start_y) * 0.3 + random.uniform(-offset, offset)
+    )
+    p2 = (
+        start_x + (end_x - start_x) * 0.7 + random.uniform(-offset, offset),
+        start_y + (end_y - start_y) * 0.7 + random.uniform(-offset, offset)
+    )
+
+    for i in range(steps + 1):
+        t = i / steps
+        eased_t = ease_out_quad(t)
+        point = get_cubic_bezier_point(eased_t, p0, p1, p2, p3)
+        jitter = 1.5
+        final_x = point[0] + random.uniform(-jitter, jitter)
+        final_y = point[1] + random.uniform(-jitter, jitter)
+        if i == steps:
+            final_x, final_y = end_x, end_y
+        path.append((final_x, final_y))
+    return path
+
+class HumanMouse:
+    def __init__(self, page):
+        self.page = page
+        self.curr_x = random.randint(100, 500)
+        self.curr_y = random.randint(100, 500)
+        
+        self.page.run_cdp('Input.dispatchMouseEvent', **{
+            'type': 'mouseMoved',
+            'x': self.curr_x,
+            'y': self.curr_y
+        })
+
+    def _get_center(self, ele):
+        """兼容性获取中心点"""
+        rect = ele.rect
+        try:
+            tl_x, tl_y = rect.location 
+            width, height = rect.size
+        except AttributeError:
+            tl_x, tl_y, width, height = rect
+        return tl_x + (width / 2), tl_y + (height / 2)
+
+    def move_to(self, ele, duration=0.5):
+        center_x, center_y = self._get_center(ele)
+        
+        # 目标稍微带点随机偏移
+        target_x = center_x + random.uniform(-3, 3)
+        target_y = center_y + random.uniform(-3, 3)
+        
+        if self.curr_x == 0 and self.curr_y == 0:
+            self.curr_x = target_x - random.randint(300, 500)
+            self.curr_y = target_y - random.randint(300, 500)
+            self.page.run_cdp('Input.dispatchMouseEvent', **{
+                'type': 'mouseMoved', 
+                'x': self.curr_x, 
+                'y': self.curr_y
+            })
+
+        steps = int(duration * 60)
+        if steps < 10: steps = 10
+        
+        points = generate_human_path(self.curr_x, self.curr_y, target_x, target_y, steps)
+        
+        for x, y in points:
+            self.page.run_cdp('Input.dispatchMouseEvent', **{
+                'type': 'mouseMoved',
+                'x': x,
+                'y': y
+            })
+            self.curr_x = x
+            self.curr_y = y
+            time.sleep(duration / steps * random.uniform(0.8, 1.2))
+
+    def scroll_to_visible(self, ele):
+        viewport_height = self.page.run_js("return window.innerHeight")
+        while True:
+            # 使用 arguments[0] 修复 run_js 参数问题
+            rect = self.page.run_js("""
+                var rect = arguments[0].getBoundingClientRect();
+                return {top: rect.top, bottom: rect.bottom, height: rect.height};
+            """, ele)
+            
+            element_top = rect['top']
+            element_bottom = rect['bottom']
+            
+            scroll_needed = False
+            delta_y = 0
+            
+            # 增加一些缓冲区,不要滚得太极限
+            if element_top > viewport_height * 0.7: 
+                scroll_needed = True
+                delta_y = random.randint(100, 250) 
+            elif element_bottom < viewport_height * 0.3:
+                scroll_needed = True
+                delta_y = -random.randint(100, 250)
+            
+            if not scroll_needed:
+                break
+                
+            self._dispatch_scroll(delta_y)
+            time.sleep(random.uniform(0.1, 0.2))
+
+    def _dispatch_scroll(self, delta_y):
+        self.page.run_cdp('Input.dispatchMouseEvent', **{
+            'type': 'mouseWheel',
+            'x': self.curr_x, 
+            'y': self.curr_y,
+            'deltaX': 0,
+            'deltaY': delta_y,
+            'modifiers': 0,
+            'pointerType': 'mouse'
+        })

+ 490 - 0
vfs_registration_bot.py

@@ -0,0 +1,490 @@
+import os
+import random
+import socket
+import json
+import time
+import string
+import logging
+import base64
+from datetime import datetime, timezone
+from urllib.parse import urlparse, urlencode
+
+from bs4 import BeautifulSoup
+from cryptography.hazmat.primitives import serialization, hashes
+from cryptography.hazmat.primitives.asymmetric import padding
+from cryptography.hazmat.backends import default_backend
+
+from DrissionPage import ChromiumPage, ChromiumOptions
+from vs_types import RateLimiteddError, BizLogicError 
+from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
+from toolkit.vs_cloud_api import VSCloudApi
+
+# --- 配置日志 ---
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s [%(levelname)s] %(message)s',
+    datefmt='%H:%M:%S'
+)
+logger = logging.getLogger("VFSRegistrar")
+
+# --- 常量 ---
+VFS_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuupFgB+lYIOtSxrRoHzc
+LmCZKJ6+oSbgqgOPzFMM0TasOeLw0NXEn1XfIzXdx75+tegNKwyIZumoh0yhubKs
+t59GV321kN0iquYRHrdh3ygfDDHlS9rROQeBqRga0ncSADtbLMrBPqXJjPCoV76y
+t92towriKoH75BhiazY0mghm4LjmAWrV0u/GNpV3tk9bxbtHEXGaFmxCJqjg+7x6
+1e5wXLfvpj9w1QsiSWOSJxLOyICz/9ByxXycQQFdNmjnnnwco9Gt/Mi33NYH71j0
+5oXIjklFC4lvJqaqSY5lS7Vwb9oCt9zX9J0Yz4z4e/3V+0jgRnWOFGofyks4FKe2
+GQIDAQAB
+-----END PUBLIC KEY-----"""
+
+class VFSHelper:
+    """工具方法的静态类"""
+    
+    @staticmethod
+    def generate_mobile_number(country_code=353, e164_format=False):
+        if country_code == 353: # Ireland
+            prefix = random.choice(['83', '85', '86', '87', '89'])
+            number = f"{prefix}{''.join([str(random.randint(0, 9)) for _ in range(7)])}"
+            return f"+353{number}" if e164_format else number
+            
+        elif country_code == 44: # UK
+            prefix_second = random.choice(['1', '2', '3', '4', '5', '7', '8', '9'])
+            number = f"7{prefix_second}{''.join([str(random.randint(0, 9)) for _ in range(8)])}"
+            return f"+44{number}" if e164_format else number
+
+        elif country_code == 86: # China
+            prefixes = ["130", "131", "132", "133", "135", "136", "138", "139", "150", "158", "159", "186"]
+            prefix = random.choice(prefixes)
+            number = f"{prefix}{''.join([str(random.randint(0, 9)) for _ in range(8)])}"
+            return f"+86{number}" if e164_format else number
+        
+        return "".join([str(random.randint(0, 9)) for _ in range(10)])
+
+    @staticmethod
+    def generate_password(length=12):
+        chars = string.ascii_letters + string.digits + "@#$%"
+        while True:
+            pwd = ''.join(random.choices(chars, k=length))
+            if (any(c.islower() for c in pwd) and 
+                any(c.isupper() for c in pwd) and 
+                any(c.isdigit() for c in pwd) and 
+                any(c in "@#$%" for c in pwd)):
+                return pwd
+
+    @staticmethod
+    def encrypt_password(password: str) -> str:
+        public_key = serialization.load_pem_public_key(
+            VFS_PUBLIC_KEY_PEM.encode(), backend=default_backend()
+        )
+        ciphertext = public_key.encrypt(
+            password.encode(),
+            padding.OAEP(
+                mgf=padding.MGF1(algorithm=hashes.SHA256()),
+                algorithm=hashes.SHA256(),
+                label=None
+            )
+        )
+        return base64.b64encode(ciphertext).decode()
+
+    @staticmethod
+    def get_client_source() -> str:
+        timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
+        payload = f"GA;{timestamp}Z"
+        return VFSHelper.encrypt_password(payload)
+
+class BrowserResponse:
+    """标准化浏览器响应"""
+    def __init__(self, result_dict):
+        result_dict = result_dict or {}
+        self.status_code = result_dict.get('status', 0)
+        self.text = result_dict.get('body', '')
+        self.headers = result_dict.get('headers', {})
+        self.url = result_dict.get('url', '')
+        self._json = None
+
+    def json(self):
+        if self._json is None:
+            try:
+                self._json = json.loads(self.text) if self.text else {}
+            except json.JSONDecodeError:
+                self._json = {}
+        return self._json
+
+class VFSRegistrationBot:
+    def __init__(self, config):
+        self.config = config
+        self.page = None
+        self.proxy_url = config.get("proxy_url")
+        
+    def _init_browser(self):
+        """初始化浏览器配置"""
+        co = ChromiumOptions()
+        
+        # 查找可用端口
+        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+            s.bind(('', 0))
+            port = s.getsockname()[1]
+            
+        co.set_local_port(port)
+        
+        chrome_path = os.getenv("CHROME_BIN")
+        if chrome_path:
+            co.set_paths(browser_path=chrome_path)
+
+        if self.proxy_url:
+            co.set_argument(f'--proxy-server={self.proxy_url}')
+
+        co.headless(False) # VFS 验证码通常需要有头模式
+        co.set_argument('--no-sandbox')
+        co.set_argument('--disable-gpu')
+        co.set_argument('--disable-dev-shm-usage')
+        co.set_argument('--window-size=1920,1080')
+        co.set_argument('--disable-blink-features=AutomationControlled')
+        
+        # 创建页面对象
+        self.page = ChromiumPage(co)
+        # 设置超时
+        self.page.set.timeouts(15)
+
+    def _perform_js_fetch(self, method, url, headers=None, data=None, json_data=None, retry_count=0):
+        """注入JS执行Fetch请求,绕过部分指纹检测"""
+        if not self.page:
+            raise BizLogicError("Browser not initialized")
+
+        if retry_count > 3:
+            raise BizLogicError("Max retries exceeded for request")
+
+        fetch_options = {
+            "method": method.upper(),
+            "headers": headers or {},
+            "credentials": "include"
+        }
+        
+        if json_data:
+            fetch_options['body'] = json.dumps(json_data)
+            fetch_options['headers']['Content-Type'] = 'application/json'
+        elif data:
+            fetch_options['body'] = urlencode(data) if isinstance(data, dict) else str(data)
+            fetch_options['headers']['Content-Type'] = 'application/x-www-form-urlencoded'
+
+        logger.debug(f"Request: {method} {url}")
+
+        js_script = f"""
+        const url = "{url}";
+        const options = {json.dumps(fetch_options)};
+        
+        return fetch(url, options)
+            .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 => ({{ status: 0, body: err.toString() }}));
+        """
+        
+        try:
+            res_dict = self.page.run_js(js_script, timeout=60)
+            resp = BrowserResponse(res_dict)
+            
+            if resp.status_code == 200:
+                return resp
+            
+            # 处理 Cloudflare 403 拦截
+            if resp.status_code == 403 and ("cloudflare" in resp.text.lower() or "Just a moment" in resp.text):
+                logger.warning(f"Cloudflare 403 detected. Retrying ({retry_count+1})...")
+                new_token = self._refresh_turnstile()
+                if new_token and json_data and "captcha_api_key" in json_data:
+                    json_data["captcha_api_key"] = new_token
+                return self._perform_js_fetch(method, url, headers, data, json_data, retry_count + 1)
+            
+            if resp.status_code == 429:
+                raise RateLimiteddError(f"Rate Limit: {resp.text[:100]}")
+                
+            return resp # 返回其他状态码供调用者处理 (如 400 业务错误)
+            
+        except Exception as e:
+            logger.error(f"JS Execution Error: {e}")
+            raise BizLogicError(f"Fetch failed: {e}")
+
+    def _handle_cookie_banner(self):
+        """处理 Cookie 弹窗"""
+        try:
+            js = """
+            var btn = document.getElementById('onetrust-accept-btn-handler');
+            if(btn) { btn.click(); return true; }
+            var banner = document.getElementById('onetrust-banner-sdk');
+            if(banner) { banner.remove(); return true; }
+            """
+            self.page.run_js(js)
+        except:
+            pass
+
+    def _refresh_turnstile(self):
+        """刷新并获取 Cloudflare Token"""
+        logger.info("Attempting to refresh Turnstile token...")
+        try:
+            self.page.run_js('try{window.turnstile.reset()}catch(e){}')
+            cf_bypasser = CloudflareBypasser(self.page, log=False)
+            
+            for i in range(30):
+                time.sleep(1)
+                try:
+                    ele = self.page.ele('@name=cf-turnstile-response')
+                    if ele and ele.value:
+                        logger.info("Turnstile token obtained.")
+                        return ele.value
+                except:
+                    pass
+                
+                if i > 5:
+                    try:
+                        cf_bypasser.click_verification_button(is_dfs=(i > 15))
+                    except:
+                        pass
+        except Exception as e:
+            logger.error(f"Turnstile refresh failed: {e}")
+        return None
+
+    def _wait_for_activation_link(self, username, max_wait_sec=300):
+        """轮询获取激活链接"""
+        logger.info(f"Waiting for email to {username}...")
+        start_time = time.time()
+        
+        master_email = self.config.get("master_email", "visafly666@gmail.com")
+        
+        while time.time() - start_time < max_wait_sec:
+            try:
+                utc_now_str = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
+                # 调用邮件 API
+                content = VSCloudApi.Instance().fetch_mail_content(
+                    master_email, 
+                    'donotreply at vfsglobal.com',
+                    username, 
+                    'Welcome', 
+                    'ActivateAccount', 
+                    utc_now_str, 
+                    expiry=60
+                )
+                
+                if content:
+                    soup = BeautifulSoup(content, "html.parser")
+                    link = soup.find("a", string="ActivateAccount")
+                    if link:
+                        raw_link = link["href"]
+                        clean_url = raw_link.replace(" ", "").replace("\n", "").replace("\r", "").strip()
+                        return clean_url
+            except Exception as e:
+                logger.warning(f"Error fetching email: {e}")
+            
+            time.sleep(15)
+            logger.info("Checking email again...")
+            
+        return None
+
+    def register(self, account):
+        """执行单个账号注册"""
+        website = self.config['website']
+        
+        try:
+            self._init_browser()
+            logger.info(f"Opening {website}")
+            
+            # 1. 设置超时
+            self.page.set.timeouts(page_load=30, script=30)
+            
+            # 2. 尝试打开页面
+            try:
+                self.page.get(website, retry=0, timeout=30)
+            except Exception:
+                # 超时强制停止,防止卡死
+                logger.warning(f"Page load timed out (Stopped manually). Checking URL...")
+                self.page.stop_loading()
+            
+            # 1. 过盾
+            cf_token = None
+            cf_bypasser = CloudflareBypasser(self.page, log=True)
+            
+            for _ in range(40):
+                time.sleep(1)
+                
+                current_url = self.page.url
+                if "page-not-found" in current_url:
+                    logger.error(f"❌ [BLOCKED] Redirected to 'Page Not Found' during check. Aborting.")
+                    return False
+                
+                # 如果页面标题变成 403 Forbidden
+                if "403" in self.page.title and "Just a moment" not in self.page.title:
+                    logger.error(f"❌ [BLOCKED] 403 Forbidden detected. Aborting.")
+                    return False
+                
+                self._handle_cookie_banner()
+                
+                # 尝试获取 Token
+                try:
+                    ele = self.page.ele('@name=cf-turnstile-response')
+                    if ele and ele.value:
+                        cf_token = ele.value
+                        break
+                except:
+                    pass
+                
+                # 尝试点击
+                try:
+                    cf_bypasser.click_verification_button()
+                except:
+                    pass
+            
+            if not cf_token:
+                raise BizLogicError("Failed to obtain initial Cloudflare token")
+
+            # 2. 构造注册请求
+            post_data = {
+                'emailid': account['username'],
+                'password': VFSHelper.encrypt_password(account['password']),
+                'confirmPassword': VFSHelper.encrypt_password(account['password']),
+                'processPerDataAgreed': True,
+                'intTransPerDataAgreed': True,
+                'termAndConditionAgreed': True,
+                'missioncode': account['mission_code'],
+                'countrycode': account['country_code'],
+                'languageCode': 'en',
+                'dialcode': str(account['phone_country_code']),
+                'contact': account['phone_number'],
+                'captcha_version': 'cloudflare-v1',
+                'captcha_api_key': cf_token,
+                'cultureCode': 'en-US',
+                'IsSpecialUser': False,
+            }
+            
+            headers = {
+                'content-type': 'application/json;charset=utf-8',
+                'accept': 'application/json, text/plain, */*',
+                'route': f"{account['country_code']}/en/{account['mission_code']}",
+                'clientsource': VFSHelper.get_client_source(),
+            }
+
+            logger.info(f"Submitting registration for {account['username']}")
+            resp = self._perform_js_fetch(
+                'POST', 
+                'https://lift-api.vfsglobal.com/user/registration', 
+                headers=headers, 
+                json_data=post_data
+            )
+            
+            resp_data = resp.json()
+            if resp_data.get("code") == "200":
+                logger.info("Registration API success. Waiting for email...")
+                
+                activate_link = self._wait_for_activation_link(account['username'])
+                if activate_link:
+                    logger.info(f"Activating account: {activate_link}")
+                    # 在当前浏览器上下文中打开链接,保持环境一致性
+                    # === 关键步骤:打开新标签页并验证结果 ===
+                    # 打开新标签页
+                    activate_tab = self.page.new_tab(activate_link)
+                    
+                    try:
+                        # 等待页面加载并查找 "Activation Successful" 文本
+                        # timeout=30 表示最多等待 30 秒
+                        logger.info("Waiting for 'Activation Successful' message on page...")
+                        
+                        # DrissionPage 查找包含特定文本的元素
+                        success_ele = activate_tab.ele('Activation Successful', timeout=30)
+                        
+                        if success_ele:
+                            logger.info(f"✅ Account {account['username']} activated successfully (Verified).")
+                            return True
+                        else:
+                            # 如果没找到成功提示,尝试读取页面内容找错误原因
+                            body_text = activate_tab.ele('tag:body').text[:200]
+                            logger.error(f"Activation verification failed. Page text: {body_text}")
+                            return False
+                            
+                    except Exception as e:
+                        logger.error(f"Error checking activation status: {e}")
+                        return False
+                    finally:
+                        # 无论成功失败,关闭激活标签页,切回主标签
+                        activate_tab.close()
+                else:
+                    logger.error("Timeout waiting for activation email.")
+            else:
+                logger.error(f"Registration failed: {resp_data}")
+
+        except Exception as e:
+            logger.error(f"Registration process exception: {e}", exc_info=True)
+        finally:
+            if self.page:
+                try:
+                    self.page.quit()
+                except:
+                    pass
+        return False
+
+# --- 主流程 ---
+
+def generate_account_details(config, pool_name):
+    """生成账号数据字典"""
+    rand_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
+    username = f"{pool_name}_{rand_suffix}@{config['email_domain']}.com"
+    phone = VFSHelper.generate_mobile_number(config['phone_country_code'])
+    
+    return {
+        'pool_name': pool_name,
+        'country_code': config['country_code'],
+        'mission_code': config['mission_code'],
+        'phone_country_code': config['phone_country_code'],
+        'phone_number': phone,
+        'username': username,
+        'password': VFSHelper.generate_password(),
+    }
+
+def main():
+    # 配置
+    config = {
+        "pool_name": "ie_fi",
+        "email_domain": "gmail-app", 
+        "master_email": "visafly666@gmail.com",
+        "proxy_url": "http://127.0.0.1:7890",
+        "target_count": 30,
+        "phone_country_code": 353,
+        "country_code": "irl",
+        "mission_code": "fin",
+        "website": "https://visa.vfsglobal.com/irl/en/fin/register",
+    }
+    
+    bot = VFSRegistrationBot(config)
+    success_accounts = []
+    
+    print(">>> Starting Registration Bot <<<")
+    
+    while len(success_accounts) < config['target_count']:
+        account = generate_account_details(config, config['pool_name'])
+        logger.info(f"Processing Account: {account['username']}")
+        
+        is_success = bot.register(account)
+        
+        if is_success:
+            success_accounts.append(account)
+            logger.info(f"Progress: {len(success_accounts)}/{config['target_count']}")
+            
+            # 保存结果到文件,防止中途退出丢失
+            with open("registered_accounts.json", "w", encoding='utf-8') as f:
+                json.dump(success_accounts, f, indent=4, ensure_ascii=False)
+        else:
+            logger.warning("Retrying with new account details...")
+
+        # 稍微暂停,避免请求过于频繁
+        time.sleep(5)
+
+    print(">>> All tasks completed <<<")
+
+if __name__ == "__main__":
+    main()