Hujiarui hace 9 horas
padre
commit
0336ad372c
Se han modificado 9 ficheros con 498 adiciones y 247 borrados
  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
 *.orig
 *.tmp
 *.tmp
 *.temp
 *.temp
+
+downloads

+ 2 - 2
booker_order.py

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

+ 5 - 5
config/config.json

@@ -78,7 +78,7 @@
             "enable": false,
             "enable": false,
             "need_account": true,
             "need_account": true,
             "need_proxy": true,
             "need_proxy": true,
-            "proxy_pool": ["proxy-cheap", "decodo"],
+            "proxy_pool": ["proxy-cheap"],
             "proxy_cd": 300,
             "proxy_cd": 300,
             "session_max_life": 1800,
             "session_max_life": 1800,
             "sentinel": {
             "sentinel": {
@@ -974,7 +974,7 @@
             "enable": true,
             "enable": true,
             "need_account": true,
             "need_account": true,
             "need_proxy": true,
             "need_proxy": true,
-            "proxy_pool": ["proxy-cheap-good", "decodo-good"],
+            "proxy_pool": ["proxy-cheap"],
             "proxy_cd": 900,
             "proxy_cd": 900,
             "session_max_life": 1800,
             "session_max_life": 1800,
             "sentinel": {
             "sentinel": {
@@ -986,7 +986,7 @@
             },
             },
             "booker": {
             "booker": {
                 "account_source": "order",
                 "account_source": "order",
-                "target_instances": 1,
+                "target_instances": 2,
                 "account_cd": 1800,
                 "account_cd": 1800,
                 "booking_cooldown": 10,
                 "booking_cooldown": 10,
                 "max_bookings_per_account": 1
                 "max_bookings_per_account": 1
@@ -994,8 +994,8 @@
             "query_wait": {
             "query_wait": {
                 "mode": "Random",
                 "mode": "Random",
                 "fixed_wait": 10,
                 "fixed_wait": 10,
-                "random_min": 60,
-                "random_max": 300
+                "random_min": 55,
+                "random_max": 65
             },
             },
             "plugin_config": {
             "plugin_config": {
                 "lib_path": "plugins",
                 "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 vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
 from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
 from toolkit.mihomo_tunnel import MihomoTunnel
 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.keyboard import HumanKeyboard
 from utils.fingerprint_utils import FingerprintGenerator
 from utils.fingerprint_utils import FingerprintGenerator
 
 
@@ -65,8 +65,8 @@ class TlsPlugin(IVSPlg):
     
     
         if not os.path.exists(self.root_workspace):
         if not os.path.exists(self.root_workspace):
             os.makedirs(self.root_workspace)
             os.makedirs(self.root_workspace)
-            
-        self.last_refresh_time = time.time()
+        
+        self.is_busy = False
         self.tunnel = None
         self.tunnel = None
         self.session_create_time: float = 0
         self.session_create_time: float = 0
 
 
@@ -87,42 +87,19 @@ class TlsPlugin(IVSPlg):
         self.free_config = config.free_config or {}
         self.free_config = config.free_config or {}
         
         
     def keep_alive(self):
     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 max_x: X坐标最大范围
         :param min_y: Y坐标最小范围
         :param min_y: Y坐标最小范围
         :param max_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_x = random.randint(min_x, max_x)
             rand_y = random.randint(min_y, max_y)
             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:
     def health_check(self) -> bool:
+        if self.is_busy:
+            return True
         if not self.is_healthy:
         if not self.is_healthy:
             return False
             return False
         if self.page is None:
         if self.page is None:
@@ -322,7 +297,11 @@ class TlsPlugin(IVSPlg):
             cf_bypasser.handle_waiting_room()
             cf_bypasser.handle_waiting_room()
             
             
             self._log("Init humanize tools...")
             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)
             self.keyboard = HumanKeyboard(self.page)
             viewport_width = self.page.rect.viewport_size[0]
             viewport_width = self.page.rect.viewport_size[0]
             viewport_height = self.page.rect.viewport_size[1]
             viewport_height = self.page.rect.viewport_size[1]
@@ -511,60 +490,63 @@ class TlsPlugin(IVSPlg):
     def query(self, apt_type: AppointmentType) -> VSQueryResult:
     def query(self, apt_type: AppointmentType) -> VSQueryResult:
         res = VSQueryResult()
         res = VSQueryResult()
         res.success = False
         res.success = False
-
-        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
         return res
     
     
     def book_bak(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
     def book_bak(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
@@ -954,6 +936,7 @@ class TlsPlugin(IVSPlg):
         try:
         try:
             self._log("Refreshing page to trigger Cloudflare...")
             self._log("Refreshing page to trigger Cloudflare...")
             self.page.refresh()
             self.page.refresh()
+            time.sleep(5)
             cf = CloudflareBypasser(self.page, log=self.config.debug)
             cf = CloudflareBypasser(self.page, log=self.config.debug)
             success = cf.bypass(max_retry=6)
             success = cf.bypass(max_retry=6)
             
             

+ 53 - 25
sentinel.py

@@ -27,6 +27,7 @@ class SentinelGCO:
         # 1. 全局建连退避:起步 1 分钟,封顶 1 小时 (保护登录接口)
         # 1. 全局建连退避:起步 1 分钟,封顶 1 小时 (保护登录接口)
         self.group_backoff = ExponentialBackoff(base_delay=60.0, max_delay=3600.0, factor=2.0)
         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_spawn_time = 0.0
+        self.m_last_group_query_time = 0.0
 
 
     def _log(self, message):
     def _log(self, message):
         if self.m_logger:
         if self.m_logger:
@@ -86,8 +87,8 @@ class SentinelGCO:
 
 
     def _monitor_loop(self):
     def _monitor_loop(self):
         self._log("Monitor loop started.")
         self._log("Monitor loop started.")
-        rng = random.Random()
         
         
+        self.m_last_group_query_time = 0.0 
         while not self.m_stop_event.is_set():
         while not self.m_stop_event.is_set():
             try:
             try:
                 time.sleep(0.5)
                 time.sleep(0.5)
@@ -99,6 +100,11 @@ class SentinelGCO:
                 active_tasks = []
                 active_tasks = []
                 dead_tasks = []
                 dead_tasks = []
                 for t in tasks_to_check:
                 for t in tasks_to_check:
+                    
+                    if not t.is_querying:
+                        active_tasks.append(t)
+                        continue
+                    
                     try:
                     try:
                         if t.instance.health_check():
                         if t.instance.health_check():
                             active_tasks.append(t)
                             active_tasks.append(t)
@@ -119,10 +125,23 @@ class SentinelGCO:
                     with self.m_lock:
                     with self.m_lock:
                         self.m_tasks = [t for t in self.m_tasks if t in active_tasks]
                         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:
                 for task in active_tasks:
                     if now < task.next_run:
                     if now < task.next_run:
                         continue
                         continue
                     
                     
+                    if task.is_querying:
+                        continue
+                    
+                    if now - self.m_last_group_query_time < global_gap:
+                        break
+
                     apt_types = self.m_cfg.appointment_types
                     apt_types = self.m_cfg.appointment_types
                     if not apt_types:
                     if not apt_types:
                         continue
                         continue
@@ -136,30 +155,39 @@ class SentinelGCO:
                     elif mode == QueryWaitMode.Fixed:
                     elif mode == QueryWaitMode.Fixed:
                         interval = task.qw_cfg.fixed_wait
                         interval = task.qw_cfg.fixed_wait
                     elif mode == QueryWaitMode.Random:
                     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:
             except Exception as e:
                 self._log(f"Monitor loop error: {e}")
                 self._log(f"Monitor loop error: {e}")
                 time.sleep(2)
                 time.sleep(2)

+ 32 - 109
tls_registration_bot.py

@@ -14,107 +14,17 @@ from datetime import datetime, timedelta
 from typing import Optional, Dict
 from typing import Optional, Dict
 from DrissionPage.common import Keys
 from DrissionPage.common import Keys
 from DrissionPage import ChromiumPage, ChromiumOptions
 from DrissionPage import ChromiumPage, ChromiumOptions
+
+import configure
 from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
 from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
 from toolkit.vs_cloud_api import VSCloudApi
 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 vs_types import NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 from utils.mouse import HumanMouse
 from utils.mouse import HumanMouse
 from utils.keyboard import HumanKeyboard
 from utils.keyboard import HumanKeyboard
 from utils.scroll import HumanScroll
 from utils.scroll import HumanScroll
 from utils.fingerprint_utils import FingerprintGenerator
 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):
 def load_proxies(pool_name):
     """从 config/proxies.json 读取对应的代理池"""
     """从 config/proxies.json 读取对应的代理池"""
@@ -178,20 +88,39 @@ class TlsRegistrator:
         co.set_local_port(port)
         co.set_local_port(port)
         co.set_user_data_path(self.workspace)
         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):
         if chrome_path and os.path.exists(chrome_path):
             co.set_paths(browser_path=chrome_path)
             co.set_paths(browser_path=chrome_path)
         
         
-        # 2. 代理配置 (支持账号密码)
         if self.proxy_config and self.proxy_config.get("ip"):
         if self.proxy_config and self.proxy_config.get("ip"):
             p = self.proxy_config
             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()
                 local_proxy = self.tunnel.start()
                 self._log(f"Tunnel started at {local_proxy}")
                 self._log(f"Tunnel started at {local_proxy}")
                 co.set_argument(f'--proxy-server={local_proxy}')
                 co.set_argument(f'--proxy-server={local_proxy}')
             else:
             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}')
                 co.set_argument(f'--proxy-server={proxy_str}')
         else:
         else:
             self._log("[WARN] No proxy configured!")
             self._log("[WARN] No proxy configured!")
@@ -218,7 +147,7 @@ class TlsRegistrator:
         cf_bypasser.handle_waiting_room()
         cf_bypasser.handle_waiting_room()
         
         
         self._log("正在初始化拟人化工具...")
         self._log("正在初始化拟人化工具...")
-        self.mouse = HumanMouse(self.page, debug=True)
+        self.mouse = HumanMouse(self.page, debug=False)
         self.keyboard = HumanKeyboard(self.page)
         self.keyboard = HumanKeyboard(self.page)
         self._log("随机化鼠标开始位置...")
         self._log("随机化鼠标开始位置...")
         viewport_width = self.page.rect.viewport_size[0]
         viewport_width = self.page.rect.viewport_size[0]
@@ -311,14 +240,9 @@ class TlsRegistrator:
                 
                 
         self._log("提交注册...")
         self._log("提交注册...")
         btn_e = self.page.ele(btn_selector)
         btn_e = self.page.ele(btn_selector)
-        btn_e.scroll.to_see(center=True) 
         time.sleep(random.uniform(0.3, 0.6))
         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秒)...")
         self._log("正在等待验证结果 (最多10秒)...")
         success_dialog = self.page.wait.ele_displayed('tag:h1@text():Check your email inbox', timeout=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):
 def register_worker(proxy_config, tls_url, capsolver_key):
     """单个注册任务的工作线程函数"""
     """单个注册任务的工作线程函数"""
-    account_detail = generate_random_account_detail()
+    account_detail = generate_random_account_detail('CN')
     bot = None
     bot = None
     try:
     try:
         bot = TlsRegistrator(
         bot = TlsRegistrator(
@@ -739,8 +663,7 @@ def main():
     # ================= 环境变量读取 =================
     # ================= 环境变量读取 =================
     capsolver_key = os.getenv("CAPSOLVER_KEY")
     capsolver_key = os.getenv("CAPSOLVER_KEY")
     if not 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}")
     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 DrissionPage import ChromiumPage
 
 
+from utils.scroll import HumanScroll
 from utils.math_utils import (
 from utils.math_utils import (
     bezier_2d,
     bezier_2d,
     fitts_duration,
     fitts_duration,
@@ -93,6 +94,136 @@ class MouseTimingConfig:
 
 
     min_duration: float = 0.08
     min_duration: float = 0.08
     max_duration: float = 2.2
     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:
 class HumanMouse:
@@ -142,6 +273,7 @@ class HumanMouse:
         self._debug = debug
         self._debug = debug
         self._debug_initialized = False
         self._debug_initialized = False
         self._session_profile = self._build_session_profile()
         self._session_profile = self._build_session_profile()
+        self._scroll = HumanScroll(self._page)
 
 
     @property
     @property
     def timing(self) -> MouseTimingConfig:
     def timing(self) -> MouseTimingConfig:
@@ -174,8 +306,8 @@ class HumanMouse:
         :param element: DrissionPage 的元素对象
         :param element: DrissionPage 的元素对象
         """
         """
         # 1. 强制滚动,把元素尽量移到屏幕正中间,防止被顶部导航栏或底部悬浮窗遮盖
         # 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. 【核心修复】获取相对于当前屏幕视口的坐标,而不是页面绝对坐标
         # 2. 【核心修复】获取相对于当前屏幕视口的坐标,而不是页面绝对坐标
         # 注意:这里使用的是 viewport_midpoint
         # 注意:这里使用的是 viewport_midpoint

+ 2 - 0
vs_types.py

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