Hujiarui 9 годин тому
батько
коміт
0336ad372c
9 змінених файлів з 498 додано та 247 видалено
  1. 2 0
      .gitignore
  2. 2 2
      booker_order.py
  3. 5 5
      config/config.json
  4. 87 104
      plugins/tls_plugin.py
  5. 53 25
      sentinel.py
  6. 32 109
      tls_registration_bot.py
  7. 181 0
      utils/fake_utils.py
  8. 134 2
      utils/mouse.py
  9. 2 0
      vs_types.py

+ 2 - 0
.gitignore

@@ -77,3 +77,5 @@ data/**/*.png
 *.orig
 *.tmp
 *.temp
+
+downloads

+ 2 - 2
booker_order.py

@@ -119,7 +119,7 @@ class OrderBookerGCO:
                         t.instance.keep_alive()
                         if t.instance.health_check(): 
                             healthy_tasks.append(t)
-                            next_delay = random.randint(25, 35) 
+                            next_delay = random.randint(55, 65) 
                             t.next_remote_ping = now + next_delay
                             self._log(f"🛡️ Task={t.task_ref} keep-alive success. Next ping in {next_delay}s.")
                         else:
@@ -402,7 +402,7 @@ class OrderBookerGCO:
                             acceptable_routing_keys=acceptable_keys, 
                             source_queue=target_routing_key,
                             book_allowed=True,
-                            next_remote_ping=time.time() + random.randint(25, 35) 
+                            next_remote_ping=time.time() + random.randint(55, 65) 
                         )
                     )
                     queue_fail_key = f"vs:queue:failures:{target_routing_key}"

+ 5 - 5
config/config.json

@@ -78,7 +78,7 @@
             "enable": false,
             "need_account": true,
             "need_proxy": true,
-            "proxy_pool": ["proxy-cheap", "decodo"],
+            "proxy_pool": ["proxy-cheap"],
             "proxy_cd": 300,
             "session_max_life": 1800,
             "sentinel": {
@@ -974,7 +974,7 @@
             "enable": true,
             "need_account": true,
             "need_proxy": true,
-            "proxy_pool": ["proxy-cheap-good", "decodo-good"],
+            "proxy_pool": ["proxy-cheap"],
             "proxy_cd": 900,
             "session_max_life": 1800,
             "sentinel": {
@@ -986,7 +986,7 @@
             },
             "booker": {
                 "account_source": "order",
-                "target_instances": 1,
+                "target_instances": 2,
                 "account_cd": 1800,
                 "booking_cooldown": 10,
                 "max_bookings_per_account": 1
@@ -994,8 +994,8 @@
             "query_wait": {
                 "mode": "Random",
                 "fixed_wait": 10,
-                "random_min": 60,
-                "random_max": 300
+                "random_min": 55,
+                "random_max": 65
             },
             "plugin_config": {
                 "lib_path": "plugins",

+ 87 - 104
plugins/tls_plugin.py

@@ -17,7 +17,7 @@ from vs_plg import IVSPlg
 from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
 from toolkit.mihomo_tunnel import MihomoTunnel
-from utils.mouse import HumanMouse
+from utils.mouse import HumanMouse, MOUSE_PROFILES
 from utils.keyboard import HumanKeyboard
 from utils.fingerprint_utils import FingerprintGenerator
 
@@ -65,8 +65,8 @@ class TlsPlugin(IVSPlg):
     
         if not os.path.exists(self.root_workspace):
             os.makedirs(self.root_workspace)
-            
-        self.last_refresh_time = time.time()
+        
+        self.is_busy = False
         self.tunnel = None
         self.session_create_time: float = 0
 
@@ -87,42 +87,19 @@ class TlsPlugin(IVSPlg):
         self.free_config = config.free_config or {}
         
     def keep_alive(self):
-        """
-        统一保活机制:
-        - 距离上次刷新超过 10 分钟:执行完整页面刷新并检查 Session。
-        - 否则:随机发送 Fetch 小请求保活。
-        """
-        if time.time() - self.last_refresh_time >= 60*10:
-            try:
-                self._log("Cut all connections...")
-                self.tunnel.cut_all_connections()
-                self._log("refresh page...")
-                self.page.refresh()
-                self.page.wait.load_start(timeout=2)
-                self.page.wait.doc_loaded()
-                time.sleep(random.uniform(1, 3))
-                self._check_page_is_session_expired_or_invalid('Book your appointment', html=self.page.html)
-                self.last_refresh_time = time.time() 
-                self._log("refresh page finished")
-            except Exception as e:
-                self._log(f"refresh page error: {str(e)}")
-                self.is_healthy = False 
-        else:
-            choice = random.choice(['home', 'travel_groups'])
-            headers = {}
-            if choice == 'home':
-                url = "https://visas-fr.tlscontact.com/"
-            elif choice == 'travel_groups':
-                url = "https://visas-fr.tlscontact.com/en-us/travel-groups"
-                headers = {"cache-control": "max-age=0"}
-            try:
-                self._log(f"send keep alive fetch request ({choice})")
-                self._perform_request("GET", url, headers=headers)
-            except Exception as e:
-                self._log(f"send keep alive fetch error: {str(e)}")
-                self.is_healthy = False
+        try:
+            self.page.refresh()
+            self.page.wait.load_start(timeout=2)
+            self.page.wait.doc_loaded()
+            time.sleep(random.uniform(1, 3))
+            self._check_page_is_session_expired_or_invalid('Book your appointment', html = self.page.html)
+            self.simulate_random_human_mouse_move()
+        except SessionExpiredOrInvalidError as e:
+            self.is_healthy = False
+        except Exception as e:
+            self._log(f"Unexpected error in keep_alive: {e}")
             
-    def simulate_random_human_clicks(self, min_x=300, max_x=800, min_y=400, max_y=600, min_clicks=1, max_clicks=2):
+    def simulate_random_human_mouse_move(self, min_x=100, max_x=800, min_y=100, max_y=800, min_points=1, max_points=2):
         """
         在指定区域内模拟人类随机移动鼠标并点击数次。
         
@@ -130,23 +107,21 @@ class TlsPlugin(IVSPlg):
         :param max_x: X坐标最大范围
         :param min_y: Y坐标最小范围
         :param max_y: Y坐标最大范围
-        :param min_clicks: 随便点击的最少次数
-        :param max_clicks: 随便点击的最多次数
+        :param min_point: 随便移动的最少次数
+        :param max_point: 随便移动的最多次数
         """
-        click_count = random.randint(min_clicks, max_clicks)
-        self._log(f"Starting random human simulation: will click {click_count} times in the area.")
-        for i in range(click_count):
+        move_cnt = random.randint(min_points, max_points)
+        self._log(f"Starting random human simulation: will move {move_cnt} times in the area.")
+        for i in range(move_cnt):
             rand_x = random.randint(min_x, max_x)
             rand_y = random.randint(min_y, max_y)
-            self._log(f"[{i+1}/{click_count}] Moving mouse to ({rand_x}, {rand_y}) and clicking")
-            self.mouse.click(rand_x, rand_y, humanize=True)
-            if i < click_count - 1:
-                sleep_time = random.uniform(0.5, 1.8)
-                self._log(f"Resting for {sleep_time:.2f} seconds before next click...")
-                time.sleep(sleep_time)
-        self._log("Random human clicks simulation completed.")
+            self._log(f"[{i+1}/{move_cnt}] Moving mouse to ({rand_x}, {rand_y})")
+            self.mouse.move(rand_x, rand_y, humanize=True)
+        self._log("Random human move simulation completed.")
 
     def health_check(self) -> bool:
+        if self.is_busy:
+            return True
         if not self.is_healthy:
             return False
         if self.page is None:
@@ -322,7 +297,11 @@ class TlsPlugin(IVSPlg):
             cf_bypasser.handle_waiting_room()
             
             self._log("Init humanize tools...")
-            self.mouse = HumanMouse(self.page, debug=self.config.debug)
+            
+            profile_name = random.choice(list(MOUSE_PROFILES.keys()))
+            self._log(f"[HumanMouse] current mouse profiles: {profile_name}") 
+            
+            self.mouse = HumanMouse(self.page, timing=MOUSE_PROFILES[profile_name], debug=self.config.debug)
             self.keyboard = HumanKeyboard(self.page)
             viewport_width = self.page.rect.viewport_size[0]
             viewport_height = self.page.rect.viewport_size[1]
@@ -511,60 +490,63 @@ class TlsPlugin(IVSPlg):
     def query(self, apt_type: AppointmentType) -> VSQueryResult:
         res = VSQueryResult()
         res.success = False
-
-        slots = []
-        self._log(f"Executing silent JS fetch...") 
-        resp = self._perform_request("GET", self.page.url, retry_count=1)
-        self._check_page_is_session_expired_or_invalid('Book your appointment', resp.text)
-        slots = self._parse_appointment_slots(resp.text)
-        
-        if slots:
-            res.success = True
-            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 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! -> {slots}")
-        else:
-            self._log("No slots available.")
-            res.success = False
-            res.availability_status = AvailabilityStatus.NoneAvailable
+        self.is_busy = True
+        try:
+            slots = []
+            self._log(f"Executing silent JS fetch...") 
+            resp = self._perform_request("GET", self.page.url, retry_count=0)
+            self._check_page_is_session_expired_or_invalid('Book your appointment', resp.text)
+            slots = self._parse_appointment_slots(resp.text)
             
-        # TODO(TEST): 临时测试预约提交
-        if configure.TLS_TEST_BOOK_AFTER_QUERY:
-            test_date = "2026-06-10"
-            test_time = "09:00"
-            test_label = ""
-            test_dt = datetime.strptime(test_date, "%Y-%m-%d")
-            query_res = VSQueryResult()
-            query_res.success = True
-            query_res.availability_status = AvailabilityStatus.Available
-            query_res.earliest_date = test_dt
-            query_res.availability = [
-                DateAvailability(
-                    date=test_dt,
-                    times=[TimeSlot(time=test_time, label=test_label)]
-                )
-            ]
-            self._log(f"[TEST] using fixed June slot: {test_date} {test_time} {test_label}")
-            test_userinput = {
-                "support_pta": False,
-                "expected_end_date": "2100-01-01",
-                "expected_start_date": "2000-01-01"
-            }
-            try:
-                self.book(query_res, test_userinput)
-            except Exception as e:
-                self._log(f"[TEST] book() after query failed: {e}")
-            self.is_healthy = False
+            if slots:
+                res.success = True
+                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 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! -> {slots}")
+            else:
+                self._log("No slots available.")
+                res.success = False
+                res.availability_status = AvailabilityStatus.NoneAvailable
+                
+            # TODO(TEST): 临时测试预约提交
+            if configure.TLS_TEST_BOOK_AFTER_QUERY:
+                test_date = "2026-06-10"
+                test_time = "09:00"
+                test_label = ""
+                test_dt = datetime.strptime(test_date, "%Y-%m-%d")
+                query_res = VSQueryResult()
+                query_res.success = True
+                query_res.availability_status = AvailabilityStatus.Available
+                query_res.earliest_date = test_dt
+                query_res.availability = [
+                    DateAvailability(
+                        date=test_dt,
+                        times=[TimeSlot(time=test_time, label=test_label)]
+                    )
+                ]
+                self._log(f"[TEST] using fixed June slot: {test_date} {test_time} {test_label}")
+                test_userinput = {
+                    "support_pta": False,
+                    "expected_end_date": "2100-01-01",
+                    "expected_start_date": "2000-01-01"
+                }
+                try:
+                    self.book(query_res, test_userinput)
+                except Exception as e:
+                    self._log(f"[TEST] book() after query failed: {e}")
+                self.is_healthy = False
+        finally:
+            self.is_busy = False
         return res
     
     def book_bak(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
@@ -954,6 +936,7 @@ class TlsPlugin(IVSPlg):
         try:
             self._log("Refreshing page to trigger Cloudflare...")
             self.page.refresh()
+            time.sleep(5)
             cf = CloudflareBypasser(self.page, log=self.config.debug)
             success = cf.bypass(max_retry=6)
             

+ 53 - 25
sentinel.py

@@ -27,6 +27,7 @@ class SentinelGCO:
         # 1. 全局建连退避:起步 1 分钟,封顶 1 小时 (保护登录接口)
         self.group_backoff = ExponentialBackoff(base_delay=60.0, max_delay=3600.0, factor=2.0)
         self.m_last_spawn_time = 0.0
+        self.m_last_group_query_time = 0.0
 
     def _log(self, message):
         if self.m_logger:
@@ -86,8 +87,8 @@ class SentinelGCO:
 
     def _monitor_loop(self):
         self._log("Monitor loop started.")
-        rng = random.Random()
         
+        self.m_last_group_query_time = 0.0 
         while not self.m_stop_event.is_set():
             try:
                 time.sleep(0.5)
@@ -99,6 +100,11 @@ class SentinelGCO:
                 active_tasks = []
                 dead_tasks = []
                 for t in tasks_to_check:
+                    
+                    if not t.is_querying:
+                        active_tasks.append(t)
+                        continue
+                    
                     try:
                         if t.instance.health_check():
                             active_tasks.append(t)
@@ -119,10 +125,23 @@ class SentinelGCO:
                     with self.m_lock:
                         self.m_tasks = [t for t in self.m_tasks if t in active_tasks]
                 
+                if not active_tasks:
+                    continue
+
+                avg_interval = self._get_average_interval()
+                global_gap = max(1.0, avg_interval / len(active_tasks)) 
+                
+                active_tasks.sort(key=lambda x: x.next_run)
                 for task in active_tasks:
                     if now < task.next_run:
                         continue
                     
+                    if task.is_querying:
+                        continue
+                    
+                    if now - self.m_last_group_query_time < global_gap:
+                        break
+
                     apt_types = self.m_cfg.appointment_types
                     if not apt_types:
                         continue
@@ -136,30 +155,39 @@ class SentinelGCO:
                     elif mode == QueryWaitMode.Fixed:
                         interval = task.qw_cfg.fixed_wait
                     elif mode == QueryWaitMode.Random:
-                        interval = rng.randint(task.qw_cfg.random_min, task.qw_cfg.random_max)
-                    task.next_run = time.time() + interval
-                    
-                    try:
-                        VSCloudApi.Instance().slot_refresh_start(apt_type.routing_key, country=apt_type.country, city=apt_type.city, visa_type=apt_type.visa_type)
-                        result = task.instance.query(apt_type)
-                        result.apt_type = apt_type
-                        if result.success:
-                            ttl = self.m_cfg.sentinel.signal_ttl
-                            self._log(f"🔥 SLOT FOUND! Writing signal to Redis (TTL: {ttl}s)")
-                            payload = {
-                                "group_id": self.m_cfg.identifier,
-                                "apt_type": apt_type.model_dump(),
-                                "query_result": result.to_snapshot_payload(),
-                                "timestamp": now
-                            }
-                            redis_key = self._get_redis_key(apt_type.routing_key)
-                            self.redis_client.setex(redis_key, ttl, json.dumps(payload))
-                            payload["query_result"]["website"] = self.m_cfg.website
-                            VSCloudApi.Instance().slot_snapshot_report(payload["query_result"])
-                        VSCloudApi.Instance().slot_refresh_success(apt_type.routing_key)
-                    except Exception as e:
-                        self._log(f"Query exception: {e}")
-                        VSCloudApi.Instance().slot_refresh_fail(apt_type.routing_key, error=str(e))
+                        interval = random.randint(task.qw_cfg.random_min, task.qw_cfg.random_max)
+
+                    task.is_querying = True 
+                    self.m_last_group_query_time = now
+
+                    def _query_job(current_task=task, a_type=apt_type, wait_gap=interval):
+                        try:
+                            VSCloudApi.Instance().slot_refresh_start(a_type.routing_key, country=a_type.country, city=a_type.city, visa_type=a_type.visa_type)
+                            result = current_task.instance.query(a_type)
+                            result.apt_type = a_type
+                            if result.success:
+                                ttl = self.m_cfg.sentinel.signal_ttl
+                                self._log(f"🔥 SLOT FOUND! Writing signal to Redis (TTL: {ttl}s)")
+                                payload = {
+                                    "group_id": self.m_cfg.identifier,
+                                    "apt_type": a_type.model_dump(),
+                                    "query_result": result.to_snapshot_payload(),
+                                    "timestamp": time.time()
+                                }
+                                redis_key = self._get_redis_key(a_type.routing_key)
+                                self.redis_client.setex(redis_key, ttl, json.dumps(payload))
+                                payload["query_result"]["website"] = self.m_cfg.website
+                                VSCloudApi.Instance().slot_snapshot_report(payload["query_result"])
+                            VSCloudApi.Instance().slot_refresh_success(a_type.routing_key)
+                        except Exception as e:
+                            self._log(f"Query exception: {e}")
+                            VSCloudApi.Instance().slot_refresh_fail(a_type.routing_key, error=str(e))
+                        finally:
+                            current_task.next_run = time.time() + wait_gap
+                            current_task.is_querying = False
+                    ThreadPool.getInstance().enqueue(_query_job)
+                    break
+
             except Exception as e:
                 self._log(f"Monitor loop error: {e}")
                 time.sleep(2)

+ 32 - 109
tls_registration_bot.py

@@ -14,107 +14,17 @@ from datetime import datetime, timedelta
 from typing import Optional, Dict
 from DrissionPage.common import Keys
 from DrissionPage import ChromiumPage, ChromiumOptions
+
+import configure
 from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
 from toolkit.vs_cloud_api import VSCloudApi
-from toolkit.proxy_tunnel import ProxyTunnel
+from toolkit.mihomo_tunnel import MihomoTunnel
 from vs_types import NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 from utils.mouse import HumanMouse
 from utils.keyboard import HumanKeyboard
 from utils.scroll import HumanScroll
 from utils.fingerprint_utils import FingerprintGenerator
-
-
-def generate_random_account_detail() -> Dict:
-    """基于 randomuser 生成随机账户信息,并保留指定固定字段。"""
-    today = datetime.today()
-    base_date = today + timedelta(days=random.randint(20, 90))
-    arrival_schengen_area_date = base_date.strftime("%Y-%m-%d")
-    departure_origin_date = (base_date - timedelta(days=random.randint(0, 2))).strftime("%Y-%m-%d")
-    departure_schengen_area_date = (base_date + timedelta(days=random.randint(2, 15))).strftime("%Y-%m-%d")
-    
-    start_date = today - timedelta(days=5*365)
-    random_days = random.randint(0, (today - start_date).days)
-    passport_issue_date = start_date + timedelta(days=random_days)
-    passport_expiry_date = passport_issue_date.replace(year=passport_issue_date.year + 10) - timedelta(days=1)
-
-    default_payload = {
-        "pool_name": "tls.gb.fr.sentinel",
-        "email": "user@gmail-app.com",
-        "pwd": "Visafly@111",
-        "location": "Wandsworth (London)",
-        "visa_type": "Short stay (<90 days) - Tourism",
-        "travel_purpose": "Tourism / Private visit",
-        # FRA1LO20260411910
-        "application_form_id": "FRA1LO20260411910",
-        "last_name": "Smith",
-        "first_name": "James",
-        "gender": "Male",
-        "birthday": "1998-11-20",
-        "nationality": "United Kingdom",
-        "province_residence": "London",
-        "passport_type": "Ordinary passport",
-        "passport_no": "EJ1934054",
-        "passport_issue_date": "2025-10-11",
-        "passport_expiry_date": "2035-10-10",
-        "phone_country_code": "44",
-        "phone_number": "7400000000",
-        "departure_origin_date": "2026-07-30",
-        "arrival_schengen_area_date": "2026-07-30",
-        "departure_schengen_area_date": "2026-08-04",
-    }
-
-    try:
-        # 每次请求不传 seed,randomuser 会返回不同用户
-        resp = requests.get("https://randomuser.me/api/?nat=gb", timeout=15)
-        resp.raise_for_status()
-        raw = resp.json()
-        result = (raw.get("results") or [None])[0]
-        if not result:
-            return default_payload
-
-        first_name = result.get("name", {}).get("first") or default_payload["first_name"]
-        last_name = result.get("name", {}).get("last") or default_payload["last_name"]
-        gender_raw = (result.get("gender") or "").strip().lower()
-        gender = "Male" if gender_raw == "male" else "Female"
-        birthday_raw = result.get("dob", {}).get("date", "")
-        birthday = birthday_raw[:10] if birthday_raw else default_payload["birthday"]
-        city = default_payload["location"]
-        state = result.get("location", {}).get("state") or default_payload["province_residence"]
-        country = result.get("location", {}).get("country") or default_payload["nationality"]
-        phone_raw = result.get("cell") or result.get("phone") or default_payload["phone_number"]
-        phone_number = re.sub(r"\D", "", phone_raw) or default_payload["phone_number"]
-
-        email_prefix = re.sub(r"[^a-z0-9]", "", f"{first_name}{last_name}".lower())
-        if not email_prefix:
-            email_prefix = f"user{random.randint(100000, 999999)}"
-        email = f"{email_prefix}{random.randint(1000, 9999)}@gmail-app.com"
-
-        return {
-            "pool_name": "tls.gb.fr.sentinel",
-            "email": email,
-            "pwd": "Visafly@111",
-            "location": city,
-            "visa_type": "Short stay (<90 days) - Tourism",
-            "travel_purpose": "Tourism / Private visit",
-            "application_form_id": "FRA1LO" + "".join(str(random.randint(0, 9)) for _ in range(11)),
-            "last_name": last_name,
-            "first_name": first_name,
-            "gender": gender,
-            "birthday": birthday,
-            "nationality": country,
-            "province_residence": "London",
-            "passport_type": "Ordinary passport",
-            "passport_no": "".join(random.choices("ABCDEFGHIJKLMNOPQRSTUVWXYZ", k=2)) + "".join(random.choices("0123456789", k=7)),
-            "passport_issue_date": passport_issue_date.strftime("%Y-%m-%d"),
-            "passport_expiry_date": passport_expiry_date.strftime("%Y-%m-%d"),
-            "phone_country_code": "44",
-            "phone_number": phone_number,
-            "departure_origin_date": departure_origin_date,
-            "arrival_schengen_area_date": arrival_schengen_area_date,
-            "departure_schengen_area_date": departure_schengen_area_date,
-        }
-    except Exception:
-        return default_payload
+from utils.fake_utils import generate_random_account_detail
     
 def load_proxies(pool_name):
     """从 config/proxies.json 读取对应的代理池"""
@@ -178,20 +88,39 @@ class TlsRegistrator:
         co.set_local_port(port)
         co.set_user_data_path(self.workspace)
         
-        chrome_path = os.getenv("CHROME_BIN")
+        chrome_path = configure.CHROME_PATH
+        if not chrome_path:
+            chrome_path = os.getenv("CHROME_BIN")
         if chrome_path and os.path.exists(chrome_path):
             co.set_paths(browser_path=chrome_path)
         
-        # 2. 代理配置 (支持账号密码)
         if self.proxy_config and self.proxy_config.get("ip"):
             p = self.proxy_config
-            if p.get("username") and p.get("password"):
-                self.tunnel = ProxyTunnel(p['ip'], p['port'], p['username'], p['password'])
+
+            if p.get('username') and p.get('password'):
+                self._log(f"Starting Proxy Tunnel for {p.get('ip')}...")
+                exit_node = {
+                    "name": "ExitNode",
+                    "type": p.get('proto'),
+                    "server": p.get('ip'),
+                    "port": p.get('port'),
+                    "username": p.get('username'),
+                    "password": p.get('password')
+                }
+                relay_node = None
+                if configure.MIHOMO_RELAY_NODES:
+                    relay_node = random.choice(configure.MIHOMO_RELAY_NODES)
+                mihomo_path = configure.MIHOMO_BIN_PATH
+                if not mihomo_path:
+                    mihomo_path = os.getenv("MIHOMO_BIN")
+                if not mihomo_path:
+                    raise BizLogicError(message='Mihomo path is null, You need set mihomo bin path in configure or os env')
+                self.tunnel = MihomoTunnel(mihomo_path, exit_node=exit_node, relay_node=relay_node)
                 local_proxy = self.tunnel.start()
                 self._log(f"Tunnel started at {local_proxy}")
                 co.set_argument(f'--proxy-server={local_proxy}')
             else:
-                proxy_str = f"{p.get('proto', 'http')}://{p['ip']}:{p['port']}"
+                proxy_str = f"{p.get('proto')}://{p.get('ip')}:{p.get('port')}"
                 co.set_argument(f'--proxy-server={proxy_str}')
         else:
             self._log("[WARN] No proxy configured!")
@@ -218,7 +147,7 @@ class TlsRegistrator:
         cf_bypasser.handle_waiting_room()
         
         self._log("正在初始化拟人化工具...")
-        self.mouse = HumanMouse(self.page, debug=True)
+        self.mouse = HumanMouse(self.page, debug=False)
         self.keyboard = HumanKeyboard(self.page)
         self._log("随机化鼠标开始位置...")
         viewport_width = self.page.rect.viewport_size[0]
@@ -311,14 +240,9 @@ class TlsRegistrator:
                 
         self._log("提交注册...")
         btn_e = self.page.ele(btn_selector)
-        btn_e.scroll.to_see(center=True) 
         time.sleep(random.uniform(0.3, 0.6))
 
-        btn_mx, btn_my = int(btn_e.rect.midpoint[0]), int(btn_e.rect.midpoint[1])
-        self.mouse.move(btn_mx, btn_my, humanize=True)
-        
-        time.sleep(0.5)
-        btn_e.click(by_js=True)
+        self.mouse.human_click_ele(btn_e)
 
         self._log("正在等待验证结果 (最多10秒)...")
         success_dialog = self.page.wait.ele_displayed('tag:h1@text():Check your email inbox', timeout=10)
@@ -692,7 +616,7 @@ class TlsRegistrator:
             
 def register_worker(proxy_config, tls_url, capsolver_key):
     """单个注册任务的工作线程函数"""
-    account_detail = generate_random_account_detail()
+    account_detail = generate_random_account_detail('CN')
     bot = None
     try:
         bot = TlsRegistrator(
@@ -739,8 +663,7 @@ def main():
     # ================= 环境变量读取 =================
     capsolver_key = os.getenv("CAPSOLVER_KEY")
     if not capsolver_key:
-        print("[FATAL] 未设置环境变量 CAPSOLVER_KEY,程序退出!")
-        exit(1)
+        capsolver_key = "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A"
 
     print(f"[*] 启动注册任务 | 目标数: {args.target} | 并发数: {args.concurrency} | 代理池: {args.pool} | URL: {args.url}")
     

+ 181 - 0
utils/fake_utils.py

@@ -0,0 +1,181 @@
+import random
+import re
+import requests
+from datetime import datetime, timedelta
+from typing import Dict, Any, Tuple
+
+# ==========================================
+# 1. 常量与国家配置区
+# ==========================================
+
+DEFAULT_PASSWORD = "Visafly@111"
+VISA_TYPE = "Short stay (<90 days) - Tourism"
+TRAVEL_PURPOSE = "Tourism / Private visit"
+PASSPORT_TYPE = "Ordinary passport"
+
+# 国家专属配置字典,将差异化数据隔离,极大提升可维护性
+COUNTRY_CONFIGS = {
+    "CN": {
+        "nat_code": None,  # randomuser 不支持 CN,置为 None
+        "pool_name": "tls.cn.sha.fr.sentinel",
+        "location": "Shanghai",
+        "province_residence": "Shanghai",
+        "nationality": "China",
+        "phone_country_code": "86",
+        # --- 本地化生成规则(针对 API 不支持的国家) ---
+        "local_first_names": ["Wei", "Fang", "Jian", "Hui", "Lei", "Ting", "Peng", "Xia", "Bin", "Jie", "San", "Ming"],
+        "local_last_names": ["Wang", "Li", "Zhang", "Liu", "Chen", "Yang", "Huang", "Zhao", "Wu", "Zhou"],
+        "phone_prefix": ["138", "139", "150", "151", "180", "189"], # 中国手机号前缀
+        "phone_length": 11,
+        # -----------------------------------------------
+        "default_first_name": "San",
+        "default_last_name": "Zhang",
+        "default_phone": "13800000000"
+    },
+    "GB": {
+        "nat_code": "gb",  # API 支持 GB,直接依赖 API 生成姓名
+        "pool_name": "tls.gb.lon.fr.sentinel",
+        "location": "London",
+        "province_residence": "London",
+        "nationality": "United Kingdom",
+        "phone_country_code": "44",
+        "default_first_name": "James",
+        "default_last_name": "Smith",
+        "default_phone": "7400000000"
+    }
+}
+
+# ==========================================
+# 2. 辅助生成函数 (单一职责)
+# ==========================================
+
+def _fetch_random_user_data(nat_code: str = None) -> Dict[str, Any]:
+    """
+    请求 randomuser API。
+    如果 nat_code 为空,则请求全局随机用户(仅用于借用其随机的出生日期和性别)。
+    """
+    url = f"https://randomuser.me/api/?nat={nat_code}" if nat_code else "https://randomuser.me/api/"
+    try:
+        resp = requests.get(url, timeout=10)
+        resp.raise_for_status()
+        raw = resp.json()
+        results = raw.get("results")
+        if results and isinstance(results, list):
+            return results[0]
+    except Exception:
+        pass
+    return {}
+
+def _generate_localized_name(config: Dict[str, Any], api_user: Dict[str, Any]) -> Tuple[str, str]:
+    """生成姓名:优先使用本地词库,否则使用 API 返回值"""
+    if "local_last_names" in config and "local_first_names" in config:
+        return random.choice(config["local_first_names"]), random.choice(config["local_last_names"])
+    
+    first = api_user.get("name", {}).get("first") or config["default_first_name"]
+    last = api_user.get("name", {}).get("last") or config["default_last_name"]
+    return first, last
+
+def _generate_localized_phone(config: Dict[str, Any], api_user: Dict[str, Any]) -> str:
+    """生成手机号:优先使用本地规则,否则清洗 API 返回值"""
+    if "phone_prefix" in config:
+        prefix = random.choice(config["phone_prefix"])
+        suffix_len = config.get("phone_length", 11) - len(prefix)
+        suffix = "".join(str(random.randint(0, 9)) for _ in range(suffix_len))
+        return f"{prefix}{suffix}"
+    
+    phone_raw = api_user.get("cell") or api_user.get("phone") or ""
+    phone = re.sub(r"\D", "", phone_raw)
+    return phone or config["default_phone"]
+
+def _generate_random_dates() -> Dict[str, str]:
+    """生成合法的随机日期集合(出行、护照等)"""
+    today = datetime.today()
+    base_date = today + timedelta(days=random.randint(20, 90))
+    
+    # 护照日期(避免闰年 replace 报错,采用天数计算)
+    start_date = today - timedelta(days=5 * 365)
+    passport_issue = start_date + timedelta(days=random.randint(0, (today - start_date).days))
+    passport_expiry = passport_issue + timedelta(days=10 * 365 + 2) - timedelta(days=1)
+    
+    return {
+        "departure_origin_date": (base_date - timedelta(days=random.randint(0, 2))).strftime("%Y-%m-%d"),
+        "arrival_schengen_area_date": base_date.strftime("%Y-%m-%d"),
+        "departure_schengen_area_date": (base_date + timedelta(days=random.randint(2, 15))).strftime("%Y-%m-%d"),
+        "passport_issue_date": passport_issue.strftime("%Y-%m-%d"),
+        "passport_expiry_date": passport_expiry.strftime("%Y-%m-%d"),
+    }
+
+def _generate_email(first_name: str, last_name: str) -> str:
+    """基于姓名生成随机邮箱"""
+    email_prefix = re.sub(r"[^a-z0-9]", "", f"{first_name}{last_name}".lower())
+    if not email_prefix:
+        email_prefix = f"user{random.randint(100000, 999999)}"
+    return f"{email_prefix}{random.randint(1000, 9999)}@gmail-app.com"
+
+# ==========================================
+# 3. 主函数
+# ==========================================
+
+def generate_random_account_detail(country_code: str = "CN") -> Dict[str, Any]:
+    """
+    基于 randomuser 和指定国家配置生成随机账户信息。
+    """
+    config = COUNTRY_CONFIGS.get(country_code.upper())
+    if not config:
+        raise ValueError(f"Unsupported country code: {country_code}")
+
+    # 1. 抓取 API(即使不支持的国家,也可借用其随机返回的年龄/性别)
+    api_user = _fetch_random_user_data(config.get("nat_code"))
+
+    # 2. 解析基础数据(本地化拦截器)
+    first_name, last_name = _generate_localized_name(config, api_user)
+    phone_number = _generate_localized_phone(config, api_user)
+    
+    gender_raw = str(api_user.get("gender", "")).strip().lower()
+    gender = "Male" if gender_raw == "male" else "Female"
+    
+    birthday_raw = api_user.get("dob", {}).get("date", "")
+    birthday = birthday_raw[:10] if birthday_raw else "1990-01-01"
+    
+    # 3. 各种衍生计算
+    email = _generate_email(first_name, last_name)
+    dates = _generate_random_dates()
+    
+    app_form_suffix = "".join(str(random.randint(0, 9)) for _ in range(11))
+    passport_no = "".join(random.choices("ABCDEFGHIJKLMNOPQRSTUVWXYZ", k=2)) + \
+                  "".join(random.choices("0123456789", k=7))
+
+    # LO 伦敦 DB 都柏林 PB 北京 SH 上海
+    # 4. 组装组装并返回
+    return {
+        "pool_name": config["pool_name"],
+        "email": email,
+        "pwd": DEFAULT_PASSWORD,
+        "location": config["location"],
+        "visa_type": VISA_TYPE,
+        "travel_purpose": TRAVEL_PURPOSE,
+        "application_form_id": f"FRA1SH{app_form_suffix}",
+        "last_name": last_name,
+        "first_name": first_name,
+        "gender": gender,
+        "birthday": birthday,
+        "nationality": config["nationality"],
+        "province_residence": config["province_residence"],
+        "passport_type": PASSPORT_TYPE,
+        "passport_no": passport_no,
+        "passport_issue_date": dates["passport_issue_date"],
+        "passport_expiry_date": dates["passport_expiry_date"],
+        "phone_country_code": config["phone_country_code"],
+        "phone_number": phone_number,
+        "departure_origin_date": dates["departure_origin_date"],
+        "arrival_schengen_area_date": dates["arrival_schengen_area_date"],
+        "departure_schengen_area_date": dates["departure_schengen_area_date"],
+    }
+
+# 测试代码
+if __name__ == "__main__":
+    print("--- 🇨🇳 中国数据 (借用API年龄性别 + 本地化姓名/手机) ---")
+    print(generate_random_account_detail("CN"))
+
+    print("\n--- 🇬🇧 英国数据 (完全依赖API生成) ---")
+    print(generate_random_account_detail("GB"))

+ 134 - 2
utils/mouse.py

@@ -31,6 +31,7 @@ from typing import Optional, Tuple
 
 from DrissionPage import ChromiumPage
 
+from utils.scroll import HumanScroll
 from utils.math_utils import (
     bezier_2d,
     fitts_duration,
@@ -93,6 +94,136 @@ class MouseTimingConfig:
 
     min_duration: float = 0.08
     max_duration: float = 2.2
+    
+
+# ==========================================
+# 10套拟人化鼠标行为配置预设 (User Profiles)
+# ==========================================
+
+MOUSE_PROFILES = {
+    # 1. 【电竞玩家 / 熟练用户】
+    # 特征:移动极快,轨迹趋近直线,因为速度太快偶尔会有轻微过冲,点击干脆利落。
+    "gamer": MouseTimingConfig(
+        fitts_a=0.040, fitts_b=0.090,  # 移动时间极短
+        curvature_min=0.05, curvature_max=0.15,  # 轨迹很直
+        tremor_amplitude=0.40,  # 手极其稳
+        overshoot_probability=0.25, overshoot_distance_min=0.02, overshoot_distance_max=0.06, # 容易滑过头但偏差小
+        pre_click_pause_min=0.02, pre_click_pause_max=0.06,  # 几乎不犹豫直接点
+        click_hold_min=0.02, click_hold_max=0.06,  # 点击非常轻快
+        micro_pause_probability=0.02,  # 中途基本不犹豫
+        min_duration=0.05, max_duration=1.2
+    ),
+
+    # 2. 【老年人 / 鼠标新手】
+    # 特征:移动缓慢,轨迹弧度大且手抖严重,经常在半路停顿,点击按压时间很长。
+    "elderly": MouseTimingConfig(
+        fitts_a=0.150, fitts_b=0.250,  # 移动非常耗时
+        curvature_min=0.15, curvature_max=0.40,  # 巨大的弧线
+        tremor_amplitude=1.80,  # 手部抖动明显
+        overshoot_probability=0.05,  # 慢到基本不会过冲
+        pre_click_pause_min=0.15, pre_click_pause_max=0.35,  # 到了目标要看半天才点
+        click_hold_min=0.10, click_hold_max=0.25,  # 按下去很久才松开
+        micro_pause_probability=0.25, micro_pause_min=0.05, micro_pause_max=0.15, # 半路经常停下来找光标
+        min_duration=0.2, max_duration=3.5
+    ),
+
+    # 3. 【触控板 / 笔记本用户】
+    # 特征:轨迹不对称,经常因为手指滑动到边缘而产生“微小停顿”(抬起手指重新滑动),无明显过冲。
+    "trackpad": MouseTimingConfig(
+        fitts_a=0.080, fitts_b=0.180,
+        curvature_asymmetry=0.8,  # 触控板滑动极不对称
+        tremor_amplitude=0.50,
+        overshoot_probability=0.05,  # 触控板有天然加速度,一般不会过冲
+        micro_pause_probability=0.35, micro_pause_min=0.03, micro_pause_max=0.12, # 频繁微停顿(手指滑出边缘重置)
+        drag_start_pause_max=0.25, drag_end_pause_max=0.20,  # 触控板拖拽很困难,停顿长
+        double_click_interval_min=0.08, double_click_interval_max=0.15
+    ),
+
+    # 4. 【急躁 / 喝了咖啡的用户】
+    # 特征:速度快但不精确,手抖严重,极度容易“过冲”并需要大幅度回调,点击间隔很短。
+    "caffeinated": MouseTimingConfig(
+        fitts_a=0.050, fitts_b=0.110,
+        curvature_min=0.10, curvature_max=0.25,
+        tremor_amplitude=1.50,  # 兴奋状态,手抖大
+        overshoot_probability=0.45, overshoot_distance_min=0.05, overshoot_distance_max=0.15, # 疯狂冲过头
+        pre_click_pause_min=0.01, pre_click_pause_max=0.05,
+        double_click_interval_min=0.03, double_click_interval_max=0.07, # 连击速度极快
+        min_duration=0.05, max_duration=1.5
+    ),
+
+    # 5. 【心不在焉 / 看剧摸鱼的用户】
+    # 特征:移动到一半可能会长时间停住(抬头看其他屏幕),点击前犹豫时间极长。
+    "distracted": MouseTimingConfig(
+        fitts_a=0.090, fitts_b=0.160,
+        tremor_amplitude=0.90,
+        overshoot_probability=0.15,
+        pre_click_pause_min=0.20, pre_click_pause_max=0.80, # 到了按钮上发呆很久才点
+        micro_pause_probability=0.15, micro_pause_min=0.10, micro_pause_max=0.60, # 中途长时间停顿
+        click_hold_min=0.05, click_hold_max=0.15,
+        max_duration=4.0
+    ),
+
+    # 6. 【疲惫 / 深夜工作的用户】
+    # 特征:整体响应迟缓,弧度大(懒得走直线),拖拽和点击都显得很“沉重”。
+    "tired": MouseTimingConfig(
+        fitts_a=0.120, fitts_b=0.200,
+        curvature_min=0.20, curvature_max=0.35, # 懒散的大弧线
+        tremor_amplitude=1.00,
+        overshoot_probability=0.20,
+        pre_click_pause_min=0.10, pre_click_pause_max=0.25,
+        click_hold_min=0.10, click_hold_max=0.22, # 手指沉重,按压长
+        micro_pause_probability=0.10,
+        max_duration=3.0
+    ),
+
+    # 7. 【设计师 / 精准对齐用户】
+    # 特征:移动平滑优美,手极其稳(无微颤),几乎不产生过冲,会花额外时间精准停留在目标正中心。
+    "designer": MouseTimingConfig(
+        fitts_a=0.100, fitts_b=0.170,
+        curvature_min=0.15, curvature_max=0.25, # 优美圆滑的曲线
+        tremor_amplitude=0.20,  # 极低的像素级抖动
+        overshoot_probability=0.02, # 绝对不过冲
+        pre_click_pause_min=0.08, pre_click_pause_max=0.15, # 确认对准后再点
+        micro_pause_probability=0.0,
+        frame_interval=0.008, frame_interval_variance=0.002 # 高刷新率的高端鼠标
+    ),
+
+    # 8. 【笨拙重手 / “重装坦克”用户】
+    # 特征:速度中等但每次动作幅度都偏大,容易大幅度偏离,点击鼠标时力气很大(长hold)。
+    "clumsy": MouseTimingConfig(
+        fitts_a=0.080, fitts_b=0.150,
+        tremor_amplitude=1.20,
+        overshoot_probability=0.35, overshoot_distance_min=0.08, overshoot_distance_max=0.20, # 容易大幅度滑偏
+        pre_click_pause_min=0.06, pre_click_pause_max=0.15,
+        click_hold_min=0.12, click_hold_max=0.25, # 重按
+        drag_start_pause_max=0.25,
+        min_duration=0.1
+    ),
+
+    # 9. 【旧电脑 / 卡顿网络用户】
+    # 特征:轨迹不平滑,帧率低且波动极大(模拟系统卡顿导致的鼠标瞬间瞬移和丢帧)。
+    "laggy_pc": MouseTimingConfig(
+        fitts_a=0.070, fitts_b=0.150,
+        frame_interval=0.035, frame_interval_variance=0.025, # 极度丢帧、跳跃
+        tremor_amplitude=0.70,
+        overshoot_probability=0.10,
+        click_hold_min=0.05, click_hold_max=0.15,
+        micro_pause_probability=0.20, micro_pause_min=0.02, micro_pause_max=0.05, # 卡顿造成的强制停顿
+    ),
+
+    # 10. 【中规中矩的普通用户】
+    # 特征:最标准的参数,各项指标居中。
+    "average_joe": MouseTimingConfig(
+        fitts_a=0.070, fitts_b=0.150,
+        frame_interval=0.012, frame_interval_variance=0.004,
+        curvature_min=0.10, curvature_max=0.28,
+        tremor_amplitude=0.85,
+        overshoot_probability=0.22, overshoot_distance_min=0.03, overshoot_distance_max=0.10,
+        pre_click_pause_min=0.04, pre_click_pause_max=0.16,
+        click_hold_min=0.04, click_hold_max=0.12,
+        micro_pause_probability=0.08
+    )
+}
 
 
 class HumanMouse:
@@ -142,6 +273,7 @@ class HumanMouse:
         self._debug = debug
         self._debug_initialized = False
         self._session_profile = self._build_session_profile()
+        self._scroll = HumanScroll(self._page)
 
     @property
     def timing(self) -> MouseTimingConfig:
@@ -174,8 +306,8 @@ class HumanMouse:
         :param element: DrissionPage 的元素对象
         """
         # 1. 强制滚动,把元素尽量移到屏幕正中间,防止被顶部导航栏或底部悬浮窗遮盖
-        element.scroll.to_see(center=True)
-        time.sleep(0.5)  # 等待滚动动画完成,防止移动时坐标还在变化
+        self._scroll.scroll_to_element(element)
+        time.sleep(random.uniform(0.2, 0.6))  # 等待滚动动画完成,防止移动时坐标还在变化
         
         # 2. 【核心修复】获取相对于当前屏幕视口的坐标,而不是页面绝对坐标
         # 注意:这里使用的是 viewport_midpoint

+ 2 - 0
vs_types.py

@@ -206,6 +206,8 @@ class Task(BaseModel):
     successful_bookings: int = 0
     # 下一次允许心跳时间
     next_remote_ping: float = 0.0
+    # 是否正在查询
+    is_querying: bool = False
     
     model_config = {
         "underscore_attrs_are_private": True,