jerry преди 3 седмици
родител
ревизия
bbfeaf7a11
променени са 18 файла, в които са добавени 2380 реда и са изтрити 171 реда
  1. 2 1
      .gitignore
  2. 161 0
      config/config.json
  3. 0 3
      plugins/de_plugin.py
  4. 5 5
      plugins/ita_plugin.py
  5. 428 0
      plugins/pol_plugin.py
  6. 9 10
      plugins/vfs_plugin.py
  7. 1 0
      requirements.txt
  8. BIN
      test/image.png
  9. BIN
      test/output.png
  10. 250 0
      tls_registration_bot.py
  11. 27 11
      toolkit/vs_cloud_api.py
  12. 13 0
      utils/cloudflare_bypass_for_scraping.py
  13. 500 0
      utils/keyboard.py
  14. 167 0
      utils/math_utils.py
  15. 479 0
      utils/mouse.py
  16. 0 133
      utils/mouse_helper.py
  17. 328 0
      utils/scroll.py
  18. 10 8
      vfs_registration_bot.py

+ 2 - 1
.gitignore

@@ -4,4 +4,5 @@ logs
 .DS_Store
 *.jpg
 node_modules*
-temp_browser_data
+temp_browser_data
+venv

+ 161 - 0
config/config.json

@@ -1028,6 +1028,167 @@
                 ]
             }
         },
+        {
+            "identifier": "tls.cn.cng.fr",
+            "debug": false,
+            "enable": false,
+            "need_account": true,
+            "need_proxy": true,
+            "proxy_pool": "local",
+            "proxy_cd": 5,
+            "session_max_life": 30,
+            "sentinel": {
+                "account_source": "built-in",
+                "account_pool_id": "cn.cng.fr.sentinel",
+                "target_instances": 1,
+                "account_cd": 180,
+                "signal_ttl": 30
+            },
+            "booker": {
+                "account_source": "order",
+                "target_instances": 1,
+                "account_cd": 180,
+                "booking_cooldown": 10,
+                "max_bookings_per_account": 1
+            },
+            "query_wait": {
+                "mode": "Random",
+                "fixed_wait": 10,
+                "random_min": 60,
+                "random_max": 300
+            },
+            "plugin_config": {
+                "lib_path": "plugins",
+                "plugin_name": "tls_plugin",
+                "plugin_bin": "tls_plugin.py",
+                "plugin_proto": "IVSPlg"
+            },
+            "appointment_types": [
+                {
+                    "weight": 10,
+                    "routing_key": "slot.cng.fr.tourist",
+                    "city": "Chengdu",
+                    "visa_type": "Tourist",
+                    "country": "France"
+                }
+            ],
+            "website": "https://visas-fr.tlscontact.com/country/cn/vac/cnCNG2fr/",
+            "free_config": {
+                "capsolver_key": "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A",
+                "apt_config": {
+                    "code": "cnCNG2fr",
+                    "country": "cn",
+                    "mission": "fr",
+                    "city": "Chengdu"
+                },
+                "interest_month": "06-2026",
+                "target_labels": [
+                    "", "pta"
+                ]
+            }
+        },
+        {
+            "identifier": "e-konsulat.ie.pl",
+            "debug": false,
+            "enable": true,
+            "need_account": false,
+            "need_proxy": true,
+            "proxy_pool": "isp_all",
+            "proxy_cd": 5,
+            "session_max_life": 60,
+            "sentinel": {
+                "account_source": "",
+                "account_pool_id": "",
+                "target_instances": 1,
+                "account_cd": 0,
+                "signal_ttl": 30
+            },
+            "booker": {
+                "account_source": "",
+                "target_instances": 0,
+                "account_cd": 0,
+                "booking_cooldown": 10,
+                "max_bookings_per_account": 1
+            },
+            "query_wait": {
+                "mode": "Random",
+                "fixed_wait": 10,
+                "random_min": 60,
+                "random_max": 300
+            },
+            "plugin_config": {
+                "lib_path": "plugins",
+                "plugin_name": "pol_plugin",
+                "plugin_bin": "pol_plugin.py",
+                "plugin_proto": "IVSPlg"
+            },
+            "appointment_types": [
+                {
+                    "weight": 10,
+                    "routing_key": "slot.dub.pl.tourist",
+                    "city": "Dublin",
+                    "visa_type": "Tourist",
+                    "country": "Poland"
+                }
+            ],
+            "website": "https://secure.e-konsulat.gov.pl/placowki/151/wiza-schengen/wizyty/weryfikacja-obrazkowa",
+            "free_config": {
+                "query_url": "https://secure.e-konsulat.gov.pl/placowki/151/wiza-schengen/wizyty/weryfikacja-obrazkowa",
+                "service_type": "Wiza Schengen",
+                "location": "Dublin"
+            }
+        },
+        {
+            "identifier": "e-konsulat.jp.pl",
+            "debug": false,
+            "enable": false,
+            "need_account": false,
+            "need_proxy": true,
+            "proxy_pool": "local",
+            "proxy_cd": 5,
+            "session_max_life": 10000000,
+            "sentinel": {
+                "account_source": "",
+                "account_pool_id": "",
+                "target_instances": 1,
+                "account_cd": 0,
+                "signal_ttl": 30
+            },
+            "booker": {
+                "account_source": "",
+                "target_instances": 0,
+                "account_cd": 0,
+                "booking_cooldown": 10,
+                "max_bookings_per_account": 1
+            },
+            "query_wait": {
+                "mode": "Random",
+                "fixed_wait": 10,
+                "random_min": 60,
+                "random_max": 300
+            },
+            "plugin_config": {
+                "lib_path": "plugins",
+                "plugin_name": "pol_plugin",
+                "plugin_bin": "pol_plugin.py",
+                "plugin_proto": "IVSPlg"
+            },
+            "appointment_types": [
+                {
+                    "weight": 10,
+                    "routing_key": "slot.tyo.pl.tourist",
+                    "city": "Tokyo",
+                    "visa_type": "Tourist",
+                    "country": "Poland"
+                }
+            ],
+            "website": "https://secure.e-konsulat.gov.pl/placowki/178/wiza-krajowa/wizyty/weryfikacja-obrazkowa",
+            "free_config": {
+                "query_url": "https://secure.e-konsulat.gov.pl/placowki/178/wiza-krajowa/wizyty/weryfikacja-obrazkowa",
+                "service_type": "wiza krajowa",
+                "location": "Tokio"
+            }
+        },
         {
             "identifier": "visametric.ie.de",
             "debug": false,

+ 0 - 3
plugins/de_plugin.py

@@ -217,9 +217,6 @@ class DePlugin(IVSPlg):
             self._log(f"Session Create Failed: {e}")
             self.cleanup()
             raise e
-        
-    def get_booked_applicants(self)-> List[Dict]:
-        return []
 
     def _submit_captcha(self, code):
         """

+ 5 - 5
plugins/ita_plugin.py

@@ -17,8 +17,8 @@ from vs_plg import IVSPlg
 from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 from toolkit.proxy_tunnel import ProxyTunnel
 from toolkit.vs_cloud_api import VSCloudApi
-from utils.mouse_helper import HumanMouse
-
+from utils.mouse import HumanMouse
+from utils.scroll import HumanScroll
 
 class BrowserResponse:
     def __init__(self, result_dict):
@@ -191,14 +191,14 @@ class ItaPlugin(IVSPlg):
             # 4. [核心修改] 解决 ReCaptcha V3 Enterprise 并注入
             # Prenotami 使用的是 Enterprise V3, Action = 'LOGIN'
             self._solve_and_inject_prenotami_captcha()
-            human_mouse = HumanMouse(self.page)
-            
+            human_mouse = HumanMouse(self.page, debug=True)
+            human_scroll = HumanScroll(self.page)
             # 先定位
             self._log("Locating Login button...")
             login_btn = self.page.ele('@id=captcha-trigger')
 
             self._log("Scrolling to make button visible...")
-            human_mouse.scroll_to_visible(login_btn)   
+            human_scroll.scroll_to_element(login_btn, humanize=True)   
             
             self._log("Moving mouse to Login button...")
             human_mouse.move_to(login_btn, duration=random.uniform(0.6, 1.0))

+ 428 - 0
plugins/pol_plugin.py

@@ -0,0 +1,428 @@
+import time
+import json
+import random
+import re
+import os
+import uuid
+import shutil
+import base64
+import socket
+import easyocr
+from datetime import datetime
+from typing import List, Dict, Optional, Any, Callable
+from urllib.parse import urljoin, urlparse, urlencode
+
+# DrissionPage 核心
+from DrissionPage import ChromiumPage, ChromiumOptions
+
+
+from vs_plg import IVSPlg
+from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
+from toolkit.vs_cloud_api import VSCloudApi
+from toolkit.proxy_tunnel import ProxyTunnel
+
+
+class BrowserResponse:
+    def __init__(self, result_dict):
+        result_dict = result_dict or {}
+        self.status_code = result_dict.get('status', 0)
+        self.text = result_dict.get('body', '')
+        self.headers = result_dict.get('headers', {})
+        self.url = result_dict.get('url', '')
+        self._json = None
+    def json(self):
+        if self._json is None:
+            if not self.text: return {}
+            try: self._json = json.loads(self.text)
+            except: self._json = {}
+        return self._json
+
+def to_yyyymmdd(data_str: str, date_str_format: str, target_format: str="%Y-%m-%d"):
+    dt = datetime.strptime(data_str, date_str_format)
+    return dt.strftime("%Y-%m-%d")
+
+def get_alias_email(email: str, new_domain: str = "gmail-app.com") -> str:
+    if "@" not in email: raise ValueError(f"Invalid email: {email}")
+    local_part, _ = email.rsplit("@", 1)
+    return f"{local_part}@{new_domain}"
+
+class PolPlugin(IVSPlg):
+    """
+    Germany (Visametric) 签证预约插件 (Browser + Tunnel Mode)
+    """
+
+    def __init__(self, group_id: str):
+        self.group_id = group_id
+        self.config: Optional[VSPlgConfig] = None
+        self.free_config: Dict[str, Any] = {}
+        self.logger = None
+        
+        # 浏览器实例
+        self.page: Optional[ChromiumPage] = None
+        
+        # 资源隔离
+        self.instance_id = uuid.uuid4().hex[:8]
+        self.root_workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"{self.group_id}.{self.instance_id}"))
+        self.user_data_path = os.path.join(self.root_workspace, "user_data")
+        
+        if not os.path.exists(self.root_workspace):
+            os.makedirs(self.root_workspace)
+            
+        self.reader = easyocr.Reader(['en'], gpu=False)
+        
+        self.tunnel = None # 代理隧道
+        self.is_healthy = True
+        self.session_create_time: float = 0
+
+    def get_group_id(self) -> str:
+        return self.group_id
+    
+    def set_log(self, logger: Callable[[str], None]) -> None:
+        self.logger = logger
+        
+    def _log(self, message): 
+        if self.logger:
+            self.logger(f'[PolPlugin] [{self.group_id}] {message}')
+        else:
+            print(f'[PolPlugin] [{self.group_id}] {message}')
+        
+    def set_config(self, config: VSPlgConfig):
+        self.config = config
+        self.free_config = config.free_config or {}
+            
+    def keep_alive(self):
+        pass
+
+    def health_check(self) -> bool:
+        if not self.is_healthy:
+            return False
+        if not self.page:
+            return False
+        try:
+            if not self.page.run_js("return 1;"):
+                return False
+        except:
+            return False
+        if self.config.session_max_life > 0:
+            if time.time() - self.session_create_time > self.config.session_max_life * 60:
+                self._log("Session expired.")
+                return False
+        return True
+
+    def create_session(self):
+        """
+        创建会话:启动浏览器 -> 代理隧道 -> 提取 Captcha -> 本地识别 -> 提交 -> 获取 Context
+        """
+        self._log(f"Initializing Session (ID: {self.instance_id})...")
+        co = ChromiumOptions()
+        def get_free_port():
+            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+                s.bind(('', 0)); return s.getsockname()[1]
+        co.set_local_port(get_free_port())
+        
+        co.set_user_data_path(self.user_data_path)
+        chrome_path = os.getenv("CHROME_BIN")
+        if chrome_path and os.path.exists(chrome_path):
+            co.set_paths(browser_path=chrome_path)
+            
+        if self.config.proxy and self.config.proxy.ip:
+            p = self.config.proxy
+            if p.username and p.password:
+                self._log(f"Starting Tunnel for {p.ip}...")
+                self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
+                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.scheme}://{p.ip}:{p.port}"
+                co.set_argument(f'--proxy-server={proxy_str}')
+        else:
+            self._log("[WARN] No proxy configured!")
+
+        co.headless(False)
+        co.set_argument('--no-sandbox')
+        co.set_argument('--disable-gpu')
+        co.set_argument('--disable-dev-shm-usage') 
+        co.set_argument('--window-size=1920,1080')
+        co.set_argument('--disable-blink-features=AutomationControlled')
+        co.set_argument('--ignore-certificate-errors')
+
+        try:
+            self.page = ChromiumPage(co)
+            url_home = "https://secure.e-konsulat.gov.pl"
+            self._log(f"Navigating to {url_home}")
+            self.page.get(url_home)
+            self.page.wait.doc_loaded()
+            self.session_create_time = time.time()
+            self._log("Session created successfully.")
+        except Exception as e:
+            self._log(f"Session Create Failed: {e}")
+            self.cleanup()
+            raise e
+
+    def query(self, apt_type: AppointmentType) -> VSQueryResult:
+        res = VSQueryResult()
+        res.success = False
+        query_url = self.free_config.get('query_url')
+        service_type = self.free_config.get('service_type')
+        location = self.free_config.get('location')
+        self._log(f"Navigating to {query_url}")
+        self.page.get(query_url)
+        captcha_image_selector = 't:img@alt=Weryfikacja obrazkowa'
+        if not self.page.wait.ele_displayed(captcha_image_selector, timeout=30):
+            raise BizLogicError(message=f"Wait for selector={captcha_image_selector} timeout")
+        time.sleep(3)
+        img_ele = self.page.ele(captcha_image_selector)
+        img_src = img_ele.attr('src')
+        base64_data = img_src.split(',')[1]            
+        image_bytes = base64.b64decode(base64_data)
+
+        result = self.reader.readtext(image_bytes)
+        captcha_code = result[0][-2] if result else ""
+        self._log(f"Captcha code={captcha_code}")
+
+        if not captcha_code:
+            BizLogicError(message="Solve captcha failed")
+        input_ele = self.page.ele('t:input@aria-label=Znaki z obrazka')
+        input_ele.clear()
+        input_ele.input(captcha_code)
+
+        btn_selector = 'Dalej'
+        self.page.ele(btn_selector).click(by_js=True)
+   
+        toast_ele = self.page.ele('tag:app-toast', timeout=2)
+        if toast_ele:
+            error_msg = toast_ele.text.replace('\n', ' ').strip()
+            raise BizLogicError(message=f"Captcha verify error={error_msg}")
+
+        if not self._select_mat_option('Rodzaj usługi', service_type):
+            raise BizLogicError(message=f'Process select box failed')
+        if not self._select_mat_option('Lokalizacja', location):
+            raise BizLogicError(message=f'Process select box failed')
+        if not self._select_mat_option('Chcę zarezerwować termin dla', '1 osob'):
+            raise BizLogicError(message=f'Process select box failed')
+        
+        available_dates = []
+        self._log("Wait Query Slot...")
+        for _ in range(20):
+            try:
+                no_slot_alert = self.page.ele('text:Chwilowo wszystkie udostępnione terminy', timeout=0.1)
+                if no_slot_alert:
+                    self._log("No slots available")
+                    break                    
+                listbox = self.page.ele('@role=listbox', timeout=0.1)                
+                if not listbox:
+                    termin_label = self.page.ele('tag:mat-label@@text():Termin', timeout=0.5)
+                    if termin_label:
+                        termin_select = termin_label.parent('tag:app-select-control').ele('tag:mat-select')                        
+                        if termin_select and 'mat-select-disabled' not in str(termin_select.attr('class')):
+                            try:
+                                termin_select.click()
+                            except:
+                                termin_select.click(by_js=True)
+                            
+                            time.sleep(0.5)
+                            listbox = self.page.ele('@role=listbox', timeout=1)
+                if listbox:
+                    option_elements = listbox.eles('.mat-option-text')
+                    for ele in option_elements:
+                        date_str = ele.text.strip()
+                        if date_str:
+                            available_dates.append(date_str)                           
+                if available_dates:
+                    self._log(f"✅ Success extracted dates: {available_dates}")
+                    break                    
+            except Exception as e:
+                self._log(f"Query loop exception: {e}")                
+            time.sleep(0.5)
+        if available_dates:
+            selected_date = random.choice(available_dates)
+            self._log(f"🎲 Random select date: {selected_date}...")
+            locked = self._lock_slot(selected_date)
+            if locked:
+                session_id = self._save_browser_session()
+                wechat_message = f"🎉 [Poland] Slot locked\n📍 location: {location}\n📅 date: {selected_date}\n🔑 SessionId: {session_id}"
+                VSCloudApi.Instance().push_weixin_text(wechat_message)
+            res.success = True
+            res.availability_status = AvailabilityStatus.Available
+            earliest_date = available_dates[0]
+            earliest_dt = datetime.strptime(earliest_date, "%Y-%m-%d")
+            res.earliest_date = earliest_dt
+
+            res.availability = [
+                DateAvailability(
+                    date=datetime.strptime(d, "%Y-%m-%d"),
+                    times=[],
+                )
+                for d in available_dates
+            ]
+        else:
+            res.success = False
+            res.availability_status = AvailabilityStatus.NoneAvailable
+            res.availability = []
+        return res
+    
+    def _lock_slot(self, lock_date):
+        slot_selector = f'xpath://span[contains(@class, "mat-option-text") and contains(text(), "{lock_date}")]'
+        slot_ele = self.page.ele(slot_selector, timeout=1)
+        if not slot_ele:
+            termin_label = self.page.ele('tag:mat-label@@text():Termin', timeout=1)
+            if termin_label:
+                termin_select = termin_label.parent('tag:app-select-control').ele('tag:mat-select')
+                if termin_select and 'mat-select-disabled' not in str(termin_select.attr('class')):
+                    try:
+                        termin_select.click()
+                    except:
+                        termin_select.click(by_js=True)
+                    time.sleep(0.5)
+        slot_ele = self.page.ele(slot_selector, timeout=3)
+        if not slot_ele:
+            self._log(f"❌ Can't find date {lock_date} to click.")
+            return False
+        try:
+            slot_ele.click() 
+        except:
+            slot_ele.click(by_js=True)
+        self._log(f"✅ Clicked date: {lock_date}")
+        time.sleep(1)
+        btn_selector = 'xpath://button[.//span[contains(text(), "Dalej")]]'
+        next_btn = self.page.ele(btn_selector, timeout=3)
+        if not next_btn:
+            self._log("❌ Can't find 'Dalej' button")
+            return False
+        try:
+            next_btn.click()
+        except:
+            next_btn.click(by_js=True)
+        self._log("✅ Clicked Dalej, locking slot...")
+        return self.page.wait.url_change('weryfikacja-obrazkowa', exclude=True, timeout=15)
+    
+    def _select_mat_option(self, label_text, option_text):
+        self._log(f"choose: {label_text} -> {option_text}")
+        label = self.page.ele(f'tag:mat-label@@text():{label_text}', timeout=5)
+        if not label:
+            self._log(f"Can't find label: {label_text}")
+            return False
+            
+        container = label.parent('tag:app-select-control')
+        select_box = container.ele('tag:mat-select')
+        
+        if not select_box:
+            self._log("Can't find select box")
+            return False
+
+        select_box.click(by_js=True)
+        time.sleep(0.5)
+        
+        option = self.page.ele(f'tag:mat-option@@text():{option_text}', timeout=3)
+        if option:
+            option.click(by_js=True)
+            time.sleep(0.5)
+            return True
+        else:
+            self._log(f"Can't find option: {option_text}")
+            return False
+
+    def book(self, slot_info: VSQueryResult, user_inputs: Dict) -> VSBookResult:
+        res = VSBookResult()
+        return res
+    
+    def _save_browser_session(self):
+        self._log("Abstract browser session env...")
+        cookies_dict = self.page.cookies(all_domains=True, all_info=True)
+        cookies_str = cookies_dict.as_json()
+        local_storage_str = self.page.run_js('return JSON.stringify(window.localStorage) || "{}"')
+        session_storage_str = self.page.run_js('return JSON.stringify(window.sessionStorage) || "{}"')
+        proxy_str = ""
+        if hasattr(self, 'config') and hasattr(self.config, 'proxy') and self.config.proxy.ip:
+            p = self.config.proxy
+            if p.username and p.password:
+                proxy_str = f"{p.scheme}://{p.username}:{p.password}@{p.ip}:{p.port}"
+            else:
+                proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
+        session_data = VSCloudApi.Instance().create_http_session(
+            session_id=str(uuid.uuid4().hex),
+            cookies=cookies_str,
+            local_storage=local_storage_str,
+            session_storage=session_storage_str,
+            user_agent=self.page.user_agent,
+            page=self.page.url,
+            proxy=proxy_str
+        )
+        return session_data.get('session_id')
+    
+    def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None, retry_count=0):
+        if not self.page:
+            raise BizLogicError("Browser not init")
+        
+        req_url = url
+        if params:
+            sep = '&' if '?' in req_url else '?'
+            req_url += sep + urlencode(params)
+            
+        fetch_opts = { "method": method.upper(), "headers": headers or {}, "credentials": "include" }
+        
+        if json_data:
+            fetch_opts['body'] = json.dumps(json_data)
+            fetch_opts['headers']['Content-Type'] = 'application/json'
+        elif data:
+            if isinstance(data, dict):
+                fetch_opts['body'] = urlencode(data)
+                fetch_opts['headers']['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
+            else:
+                fetch_opts['body'] = data
+
+        js = f"""
+        return fetch("{req_url}", {json.dumps(fetch_opts)})
+        .then(async r => {{
+            const h = {{}}; r.headers.forEach((v, k) => h[k] = v);
+            return {{ status: r.status, body: await r.text(), headers: h, url: r.url }};
+        }}).catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
+        """
+        
+        resp = BrowserResponse(self.page.run_js(js, timeout=60))
+        
+        if resp.status_code == 200:
+            return resp
+        elif resp.status_code == 403:
+            if "Just a moment" in resp.text and retry_count < 2:
+                self._log("Cloudflare 403. Refreshing...")
+                if self._refresh_firewall_session():
+                    return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
+            raise PermissionDeniedError(f"HTTP 403: {resp.text[:100]}")
+        elif resp.status_code == 429:
+            self.is_healthy = False
+            raise RateLimiteddError()
+        elif resp.status_code in [401, 419]:
+            self.is_healthy = False
+            raise SessionExpiredOrInvalidError()
+        else:
+            raise BizLogicError(f"HTTP {resp.status_code}: {resp.text[:100]}")
+
+    def _filter_dates(self, dates, start, end):
+        if not start or not end: return dates
+        valid = []
+        s = datetime.strptime(start[:10], "%Y-%m-%d")
+        e = datetime.strptime(end[:10], "%Y-%m-%d")
+        for d in dates:
+            c = datetime.strptime(d, "%Y-%m-%d")
+            if s <= c <= e: valid.append(d)
+        random.shuffle(valid)
+        return valid
+
+    def cleanup(self):
+        if self.page:
+            try: self.page.quit()
+            except: pass
+            self.page = None
+        if os.path.exists(self.root_workspace):
+            for _ in range(3):
+                try: time.sleep(0.2); shutil.rmtree(self.root_workspace, ignore_errors=True); break
+                except: time.sleep(0.5)
+        if self.tunnel:
+            try: self.tunnel.stop()
+            except: pass
+            self.tunnel = None
+        
+    def __del__(self):
+        self.cleanup()

+ 9 - 10
plugins/vfs_plugin.py

@@ -803,16 +803,15 @@ class VfsPlugin(IVSPlg):
         now_utc = datetime.utcnow()
         formatted_utc_time = now_utc.strftime("%Y-%m-%d %H:%M:%S")
         self._log(f"Waiting for OTP email...")
-        for i in range(12):
-            content_out = VSCloudApi.Instance().fetch_mail_content(
-                master_email, sender, recipient, subject_keywords, body_keywords, formatted_utc_time, 300
-            )
-            if content_out:
-                match = re.search(r'\b\d{6}\b', content_out)
-                if match:
-                    return match.group(0)
-            time.sleep(5)
-        raise NotFoundError(message="OTP email not found")
+        content_out = VSCloudApi.Instance().fetch_mail_content(
+            master_email, sender, recipient, subject_keywords, body_keywords, formatted_utc_time, 300
+        )
+        if content_out:
+            match = re.search(r'\b\d{6}\b', content_out)
+            if match:
+                return match.group(0)
+        raise BizLogicError(message="OTP code not found")
+
 
     def _submit_login_otp(self, old_cf_token: str, otp: str):
         self._log("Submitting Login OTP...")

+ 1 - 0
requirements.txt

@@ -4,6 +4,7 @@ redis
 cryptography
 curl-cffi>=0.7.0
 ddddocr
+easyocr
 fastapi
 numpy
 pydantic

BIN
test/image.png


BIN
test/output.png


+ 250 - 0
tls_registration_bot.py

@@ -0,0 +1,250 @@
+import time
+import json
+import os
+import re
+import uuid
+import socket
+import shutil
+import requests
+from datetime import datetime, timedelta
+from DrissionPage import ChromiumPage, ChromiumOptions
+from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
+from toolkit.vs_cloud_api import VSCloudApi
+
+class TlsRegistrator:
+    def __init__(self, proxy_config: dict, capsolver_key: str):
+        self.proxy_config = proxy_config
+        self.capsolver_key = capsolver_key
+        
+        # 隔离的用户数据目录
+        self.instance_id = uuid.uuid4().hex[:8]
+        self.workspace = os.path.abspath(os.path.join("data", f"reg_session_{self.instance_id}"))
+        self.page = None
+
+    def _log(self, msg):
+        print(f"[TLS-Reg-{self.instance_id}] {msg}")
+
+    def _get_free_port(self):
+        """获取可用端口,防止 DrissionPage 解析日志报错"""
+        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+            s.bind(('', 0))
+            return s.getsockname()[1]
+
+    def init_browser(self):
+        """初始化独立、配置好代理的浏览器环境"""
+        self._log("Initializing browser...")
+        co = ChromiumOptions()
+        
+        # 1. 端口与路径隔离
+        port = self._get_free_port()
+        co.set_local_port(port)
+        co.set_user_data_path(self.workspace)
+        
+        # 2. 代理配置 (支持账号密码)
+        if self.proxy_config and self.proxy_config.get("ip"):
+            p = self.proxy_config
+            if p.get("username") and p.get("password"):
+                proxy_str = f"{p['username']}:{p['password']}@{p['ip']}:{p['port']}"
+            else:
+                proxy_str = f"{p.get('scheme', 'http')}://{p['ip']}:{p['port']}"
+            
+            self._log(f"Setting proxy: {p['ip']}:{p['port']}")
+            co.set_proxy(proxy_str)
+
+        # 3. 反爬及稳定性配置
+        co.headless(False)
+        co.set_argument('--no-sandbox')
+        co.set_argument('--disable-gpu')
+        co.set_argument('--disable-dev-shm-usage')
+        co.set_argument('--window-size=1920,1080')
+        co.set_argument('--disable-blink-features=AutomationControlled')
+
+        self.page = ChromiumPage(co)
+
+    def solve_captcha(self, page_url: str, site_key: str) -> str:
+        """调用 Capsolver 解决验证码 (无依赖版本)"""
+        if not self.capsolver_key:
+            raise ValueError("Capsolver API key is missing!")
+            
+        self._log("Submitting captcha task to Capsolver...")
+        
+        task = {
+            "type": "ReCaptchaV3TaskProxyLess", 
+            "websiteURL": page_url,
+            "websiteKey": site_key,
+            "pageAction": "register"
+        }
+        
+        payload = {
+            "clientKey": self.capsolver_key, 
+            "task": task
+        }
+        print(f'createTask payload={payload}')
+        create_res = requests.post("https://api.capsolver.com/createTask", json=payload, timeout=20)
+        if create_res.status_code != 200 or create_res.json().get("errorId") != 0:
+            raise Exception(f"Failed to create capsolver task: {create_res.text}")
+            
+        task_id = create_res.json().get("taskId")
+        self._log(f"Captcha Task created: {task_id}. Waiting for solution...")
+        
+        for _ in range(30):
+            res = requests.post(
+                "https://api.capsolver.com/getTaskResult", 
+                json={"clientKey": self.capsolver_key, "taskId": task_id}, 
+                timeout=20
+            )
+            data = res.json()
+            self._log(f"data={data}")
+            if data.get("status") == "ready":
+                self._log("Captcha solved successfully!")
+                return data.get("solution", {}).get("gRecaptchaResponse") or data.get("solution", {}).get("token")
+            time.sleep(3)
+            
+        raise Exception("Capsolver task timeout")
+
+    def register(self, email, password, issuer_id="cnCNG2fr"):
+        """执行自动注册"""
+        try:
+            self.init_browser()
+            
+            # 1. 访问注册页面并过盾
+            reg_url = f"https://visas-fr.tlscontact.com/en-us/registration?issuerId={issuer_id}"
+            self._log(f"Navigating to {reg_url}")
+            self.page.get(reg_url)
+            cf_bypasser = CloudflareBypasser(self.page, log=True)
+            cf_bypasser.bypass()
+            time.sleep(3) 
+            
+            cf_bypasser.handle_waiting_room()
+
+            site_key = "6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9" 
+            captcha_token = self.solve_captcha(reg_url, site_key)
+
+            self._log("Constructing Next.js fetch request...")
+            action_id = "608511a5a6e58da30dc13b591255b358caa2193367"
+            router_state = '%5B%22%22%2C%7B%22children%22%3A%5B%5B%22lang%22%2C%22en-us%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%22(auth)%22%2C%7B%22children%22%3A%5B%22registration%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D'
+            
+            payload =[
+                {
+                    "email": email,
+                    "password": password,
+                    "locale": "en",
+                    "issuerId": issuer_id,
+                    "consentList": ["SURVEY"]
+                },
+                captcha_token
+            ]
+            
+            body_str = json.dumps(payload).replace("'", "\\'")
+
+            js_script = f"""
+            const url = "{reg_url}";
+            const headers = {{
+                'next-action': '{action_id}',
+                'next-router-state-tree': '{router_state}',
+                'accept': 'text/x-component',
+                'content-type': 'text/plain;charset=UTF-8'
+            }};
+            const bodyData = '{body_str}';
+            
+            return fetch(url, {{ method: 'POST', headers: headers, body: bodyData }})
+                .then(async response => {{
+                    const text = await response.text();
+                    const headers = {{}};
+                    response.headers.forEach((value, key) => headers[key] = value);
+                    return {{ status: response.status, body: text, headers: headers, url: response.url }};
+                }}).catch(err => {{
+                    return {{ status: 0, body: err.toString(), headers: {{}} }};
+                }});
+            """
+
+            self._log("Submitting registration...")
+            res_dict = self.page.run_js(js_script)
+            
+            # 4. 解析结果 (处理 Next.js Server Action 响应)
+            status = res_dict.get('status')
+            body_text = res_dict.get('body', '')
+            resp_headers = {str(k).lower(): v for k, v in res_dict.get('headers', {}).items()}
+            action_redirect = resp_headers.get('x-action-redirect', '')
+            
+            # 【核心修改点】:适配 Next.js 响应中的 "status":"OK"
+            is_success = (
+                status == 303 or 
+                (status == 200 and ("login" in action_redirect or '"status":"OK"' in body_text))
+            )
+
+            if is_success:
+                self._log("✅ Registration SUCCESS!")
+                self._log(f"Response snippet: {body_text[:100]}...")
+                return True
+            else:
+                self._log(f"❌ Registration FAILED! Status: {status}")
+                self._log(f"Response Body: {body_text[:500]}")
+                return False
+        except Exception as e:
+            self._log(f"Error during registration: {e}")
+            return False
+    
+    def activate(self, email, sent_at=None):
+        email_box = 'visafly666@gmail.com'
+        sender = 'TLSContact'
+        recipient = email
+        subject_keywords = 'Activate'
+        body_keywords = ''
+        
+        content_out = VSCloudApi.Instance().fetch_mail_content(
+            email=email_box,
+            sender=sender,
+            recipient=recipient,
+            subject_keywords=subject_keywords,
+            body_keywords=body_keywords,
+            sent_date=sent_at,
+            expiry=600
+        )
+        print(f'activate email content={content_out}')
+        match = re.search(r'https://\S+', content_out)
+        activate_link = match.group(0) if match else None
+        tab = self.page.new_tab(activate_link)
+        btn_selector = "Activate"
+        if not tab.wait.ele_displayed(btn_selector, timeout=10):
+            return False
+        tab.ele(btn_selector).click()
+
+    def cleanup(self):
+        """清理浏览器进程和缓存文件夹"""
+        self._log("Cleaning up resources...")
+        if self.page:
+            try: self.page.quit()
+            except: pass
+        if os.path.exists(self.workspace):
+            time.sleep(1) # 等待文件锁释放
+            shutil.rmtree(self.workspace, ignore_errors=True)
+
+
+if __name__ == "__main__":
+    # ================= 配置区域 =================
+    
+    PROXY_CONFIG = {
+        "scheme": "http",
+        "ip": "127.0.0.1",   
+        "port": "7890",      
+        "username": "",      
+        "password": ""
+    }
+    
+    CAPSOLVER_KEY = "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A"
+    
+    TARGET_EMAIL = "lisi39@gmail-app.com"
+    TARGET_PWD = "Visafly@111"
+    TARGET_ISSUER = "cnCNG2fr"
+    
+    # ============================================
+
+    bot = TlsRegistrator(proxy_config=PROXY_CONFIG, capsolver_key=CAPSOLVER_KEY)
+    now_utc = datetime.utcnow()
+    formatted_utc_time = (now_utc - timedelta(minutes=4)).strftime("%Y-%m-%d %H:%M:%S")
+    # success = bot.register(email=TARGET_EMAIL, password=TARGET_PWD, issuer_id=TARGET_ISSUER)
+    # if not success:
+    #     print("\n--> 流程完成:账号注册失败,请检查日志。")
+        
+    bot.activate(email=TARGET_EMAIL, sent_at=formatted_utc_time)

+ 27 - 11
toolkit/vs_cloud_api.py

@@ -124,6 +124,21 @@ class VSCloudApi:
             return result.get("data", {})
         else:
             raise BizLogicError(message=f"Return vas task to queue biz error: {result.get('message')}")
+        
+    def push_weixin_text(self, text:str):
+        """
+        推送微信文本消息
+        API: POST https://visafly.top/api/wechat/send_no_token
+        """
+        url = f"{self.base_url}/api/wechat/send_no_token"
+        payload = {"message": text}
+        headers = self._get_headers()
+        resp = self._perform_request('POST', url, json_data=payload, headers=headers)
+        result = resp.json()
+        if result.get("code") == 0:
+            return result.get("data", {})
+        else:
+            raise BizLogicError(message=f"Return vas task to queue biz error: {result.get('message')}")
 
     def create_task(self, command: str, args: Dict) -> str:
         """
@@ -208,11 +223,12 @@ class VSCloudApi:
     def create_http_session(
         self, 
         session_id: str, 
-        cookies: str, 
-        local_storage: str, 
-        user_agent: str, 
-        proxy: str, 
-        page: str
+        cookies: Optional[str] = None, 
+        local_storage: Optional[str] = None, 
+        user_agent: Optional[str] = None, 
+        proxy: Optional[str] = None, 
+        page: Optional[str] = None, 
+        session_storage: Optional[str] = None
     ) -> Optional[Dict]:
         """创建 http session"""
         url = f"{self.base_url}/api/http-session"
@@ -220,6 +236,7 @@ class VSCloudApi:
         
         payload = {
             "local_storage": local_storage,
+            "session_storage": session_storage,
             "cookies": cookies,
             "user_agent": user_agent,
             "proxy": proxy,
@@ -227,7 +244,6 @@ class VSCloudApi:
             "session_id": session_id
         }
         resp = self._perform_request('POST', url, headers=headers, json_data=payload)
-
         result = resp.json()
         if result.get("code") == 0:
             return result.get("data", {})
@@ -242,7 +258,7 @@ class VSCloudApi:
         visa_type: str = "Tourist",
         snapshot_source: str = "worker",
     ):
-        url = f"https://visafly.top/api/slot_refresh/start"
+        url = f"{self.base_url}/api/slot_refresh/start"
         payload = {
             "routing_key": routing_key,
             "country": country,
@@ -263,7 +279,7 @@ class VSCloudApi:
         routing_key: str,
         snapshot_source: str = "worker",
     ):
-        url = 'https://visafly.top/api/slot_refresh/success'
+        url = f'{self.base_url}/api/slot_refresh/success'
         payload = {
             "routing_key": routing_key,
             "snapshot_source": snapshot_source,
@@ -282,7 +298,7 @@ class VSCloudApi:
         error: str,
         snapshot_source: str = "worker",
     ):
-        url = 'https://visafly.top/api/slot_refresh/fail'
+        url = f'{self.base_url}/api/slot_refresh/fail'
         payload = {
             "routing_key": routing_key,
             "snapshot_source": snapshot_source,
@@ -301,7 +317,7 @@ class VSCloudApi:
         pool_name: str,
         lock_duration: float = 60
     ):
-        url = 'https://visafly.top/api/account/next'
+        url = f'{self.base_url}/api/account/next'
         params = {
             "pool_name": pool_name,
             "lock_duration": lock_duration
@@ -318,7 +334,7 @@ class VSCloudApi:
         self,
         query_payload: Dict[str, Any] = {}
     ):
-        url = "https://visafly.top/api/slots/report"
+        url = f"{self.base_url}/api/slots/report"
         
         headers = self._get_headers()
         resp = self._perform_request("POST", url, headers=headers, json_data=query_payload)

+ 13 - 0
utils/cloudflare_bypass_for_scraping.py

@@ -98,4 +98,17 @@ class CloudflareBypasser:
             self.click_verification_button(False)
             time.sleep(2)
         return self.is_bypassed()
+    
+    def handle_waiting_room(self):
+        wait_start = time.time()
+        while True:
+            html = self.driver.html.lower()
+            if "file d'attente" in html or "waiting room" in html:
+                if time.time() - wait_start > 6 * 60:
+                    self.log_message("Waiting room timeout (1h).")
+                    break
+                self.log_message("In Waiting Room... Waiting for auto-refresh.")
+                time.sleep(30)
+            else:
+                break
             

+ 500 - 0
utils/keyboard.py

@@ -0,0 +1,500 @@
+# from DrissionPage import ChromiumPage
+# # 假设上述代码存为 human_keyboard.py
+# from human_keyboard import HumanKeyboard, Key
+
+# page = ChromiumPage()
+# page.get('https://www.baidu.com')
+
+# # 初始化高度拟人化键盘
+# keyboard = HumanKeyboard(page)
+
+# # 1. 点击搜索框以获得输入焦点
+# ele = page.ele('#kw')
+# ele.click()
+
+# # 2. 以极度拟人化的方式输入文本(有概率打错字又自己倒退回删并重新打)
+# # 因为开了 humanize,你会看到打字速度变化、可能突然思考停顿等效果。
+# keyboard.type_text("Hello World, let's test humanized typing!!!", humanize=True)
+
+# # 3. 按下回车键
+# keyboard.press(Key.ENTER)
+
+# # 4. 执行快捷键 (如 Ctrl + A 全选)
+# keyboard.hotkey(Key.CONTROL, Key.A)
+
+import logging
+import random
+import time
+import warnings
+from dataclasses import dataclass
+from enum import Enum
+from typing import Optional, Tuple, List
+
+# 引入 DrissionPage 的基础类以进行类型提示
+from DrissionPage import ChromiumPage
+
+logger = logging.getLogger(__name__)
+
+# ==========================================
+# 常量、枚举与键盘布局配置
+# ==========================================
+
+DEFAULT_TYPO_PROBABILITY = 0.02
+
+# 模拟错别字时需要的 QWERTY 键盘相邻按键映射表
+QWERTY_NEIGHBORS = {
+    'q': ['w', 'a', 's'], 'w': ['q', 'e', 'a', 's', 'd'], 'e': ['w', 'r', 's', 'd', 'f'],
+    'r': ['e', 't', 'd', 'f', 'g'], 't': ['r', 'y', 'f', 'g', 'h'], 'y': ['t', 'u', 'g', 'h', 'j'],
+    'u': ['y', 'i', 'h', 'j', 'k'], 'i': ['u', 'o', 'j', 'k', 'l'], 'o': ['i', 'p', 'k', 'l'],
+    'p': ['o', 'l'], 'a': ['q', 'w', 's', 'z', 'x'], 's': ['q', 'w', 'e', 'a', 'd', 'z', 'x', 'c'],
+    'd': ['w', 'e', 'r', 's', 'f', 'x', 'c', 'v'], 'f': ['e', 'r', 't', 'd', 'g', 'c', 'v', 'b'],
+    'g': ['r', 't', 'y', 'f', 'h', 'v', 'b', 'n'], 'h': ['t', 'y', 'u', 'g', 'j', 'b', 'n', 'm'],
+    'j': ['y', 'u', 'i', 'h', 'k', 'n', 'm'], 'k': ['u', 'i', 'o', 'j', 'l', 'm'],
+    'l': ['i', 'o', 'p', 'k'], 'z': ['a', 's', 'x'], 'x': ['a', 's', 'd', 'z', 'c'],
+    'c': ['s', 'd', 'f', 'x', 'v'], 'v': ['d', 'f', 'g', 'c', 'b'],
+    'b': ['f', 'g', 'h', 'v', 'n'], 'n': ['g', 'h', 'j', 'b', 'm'],
+    'm': ['h', 'j', 'k', 'n']
+}
+
+
+class TypoType(Enum):
+    """错字类型"""
+    ADJACENT = "adjacent"          # 按到相邻键
+    TRANSPOSE = "transpose"        # 字母顺序颠倒
+    DOUBLE = "double"              # 重复按键
+    SKIP = "skip"                  # 漏按
+    MISSED_SPACE = "missed_space"  # 漏按空格
+
+
+class Key(Enum):
+    """
+    底层 CDP 需要的特殊按键定义。
+    格式: (key_name, windows_virtual_key_code)
+    """
+    BACKSPACE = ("Backspace", 8)
+    TAB = ("Tab", 9)
+    ENTER = ("Enter", 13)
+    SHIFT = ("Shift", 16)
+    CONTROL = ("Control", 17)
+    ALT = ("Alt", 18)
+    ESCAPE = ("Escape", 27)
+    SPACE = (" ", 32)
+    META = ("Meta", 91)  # Win / Cmd 键
+
+
+@dataclass(frozen=True)
+class TypoResult:
+    """错字生成结果"""
+    typo_type: TypoType
+    wrong_char: str = ''
+
+
+@dataclass(frozen=True)
+class TimingConfig:
+    """高仿真打字节奏时间配置 (单位:秒)"""
+    keystroke_min: float = 0.03
+    keystroke_max: float = 0.12
+    punctuation_min: float = 0.08
+    punctuation_max: float = 0.18
+    thinking_probability: float = 0.02
+    thinking_min: float = 0.3
+    thinking_max: float = 0.7
+    distraction_probability: float = 0.005
+    distraction_min: float = 0.5
+    distraction_max: float = 1.2
+    mistake_realize_min: float = 0.1
+    mistake_realize_max: float = 0.25
+    after_correction_min: float = 0.03
+    after_correction_max: float = 0.08
+    double_press_min: float = 0.02
+    double_press_max: float = 0.05
+    hesitation_min: float = 0.15
+    hesitation_max: float = 0.3
+
+
+@dataclass(frozen=True)
+class TypoConfig:
+    """各类错字发生的权重比例"""
+    adjacent_weight: float = 0.55
+    transpose_weight: float = 0.20
+    double_weight: float = 0.12
+    skip_weight: float = 0.08
+    missed_space_weight: float = 0.05
+
+
+# ==========================================
+# 核心控制类
+# ==========================================
+
+class HumanKeyboard:
+    """
+    DrissionPage 的高仿真人类键盘控制器。
+    提供:
+    - 公共按键操作(press, down, up, hotkey)
+    - 私有文本输入,包含高度拟人的错别字产生、自我纠正、思考停顿与节奏变化。
+    """
+
+    PAUSE_CHARS = frozenset(' .,!?;:\n')
+
+    def __init__(
+        self,
+        page: ChromiumPage,
+        timing: Optional[TimingConfig] = None,
+        typo_config: Optional[TypoConfig] = None,
+    ):
+        """
+        初始化键盘控制器。
+
+        :param page: DrissionPage 的 ChromiumPage 或 ChromiumTab 实例
+        :param timing: 节奏时间配置
+        :param typo_config: 错别字权重配置
+        """
+        self._page = page
+        self._timing = timing or TimingConfig()
+        self._typo_config = typo_config or TypoConfig()
+
+    def press(
+        self,
+        key: Key,
+        modifiers: Optional[int] = None,
+        interval: float = 0.1,
+    ):
+        """
+        按下一个键并释放。
+        
+        :param key: 要按的键 (来自 Key enum)
+        :param modifiers: 组合键掩码 (Alt=1, Ctrl=2, Meta=4, Shift=8)
+        :param interval: 按下和释放之间的延迟
+        """
+        logger.debug(f'Pressing key: {key.name} with modifiers: {modifiers}')
+        self.down(key, modifiers)
+        time.sleep(interval)
+        self.up(key)
+
+    def down(self, key: Key, modifiers: Optional[int] = None):
+        """按下按键(不释放)"""
+        key_name, code = key.value
+        logger.debug(f'Key down: {key_name}')
+        
+        params = {
+            "type": "keyDown",
+            "key": key_name,
+            "windowsVirtualKeyCode": code,
+            "nativeVirtualKeyCode": code,
+        }
+        if modifiers is not None:
+            params["modifiers"] = modifiers
+            
+        self._page.run_cdp("Input.dispatchKeyEvent", **params)
+
+    def up(self, key: Key):
+        """释放按键"""
+        key_name, code = key.value
+        logger.debug(f'Key up: {key_name}')
+        
+        self._page.run_cdp(
+            "Input.dispatchKeyEvent",
+            type="keyUp",
+            key=key_name,
+            windowsVirtualKeyCode=code,
+            nativeVirtualKeyCode=code,
+        )
+
+    def hotkey(self, key1: Key, key2: Key, key3: Optional[Key] = None):
+        """
+        执行组合快捷键(最多支持3个键)。
+        例如:keyboard.hotkey(Key.CONTROL, Key.C)
+        """
+        logger.debug(f'Hotkey: {key1.name} + {key2.name}' + (f' + {key3.name}' if key3 else ''))
+        keys = [key1, key2]
+        if key3 is not None:
+            keys.append(key3)
+
+        modifiers, non_modifiers = self._split_modifiers_and_keys(keys)
+        modifier_value = self._calculate_modifier_value(modifiers)
+
+        for key in non_modifiers:
+            self.down(key, modifiers=modifier_value)
+            time.sleep(0.05)
+
+        time.sleep(0.1)
+
+        for key in reversed(non_modifiers):
+            self.up(key)
+            time.sleep(0.05)
+
+    def type_text(
+        self,
+        text: str,
+        humanize: bool = False,
+        interval: Optional[float] = None,
+    ):
+        """
+        逐字输入文本。
+
+        :param text: 要输入的文本
+        :param humanize: 是否开启拟人化(变速、犯错后退格重打、思考停顿)
+        :param interval: (已弃用) 字符输入间隔
+        """
+        if interval is not None:
+            warnings.warn(
+                '"interval" 参数已被弃用。请使用 "humanize=True" 开启真实的人类打字模拟。',
+                DeprecationWarning,
+                stacklevel=2,
+            )
+
+        if humanize:
+            self._type_text_humanized(text)
+            return
+
+        for current_char in text:
+            self._type_char(current_char)
+            time.sleep(0.05)
+
+    def _type_text_humanized(self, text: str):
+        """高拟人化文本输入核心逻辑"""
+        char_index = 0
+        while char_index < len(text):
+            current_char = text[char_index]
+            next_char = text[char_index + 1] if char_index + 1 < len(text) else None
+
+            # 判断是否打错字并执行错字修补逻辑,返回 True 表示下个字应跳过
+            should_skip_next = self._process_char_with_typo(current_char, next_char)
+
+            if should_skip_next:
+                char_index += 1
+
+            self._apply_realistic_delay(current_char)
+            char_index += 1
+
+    def _type_char(self, char: str):
+        """通过底层 CDP 输入单个普通字符"""
+        # 下拉字符事件
+        self._page.run_cdp(
+            "Input.dispatchKeyEvent",
+            type="keyDown",
+            key=char,
+            text=char,
+            unmodifiedText=char
+        )
+        # 抬起字符事件
+        self._page.run_cdp(
+            "Input.dispatchKeyEvent",
+            type="keyUp",
+            key=char
+        )
+
+    def _type_backspace(self):
+        """输入退格键(用来模拟人打错字回删)"""
+        self.press(Key.BACKSPACE)
+
+    def _process_char_with_typo(
+        self,
+        current_char: str,
+        next_char: Optional[str],
+    ) -> bool:
+        """处理错别字产生与修复。返回 True 表示下个字符需要跳过处理"""
+        if not self._should_make_typo():
+            self._type_char(current_char)
+            return False
+
+        typo = self._generate_typo(current_char, next_char)
+        return self._handle_typo(current_char, next_char, typo)
+
+    def _handle_typo(
+        self,
+        current_char: str,
+        next_char: Optional[str],
+        typo: TypoResult,
+    ) -> bool:
+        """根据错别字类型执行具体的打错和修正行为"""
+        if typo.typo_type == TypoType.ADJACENT:
+            self._do_adjacent_typo(current_char, typo.wrong_char)
+            return False
+
+        if typo.typo_type == TypoType.TRANSPOSE and next_char:
+            self._do_transpose_typo(current_char, next_char)
+            return True
+
+        if typo.typo_type == TypoType.DOUBLE:
+            self._do_double_typo(current_char)
+            return False
+
+        if typo.typo_type == TypoType.SKIP:
+            self._do_skip_typo(current_char)
+            return False
+
+        if typo.typo_type == TypoType.MISSED_SPACE and current_char == ' ' and next_char:
+            self._do_missed_space_typo(current_char, next_char)
+            return True
+
+        self._type_char(current_char)
+        return False
+
+    def _do_adjacent_typo(self, correct_char: str, wrong_char: str):
+        """打错相邻键 -> 停顿 -> 退格删除 -> 补上正确的"""
+        timing = self._timing
+        self._type_char(wrong_char)
+        time.sleep(random.uniform(timing.mistake_realize_min, timing.mistake_realize_max))
+        self._type_backspace()
+        time.sleep(random.uniform(timing.after_correction_min, timing.after_correction_max))
+        self._type_char(correct_char)
+
+    def _do_transpose_typo(self, current_char: str, next_char: str):
+        """字母顺序打反 -> 停顿 -> 删两格 -> 重新打正确的顺序"""
+        timing = self._timing
+        self._type_char(next_char)
+        time.sleep(random.uniform(timing.keystroke_min, timing.keystroke_max))
+        self._type_char(current_char)
+
+        time.sleep(random.uniform(timing.mistake_realize_min, timing.mistake_realize_max))
+        self._type_backspace()
+        self._type_backspace()
+        time.sleep(random.uniform(timing.after_correction_min, timing.after_correction_max))
+
+        self._type_char(current_char)
+        time.sleep(random.uniform(timing.keystroke_min, timing.keystroke_max))
+        self._type_char(next_char)
+
+    def _do_double_typo(self, current_char: str):
+        """不小心连按两次 -> 停顿 -> 退格删掉多余的一个"""
+        timing = self._timing
+        self._type_char(current_char)
+        time.sleep(random.uniform(timing.double_press_min, timing.double_press_max))
+        self._type_char(current_char)
+        time.sleep(random.uniform(timing.mistake_realize_min, timing.mistake_realize_max))
+        self._type_backspace()
+
+    def _do_skip_typo(self, current_char: str):
+        """大脑卡壳漏打:犹豫停顿 -> 接着打正常的"""
+        timing = self._timing
+        time.sleep(random.uniform(timing.hesitation_min, timing.hesitation_max))
+        self._type_char(current_char)
+
+    def _do_missed_space_typo(self, space_char: str, next_char: str):
+        """漏敲空格 -> 接着敲了下一个字 -> 发现错误 -> 退格删掉 -> 补空格 -> 重打下个字"""
+        timing = self._timing
+        self._type_char(next_char)
+        time.sleep(random.uniform(timing.mistake_realize_min, timing.mistake_realize_max))
+        self._type_backspace()
+        time.sleep(random.uniform(timing.after_correction_min, timing.after_correction_max))
+        self._type_char(space_char)
+        time.sleep(random.uniform(timing.after_correction_min, timing.after_correction_max))
+        self._type_char(next_char)
+
+    def _apply_realistic_delay(self, typed_char: str):
+        """根据输入的不同字符和人的状态,加入随机延迟"""
+        timing = self._timing
+        delay = random.uniform(timing.keystroke_min, timing.keystroke_max)
+
+        # 遇到标点符号,延迟加长
+        if typed_char in self.PAUSE_CHARS:
+            delay += random.uniform(timing.punctuation_min, timing.punctuation_max)
+
+        # 模拟突然思考
+        if random.random() < timing.thinking_probability:
+            delay += random.uniform(timing.thinking_min, timing.thinking_max)
+
+        # 模拟被打断分心
+        if random.random() < timing.distraction_probability:
+            delay += random.uniform(timing.distraction_min, timing.distraction_max)
+
+        time.sleep(delay)
+
+    @staticmethod
+    def _should_make_typo() -> bool:
+        """决定此时是否发生手误"""
+        return random.random() < DEFAULT_TYPO_PROBABILITY
+
+    def _generate_typo(self, current_char: str, next_char: Optional[str]) -> TypoResult:
+        """生成一个具体的错误结果"""
+        typo_type = self._select_typo_type()
+        return self._create_typo(typo_type, current_char, next_char)
+
+    def _select_typo_type(self) -> TypoType:
+        """通过权重随机挑选错误类型"""
+        config = self._typo_config
+        typo_types = [
+            TypoType.ADJACENT,
+            TypoType.TRANSPOSE,
+            TypoType.DOUBLE,
+            TypoType.SKIP,
+            TypoType.MISSED_SPACE,
+        ]
+        typo_weights = [
+            config.adjacent_weight,
+            config.transpose_weight,
+            config.double_weight,
+            config.skip_weight,
+            config.missed_space_weight,
+        ]
+        return random.choices(typo_types, weights=typo_weights, k=1)[0]
+
+    def _create_typo(
+        self,
+        typo_type: TypoType,
+        current_char: str,
+        next_char: Optional[str],
+    ) -> TypoResult:
+        """构建错字返回结果"""
+        if typo_type == TypoType.ADJACENT:
+            return self._create_adjacent_typo(current_char)
+        elif typo_type == TypoType.TRANSPOSE:
+            return self._create_transpose_typo(current_char, next_char)
+        elif typo_type == TypoType.MISSED_SPACE:
+            return self._create_missed_space_typo(current_char)
+        elif typo_type == TypoType.DOUBLE:
+            return TypoResult(typo_type=TypoType.DOUBLE, wrong_char=current_char)
+        else:
+            return TypoResult(typo_type=TypoType.SKIP)
+
+    def _create_transpose_typo(self, current_char: str, next_char: Optional[str]) -> TypoResult:
+        """顺序颠倒错误(如果下个不是字母则降级为按错相邻键)"""
+        if next_char and next_char.isalpha():
+            return TypoResult(typo_type=TypoType.TRANSPOSE, wrong_char=next_char)
+        return self._create_adjacent_typo(current_char)
+
+    def _create_missed_space_typo(self, current_char: str) -> TypoResult:
+        """漏打空格错误(如果当前本不是空格则降级为按错相邻键)"""
+        if current_char == ' ':
+            return TypoResult(typo_type=TypoType.MISSED_SPACE)
+        return self._create_adjacent_typo(current_char)
+
+    @staticmethod
+    def _create_adjacent_typo(original_char: str) -> TypoResult:
+        """生成按错相邻键的错误"""
+        lowercase_char = original_char.lower()
+
+        # 如果字典中没有对应相邻键,降级成连按两下
+        if lowercase_char not in QWERTY_NEIGHBORS:
+            return TypoResult(typo_type=TypoType.DOUBLE, wrong_char=original_char)
+
+        adjacent_char = random.choice(QWERTY_NEIGHBORS[lowercase_char])
+
+        if original_char.isupper():
+            adjacent_char = adjacent_char.upper()
+
+        return TypoResult(typo_type=TypoType.ADJACENT, wrong_char=adjacent_char)
+
+    @staticmethod
+    def _split_modifiers_and_keys(keys: List[Key]) -> Tuple[List[Key], List[Key]]:
+        """分离修饰键(Ctrl/Shift等)和普通键"""
+        modifier_keys = {Key.CONTROL, Key.SHIFT, Key.ALT, Key.META}
+        modifiers = [key for key in keys if key in modifier_keys]
+        non_modifiers = [key for key in keys if key not in modifier_keys]
+        return modifiers, non_modifiers
+
+    @staticmethod
+    def _calculate_modifier_value(modifiers: List[Key]) -> Optional[int]:
+        """计算 CDP 事件所需的 modifiers 掩码值"""
+        if not modifiers:
+            return None
+
+        modifier_map = {
+            Key.ALT: 1,
+            Key.CONTROL: 2,
+            Key.META: 4,
+            Key.SHIFT: 8,
+        }
+        value = sum(modifier_map.get(mod, 0) for mod in modifiers)
+        return value if value > 0 else None

+ 167 - 0
utils/math_utils.py

@@ -0,0 +1,167 @@
+from __future__ import annotations
+
+import math
+import random
+
+
+class CubicBezier:
+    """Cubic Bezier curve solver for smooth animation timing.
+
+    Based on UnitBezier from WebKit/Chromium. Maps a time progress value
+    to an eased progress value using a cubic Bezier curve.
+    """
+
+    def __init__(self, point1_x: float, point1_y: float, point2_x: float, point2_y: float):
+        self.coefficient_c_x = 3.0 * point1_x
+        self.coefficient_b_x = 3.0 * (point2_x - point1_x) - self.coefficient_c_x
+        self.coefficient_a_x = 1.0 - self.coefficient_c_x - self.coefficient_b_x
+
+        self.coefficient_c_y = 3.0 * point1_y
+        self.coefficient_b_y = 3.0 * (point2_y - point1_y) - self.coefficient_c_y
+        self.coefficient_a_y = 1.0 - self.coefficient_c_y - self.coefficient_b_y
+
+    def sample_curve_x(self, time_progress: float) -> float:
+        return (
+            (self.coefficient_a_x * time_progress + self.coefficient_b_x) * time_progress
+            + self.coefficient_c_x
+        ) * time_progress
+
+    def sample_curve_y(self, time_progress: float) -> float:
+        return (
+            (self.coefficient_a_y * time_progress + self.coefficient_b_y) * time_progress
+            + self.coefficient_c_y
+        ) * time_progress
+
+    def sample_curve_derivative_x(self, time_progress: float) -> float:
+        return (
+            3.0 * self.coefficient_a_x * time_progress + 2.0 * self.coefficient_b_x
+        ) * time_progress + self.coefficient_c_x
+
+    def solve_curve_x(self, target_x: float, epsilon: float = 1e-6) -> float:
+        """Given an x value, find the corresponding t value."""
+        estimated_t = target_x
+
+        for _ in range(8):
+            current_x = self.sample_curve_x(estimated_t) - target_x
+            if abs(current_x) < epsilon:
+                return estimated_t
+            derivative = self.sample_curve_derivative_x(estimated_t)
+            if abs(derivative) < epsilon:
+                break
+            estimated_t -= current_x / derivative
+
+        lower_bound = 0.0
+        upper_bound = 1.0
+        estimated_t = target_x
+
+        if estimated_t < lower_bound:
+            return lower_bound
+        if estimated_t > upper_bound:
+            return upper_bound
+
+        while lower_bound < upper_bound:
+            current_x = self.sample_curve_x(estimated_t)
+            if abs(current_x - target_x) < epsilon:
+                return estimated_t
+            if target_x > current_x:
+                lower_bound = estimated_t
+            else:
+                upper_bound = estimated_t
+            estimated_t = (upper_bound - lower_bound) * 0.5 + lower_bound
+
+        return estimated_t
+
+    def solve(self, input_x: float) -> float:
+        """Get y value for a given x (time progress)."""
+        return self.sample_curve_y(self.solve_curve_x(input_x))
+
+
+def minimum_jerk(t: float) -> float:
+    """Minimum jerk position at normalized time t in [0,1].
+
+    Returns 10t^3 - 15t^4 + 6t^5 which produces a bell-shaped velocity
+    profile: slow start, peak in middle, slow end.
+    """
+    t2 = t * t
+    t3 = t2 * t
+    return 10.0 * t3 - 15.0 * t3 * t + 6.0 * t3 * t2
+
+
+def bezier_2d(
+    t: float,
+    p0: tuple[float, float],
+    p1: tuple[float, float],
+    p2: tuple[float, float],
+    p3: tuple[float, float],
+) -> tuple[float, float]:
+    """Evaluate 2D cubic Bezier at parameter t.
+
+    B(t) = (1-t)^3*P0 + 3(1-t)^2*t*P1 + 3(1-t)*t^2*P2 + t^3*P3
+    """
+    u = 1.0 - t
+    u2 = u * u
+    u3 = u2 * u
+    t2 = t * t
+    t3 = t2 * t
+    x = u3 * p0[0] + 3.0 * u2 * t * p1[0] + 3.0 * u * t2 * p2[0] + t3 * p3[0]
+    y = u3 * p0[1] + 3.0 * u2 * t * p1[1] + 3.0 * u * t2 * p2[1] + t3 * p3[1]
+    return (x, y)
+
+
+def fitts_duration(
+    distance: float,
+    target_width: float,
+    a: float,
+    b: float,
+) -> float:
+    """Fitts's Law: MT = a + b * log2(D/W + 1)."""
+    if distance <= 0:
+        return a
+    return a + b * math.log2(distance / target_width + 1.0)
+
+
+def random_control_points(
+    start: tuple[float, float],
+    end: tuple[float, float],
+    curvature_min: float,
+    curvature_max: float,
+    curvature_asymmetry: float,
+    short_distance_threshold: float,
+) -> tuple[tuple[float, float], tuple[float, float]]:
+    """Generate randomized 2D Bezier control points for a curved mouse path.
+
+    Control points are offset perpendicular to the start-end line.
+    The first control point is biased earlier along the path
+    (ballistic phase asymmetry).
+    """
+    dx = end[0] - start[0]
+    dy = end[1] - start[1]
+    distance = math.hypot(dx, dy)
+
+    if distance < 1.0:
+        return (start, end)
+
+    perp = (-dy / distance, dx / distance)
+
+    scale = min(1.0, distance / short_distance_threshold)
+    offsets = (
+        random.uniform(curvature_min, curvature_max) * distance * scale,
+        random.uniform(curvature_min, curvature_max) * distance * scale,
+    )
+
+    sign = random.choice([-1.0, 1.0])
+    t1 = random.uniform(0.2, curvature_asymmetry)
+    t2 = random.uniform(curvature_asymmetry, 0.8)
+
+    cp1 = (
+        start[0] + dx * t1 + perp[0] * offsets[0] * sign,
+        start[1] + dy * t1 + perp[1] * offsets[0] * sign,
+    )
+
+    counter = random.uniform(0.3, 1.0)
+    cp2 = (
+        start[0] + dx * t2 + perp[0] * offsets[1] * sign * counter,
+        start[1] + dy * t2 + perp[1] * offsets[1] * sign * counter,
+    )
+
+    return (cp1, cp2)

+ 479 - 0
utils/mouse.py

@@ -0,0 +1,479 @@
+# from DrissionPage import ChromiumPage
+# # 假设上面的代码保存在 human_mouse.py
+# from human_mouse import HumanMouse 
+
+# # 初始化 DrissionPage
+# page = ChromiumPage()
+# page.get('https://example.com')
+
+# # 初始化我们移植的鼠标控制器,开启 debug 模式可在页面上看到鼠标轨迹点
+# mouse = HumanMouse(page, debug=True)
+
+# # 1. 拟人化移动到一个坐标
+# mouse.move(500, 300, humanize=True)
+
+# # 2. 拟人化点击
+# mouse.click(600, 400, humanize=True)
+
+# # 3. 如果需要结合 DrissionPage 元素一起使用:
+# ele = page.ele('@href=https://www.iana.org/domains/example')
+# # 获取元素在屏幕中的中点坐标
+# x, y = ele.rect.midpoint 
+# mouse.click(x, y, humanize=True)
+
+import logging
+import math
+import random
+import time
+from dataclasses import dataclass
+from enum import Enum
+from typing import Optional, Tuple
+
+from DrissionPage import ChromiumPage
+
+from math_utils import (
+    bezier_2d,
+    fitts_duration,
+    minimum_jerk,
+    random_control_points,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class MouseButton(Enum):
+    LEFT = "left"
+    RIGHT = "right"
+    MIDDLE = "middle"
+
+
+class MouseEventType(Enum):
+    MOUSE_MOVED = "mouseMoved"
+    MOUSE_PRESSED = "mousePressed"
+    MOUSE_RELEASED = "mouseReleased"
+
+
+@dataclass(frozen=True)
+class MouseTimingConfig:
+    """模拟人类鼠标移动物理特性的配置"""
+
+    fitts_a: float = 0.070
+    fitts_b: float = 0.150
+
+    frame_interval: float = 0.012
+    frame_interval_variance: float = 0.004
+
+    curvature_min: float = 0.10
+    curvature_max: float = 0.30
+    curvature_asymmetry: float = 0.6
+
+    short_distance_threshold: float = 50.0
+
+    tremor_amplitude: float = 1.0
+
+    overshoot_probability: float = 0.70
+    overshoot_distance_min: float = 0.03
+    overshoot_distance_max: float = 0.12
+    overshoot_speed_threshold: float = 200.0
+
+    pre_click_pause_min: float = 0.05
+    pre_click_pause_max: float = 0.20
+    click_hold_min: float = 0.05
+    click_hold_max: float = 0.15
+    double_click_interval_min: float = 0.05
+    double_click_interval_max: float = 0.10
+    drag_start_pause_min: float = 0.08
+    drag_start_pause_max: float = 0.20
+    drag_end_pause_min: float = 0.05
+    drag_end_pause_max: float = 0.15
+
+    micro_pause_probability: float = 0.03
+    micro_pause_min: float = 0.015
+    micro_pause_max: float = 0.04
+
+    min_duration: float = 0.08
+    max_duration: float = 2.5
+
+
+class HumanMouse:
+    """
+    DrissionPage 的高仿真人类鼠标控制器。
+    提供移动、点击、双击和拖拽方法,采用贝塞尔曲线、Fitts 定律、生理性微颤和过冲校正。
+    """
+
+    _DEBUG_INIT_JS = """
+    if (!document.getElementById('__dp_mouse_debug')) {
+        const canvas = document.createElement('canvas');
+        canvas.id = '__dp_mouse_debug';
+        canvas.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;'
+            + 'pointer-events:none;z-index:2147483647;';
+        canvas.width = window.innerWidth;
+        canvas.height = window.innerHeight;
+        document.body.appendChild(canvas);
+        window.__dp_debug_ctx = canvas.getContext('2d');
+    }
+    """
+
+    _DEBUG_DOT_JS = """
+    if (window.__dp_debug_ctx) {
+        const ctx = window.__dp_debug_ctx;
+        ctx.beginPath();
+        ctx.arc(%s, %s, %s, 0, 2 * Math.PI);
+        ctx.fillStyle = '%s';
+        ctx.fill();
+    }
+    """
+
+    def __init__(
+        self,
+        page: ChromiumPage,
+        timing: Optional[MouseTimingConfig] = None,
+        debug: bool = False,
+    ):
+        """
+        初始化鼠标控制器
+        :param page: DrissionPage 的 ChromiumPage 或 ChromiumTab 实例
+        :param timing: 轨迹与时间配置
+        :param debug: 是否在页面上绘制调试红蓝点
+        """
+        self._page = page
+        self._timing = timing or MouseTimingConfig()
+        self._position: Tuple[float, float] = (0.0, 0.0)
+        self._debug = debug
+        self._debug_initialized = False
+
+    @property
+    def timing(self) -> MouseTimingConfig:
+        return self._timing
+
+    @timing.setter
+    def timing(self, config: MouseTimingConfig) -> None:
+        self._timing = config
+
+    @property
+    def debug(self) -> bool:
+        return self._debug
+
+    @debug.setter
+    def debug(self, value: bool) -> None:
+        self._debug = value
+        self._debug_initialized = False
+
+    def move(self, x: float, y: float, *, humanize: bool = False) -> None:
+        """移动鼠标"""
+        if humanize:
+            self._move_humanized(x, y)
+            return
+
+        self._dispatch_move(x, y)
+        
+    def move_to(self, ele, duration: float = 0.8) -> None:
+        """
+        拟人化移动鼠标到指定元素上。
+        
+        :param ele: DrissionPage 的 ChromiumElement 对象
+        :param duration: 移动耗时 (秒)。如果传入 None,则使用内置的 Fitts 物理定律自动计算耗时。
+        """
+        # 1. 确保元素在视口可见(模拟人眼要看到才能移过去)
+        ele.scroll.to_see()
+        time.sleep(random.uniform(0.1, 0.3)) # 看到元素后人类通常有短暂的反应时间
+
+        # 2. 获取元素的中心点和尺寸 (DrissionPage 的 rect 属性非常方便)
+        center_x, center_y = ele.rect.midpoint
+        width, height = ele.rect.size
+
+        # 3. 拟人化处理:人类很难每次都精准移动到绝对中心
+        # 我们在元素中心点 60% 的长宽范围内随机取一个落点
+        offset_x = random.uniform(-width * 0.3, width * 0.3)
+        offset_y = random.uniform(-height * 0.3, height * 0.3)
+        
+        target_x = center_x + offset_x
+        target_y = center_y + offset_y
+
+        # 4. 执行移动
+        self._move_humanized(target_x, target_y, custom_duration=duration)
+
+    def click(
+        self,
+        x: float,
+        y: float,
+        *,
+        button: MouseButton = MouseButton.LEFT,
+        click_count: int = 1,
+        humanize: bool = False,
+    ) -> None:
+        """点击鼠标"""
+        if humanize:
+            self._click_humanized(x, y, button, click_count)
+            return
+
+        self._dispatch_move(x, y)
+        self._dispatch_button(MouseEventType.MOUSE_PRESSED, button, click_count)
+        self._dispatch_button(MouseEventType.MOUSE_RELEASED, button, click_count)
+
+    def double_click(
+        self,
+        x: float,
+        y: float,
+        *,
+        button: MouseButton = MouseButton.LEFT,
+        humanize: bool = False,
+    ) -> None:
+        """双击鼠标"""
+        self.click(x, y, button=button, click_count=2, humanize=humanize)
+
+    def down(self, button: MouseButton = MouseButton.LEFT) -> None:
+        """按下鼠标按键"""
+        self._dispatch_button(MouseEventType.MOUSE_PRESSED, button)
+
+    def up(self, button: MouseButton = MouseButton.LEFT) -> None:
+        """释放鼠标按键"""
+        self._dispatch_button(MouseEventType.MOUSE_RELEASED, button)
+
+    def drag(
+        self,
+        start_x: float,
+        start_y: float,
+        end_x: float,
+        end_y: float,
+        *,
+        humanize: bool = False,
+    ) -> None:
+        """拖拽鼠标"""
+        if humanize:
+            self._drag_humanized(start_x, start_y, end_x, end_y)
+            return
+
+        self._dispatch_move(start_x, start_y)
+        self._dispatch_button(MouseEventType.MOUSE_PRESSED, MouseButton.LEFT)
+        self._dispatch_move(end_x, end_y)
+        self._dispatch_button(MouseEventType.MOUSE_RELEASED, MouseButton.LEFT)
+
+    def _move_humanized(self, target_x: float, target_y: float, custom_duration: Optional[float] = None) -> None:
+        """拟人化移动核心逻辑"""
+        start = self._position
+        target = (target_x, target_y)
+        distance = math.hypot(target_x - start[0], target_y - start[1])
+
+        if distance < 1.0:
+            self._dispatch_move(target_x, target_y)
+            return
+
+        config = self._timing
+        
+        if custom_duration is not None:
+            duration = custom_duration
+        else:
+            duration = fitts_duration(distance, 20.0, config.fitts_a, config.fitts_b)
+            duration = max(config.min_duration, min(duration, config.max_duration))
+
+        should_overshoot = (
+            distance > config.overshoot_speed_threshold
+            and random.random() < config.overshoot_probability
+        )
+
+        if should_overshoot:
+            self._move_with_overshoot(start, target, duration)
+        else:
+            cp1, cp2 = self._get_control_points(start, target)
+            self._perform_movement_loop(start, target, duration, cp1, cp2)
+
+        self._dispatch_move(target_x, target_y)
+
+    def _move_with_overshoot(
+        self,
+        start: Tuple[float, float],
+        target: Tuple[float, float],
+        duration: float,
+    ) -> None:
+        """带过冲现象的移动"""
+        config = self._timing
+        overshoot_fraction = random.uniform(
+            config.overshoot_distance_min, config.overshoot_distance_max
+        )
+        dx = target[0] - start[0]
+        dy = target[1] - start[1]
+        
+        overshoot = (target[0] + dx * overshoot_fraction, target[1] + dy * overshoot_fraction)
+
+        cp1, cp2 = self._get_control_points(start, overshoot)
+        self._perform_movement_loop(start, overshoot, duration * 0.85, cp1, cp2)
+
+        cp1, cp2 = self._get_control_points(overshoot, target)
+        self._perform_movement_loop(overshoot, target, duration * 0.15, cp1, cp2)
+
+    def _perform_movement_loop(
+        self,
+        start: Tuple[float, float],
+        end: Tuple[float, float],
+        duration: float,
+        cp1: Tuple[float, float],
+        cp2: Tuple[float, float],
+    ) -> None:
+        """按帧渲染并执行移动循环"""
+        config = self._timing
+        start_time = time.perf_counter()
+        prev = (start[0], start[1], start_time)
+
+        while True:
+            now = time.perf_counter()
+            elapsed = now - start_time
+
+            if elapsed >= duration:
+                break
+
+            t = minimum_jerk(elapsed / duration)
+            x, y = bezier_2d(t, start, cp1, cp2, end)
+
+            sigma = self._compute_tremor_sigma(x, y, now, prev, config)
+            x += random.gauss(0, sigma)
+            y += random.gauss(0, sigma)
+
+            self._dispatch_move(x, y)
+            prev = (x, y, now)
+
+            frame_delay = config.frame_interval + random.uniform(
+                -config.frame_interval_variance, config.frame_interval_variance
+            )
+            time.sleep(max(0.001, frame_delay))
+
+            if random.random() < config.micro_pause_probability:
+                pause = random.uniform(config.micro_pause_min, config.micro_pause_max)
+                time.sleep(pause)
+                start_time += pause  # 补偿停顿时间
+
+    @staticmethod
+    def _compute_tremor_sigma(
+        x: float,
+        y: float,
+        now: float,
+        prev: Tuple[float, float, float],
+        config: MouseTimingConfig,
+    ) -> float:
+        """动态计算手抖幅度"""
+        dt = now - prev[2]
+        if dt > 0:
+            velocity = math.hypot(x - prev[0], y - prev[1]) / dt
+            speed_factor = max(0.2, 1.0 - velocity / 500.0)
+        else:
+            speed_factor = 1.0
+        return config.tremor_amplitude * speed_factor
+
+    def _click_humanized(
+        self,
+        x: float,
+        y: float,
+        button: MouseButton,
+        click_count: int,
+    ) -> None:
+        """拟人化点击"""
+        config = self._timing
+
+        self._move_humanized(x, y)
+
+        pre_pause = random.uniform(config.pre_click_pause_min, config.pre_click_pause_max)
+        time.sleep(pre_pause)
+
+        for i in range(click_count):
+            current_count = i + 1
+            self._dispatch_button(MouseEventType.MOUSE_PRESSED, button, current_count)
+
+            hold = random.uniform(config.click_hold_min, config.click_hold_max)
+            time.sleep(hold)
+
+            self._dispatch_button(MouseEventType.MOUSE_RELEASED, button, current_count)
+
+            if current_count < click_count:
+                interval = random.uniform(
+                    config.double_click_interval_min,
+                    config.double_click_interval_max,
+                )
+                time.sleep(interval)
+
+    def _drag_humanized(
+        self,
+        start_x: float,
+        start_y: float,
+        end_x: float,
+        end_y: float,
+    ) -> None:
+        """拟人化拖拽"""
+        config = self._timing
+
+        self._move_humanized(start_x, start_y)
+        self._dispatch_button(MouseEventType.MOUSE_PRESSED, MouseButton.LEFT)
+
+        drag_start_pause = random.uniform(config.drag_start_pause_min, config.drag_start_pause_max)
+        time.sleep(drag_start_pause)
+
+        start = self._position
+        distance = math.hypot(end_x - start[0], end_y - start[1])
+        duration = fitts_duration(distance, 20.0, config.fitts_a, config.fitts_b)
+        duration = max(config.min_duration, min(duration, config.max_duration))
+
+        cp1, cp2 = self._get_control_points(start, (end_x, end_y))
+        self._perform_movement_loop(start, (end_x, end_y), duration, cp1, cp2)
+        self._dispatch_move(end_x, end_y)
+
+        drag_end_pause = random.uniform(config.drag_end_pause_min, config.drag_end_pause_max)
+        time.sleep(drag_end_pause)
+
+        self._dispatch_button(MouseEventType.MOUSE_RELEASED, MouseButton.LEFT)
+
+    def _get_control_points(
+        self,
+        start: Tuple[float, float],
+        end: Tuple[float, float],
+    ) -> Tuple[Tuple[float, float], Tuple[float, float]]:
+        config = self._timing
+        return random_control_points(
+            start,
+            end,
+            config.curvature_min,
+            config.curvature_max,
+            config.curvature_asymmetry,
+            config.short_distance_threshold,
+        )
+
+    def _dispatch_move(self, x: float, y: float) -> None:
+        """发送 CDP 鼠标移动指令"""
+        self._page.run_cdp(
+            "Input.dispatchMouseEvent",
+            type=MouseEventType.MOUSE_MOVED.value,
+            x=int(round(x)),
+            y=int(round(y))
+        )
+        self._position = (x, y)
+
+        if self._debug:
+            self._debug_draw_dot(x, y, radius=2, color='rgba(0,150,255,0.6)')
+
+    def _dispatch_button(
+        self,
+        event_type: MouseEventType,
+        button: MouseButton,
+        click_count: int = 1,
+    ) -> None:
+        """发送 CDP 鼠标按键指令"""
+        self._page.run_cdp(
+            "Input.dispatchMouseEvent",
+            type=event_type.value,
+            button=button.value,
+            x=int(round(self._position[0])),
+            y=int(round(self._position[1])),
+            clickCount=click_count
+        )
+
+        if self._debug and event_type == MouseEventType.MOUSE_PRESSED:
+            self._debug_draw_dot(
+                self._position[0], self._position[1], radius=6, color='rgba(255,50,50,0.9)'
+            )
+
+    def _debug_draw_dot(self, x: float, y: float, radius: int, color: str) -> None:
+        """绘制轨迹调试点"""
+        if not self._debug_initialized:
+            self._page.run_js(self._DEBUG_INIT_JS)
+            self._debug_initialized = True
+
+        script = self._DEBUG_DOT_JS % (int(round(x)), int(round(y)), radius, color)
+        self._page.run_js(script)

+ 0 - 133
utils/mouse_helper.py

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

+ 328 - 0
utils/scroll.py

@@ -0,0 +1,328 @@
+# from DrissionPage import ChromiumPage
+# # 将上面的代码保存在 human_scroll.py,然后导入
+# # from human_scroll import HumanScroll, ScrollPosition
+
+# page = ChromiumPage()
+# page.get('https://example.com') # 打开一个有很长滚动条的网页
+
+# # 初始化拟人化滚动引擎
+# scroll_engine = HumanScroll(page)
+
+# # 1. 拟人化滚动到底部(它会自动拆分多次滑动,中间停顿,有时还会滑过头再弹回来)
+# print("开始拟人化滚动到底部...")
+# scroll_engine.to_bottom(humanize=True)
+
+# # 2. 拟人化向上滚动 800 像素
+# print("向上滚动 800 像素...")
+# scroll_engine.by(ScrollPosition.UP, 800, humanize=True)
+
+# # 3. 如果不需要拟人化,也可以作为普通的平滑滚动工具
+# print("瞬间回到顶部...")
+# scroll_engine.to_top(smooth=True, humanize=False)
+
+
+import time
+import random
+from enum import Enum
+from dataclasses import dataclass
+from typing import Optional, Tuple
+from DrissionPage import ChromiumPage
+from math_utils import CubicBezier
+
+# ==========================================
+# 1. 枚举与配置类
+# ==========================================
+class ScrollPosition(Enum):
+    UP = "UP"
+    DOWN = "DOWN"
+    LEFT = "LEFT"
+    RIGHT = "RIGHT"
+
+@dataclass(frozen=True)
+class ScrollTimingConfig:
+    """真实滚动物理效果配置参数"""
+    min_duration: float = 0.5
+    max_duration: float = 1.5
+    bezier_points: Tuple[float, float, float, float] = (0.645, 0.045, 0.355, 1.0)
+    frame_interval: float = 0.012
+    delta_jitter: int = 3
+    micro_pause_probability: float = 0.05
+    micro_pause_min: float = 0.02
+    micro_pause_max: float = 0.05
+    overshoot_probability: float = 0.15
+    overshoot_factor_min: float = 1.02
+    overshoot_factor_max: float = 1.08
+
+# ==========================================
+# 3. DrissionPage 拟人化滚动核心类
+# ==========================================
+class HumanScroll:
+    """
+    基于 DrissionPage 的网页拟人化滚动 API。
+    """
+    def __init__(self, page: ChromiumPage, timing: Optional[ScrollTimingConfig] = None):
+        """
+        初始化
+        :param page: DrissionPage 的 page 对象
+        :param timing: 滚动配置参数
+        """
+        self._page = page
+        self._timing = timing or ScrollTimingConfig()
+
+    def by(self, position: ScrollPosition, distance: int | float, smooth: bool = True, humanize: bool = False):
+        """按指定方向和距离滚动"""
+        if humanize:
+            self._scroll_humanized(position, distance)
+            return
+
+        axis, scroll_distance = self._get_axis_and_distance(position, distance)
+        behavior = 'smooth' if smooth else 'auto'
+        script = f"window.scrollBy({{ {axis}: {scroll_distance}, behavior: '{behavior}' }});"
+        self._page.run_js(script)
+        
+    def scroll_to_element(self, ele, humanize: bool = True):
+        """将元素拟人化滚动到视口中央偏上 (符合人类阅读习惯)"""
+        # 获取视口高度
+        viewport_height = float(self._page.run_js("return window.innerHeight || 600;"))
+        
+        try:
+            # 获取元素当前相对于屏幕顶部的坐标 (getBoundingClientRect)
+            rect = ele.run_js("return this.getBoundingClientRect();")
+            ele_y = rect.get('top', 0)
+        except Exception:
+            return # 找不到元素
+            
+        # 计算目标:让元素处于屏幕 40% 的位置
+        target_y = viewport_height * 0.4 
+        distance = ele_y - target_y
+        
+        # 如果距离小于 50 像素(已经在视野舒适区了),就不再滚动
+        if abs(distance) < 50:
+            return
+            
+        # 根据正负距离决定向上还是向下滚动
+        if distance > 0:
+            self.by(ScrollPosition.DOWN, distance, humanize=humanize)
+        else:
+            self.by(ScrollPosition.UP, abs(distance), humanize=humanize)
+
+    def to_top(self, smooth: bool = True, humanize: bool = False):
+        """滚动到顶部"""
+        if humanize:
+            self._scroll_to_end_humanized(ScrollPosition.UP)
+            return
+
+        behavior = 'smooth' if smooth else 'auto'
+        script = f"window.scrollTo({{ top: 0, behavior: '{behavior}' }});"
+        self._page.run_js(script)
+
+    def to_bottom(self, smooth: bool = True, humanize: bool = False):
+        """滚动到底部"""
+        if humanize:
+            self._scroll_to_end_humanized(ScrollPosition.DOWN)
+            return
+
+        behavior = 'smooth' if smooth else 'auto'
+        script = f"window.scrollTo({{ top: document.body.scrollHeight, behavior: '{behavior}' }});"
+        self._page.run_js(script)
+
+    def _scroll_to_end_humanized(self, position: ScrollPosition):
+        """使用多次人类停顿习惯,一直滚动到底部或顶部"""
+        max_flick_distance = random.uniform(600, 1200)
+        min_remaining_threshold = 30
+        min_stuck_threshold = 5
+        min_flick_distance = 100
+
+        last_remaining = float('inf')
+        stuck_counter = 0
+        max_stuck_attempts = 10
+
+        while True:
+            if position == ScrollPosition.DOWN:
+                remaining = self._get_remaining_scroll_to_bottom()
+            else:
+                remaining = self._get_current_scroll_y()
+
+            if remaining <= min_remaining_threshold:
+                break
+
+            # 卡死检测(防止无限滚动页面或者高度计算异常)
+            has_progressed = abs(remaining - last_remaining) >= min_stuck_threshold
+            if has_progressed:
+                stuck_counter = 0
+            else:
+                stuck_counter += 1
+                if stuck_counter >= max_stuck_attempts:
+                    break
+
+            last_remaining = remaining
+
+            flick_distance = min(remaining, max_flick_distance)
+            if flick_distance < min_flick_distance and remaining > min_flick_distance:
+                flick_distance = min_flick_distance
+
+            self._scroll_humanized(position, flick_distance)
+
+            # 每次大幅度滑动后,稍微停顿阅读
+            pause = random.uniform(0.05, 0.15)
+            time.sleep(pause)
+            max_flick_distance = random.uniform(600, 1200)
+
+    def _scroll_humanized(self, position: ScrollPosition, target_distance: float):
+        """执行单次带有真实物理引擎效果的滚动"""
+        is_vertical = position in {ScrollPosition.UP, ScrollPosition.DOWN}
+        direction = -1 if position in {ScrollPosition.UP, ScrollPosition.LEFT} else 1
+
+        effective_distance = self._calculate_effective_distance(target_distance)
+        duration = self._calculate_duration(effective_distance)
+
+        scrolled_so_far = self._perform_scroll_loop(
+            effective_distance, duration, is_vertical, direction
+        )
+
+        # 模拟人类手滑导致滚动过头(Overshoot),再往回矫正一点
+        if effective_distance > target_distance and scrolled_so_far > target_distance:
+            correction_distance = scrolled_so_far - target_distance
+            correction_direction = -direction
+
+            time.sleep(random.uniform(0.1, 0.2))
+
+            self._scroll_correction(
+                is_vertical=is_vertical,
+                direction=correction_direction,
+                distance=correction_distance,
+            )
+
+    def _perform_scroll_loop(
+        self, effective_distance: float, duration: float, is_vertical: bool, direction: int
+    ) -> float:
+        """主滚动循环,通过底层 CDP 下发 MouseWheel 事件"""
+        timing = self._timing
+        bezier = CubicBezier(*timing.bezier_points)
+
+        start_time = time.perf_counter()
+        current_time = 0.0
+        scrolled_so_far = 0.0
+
+        while current_time < duration:
+            now = time.perf_counter()
+            current_time = now - start_time
+
+            if current_time >= duration:
+                break
+
+            progress = current_time / duration
+            eased_progress = bezier.solve(progress)
+
+            target_pos = effective_distance * eased_progress
+            delta = target_pos - scrolled_so_far
+
+            # 加上手部微抖动带来的像素误差
+            jitter = random.randint(-timing.delta_jitter, timing.delta_jitter)
+            delta += jitter
+            delta = max(delta, 0)
+
+            if delta >= 1:
+                self._dispatch_scroll_event(
+                    delta_x=0 if is_vertical else int(delta * direction),
+                    delta_y=int(delta * direction) if is_vertical else 0,
+                )
+                scrolled_so_far += delta
+
+            # 帧延迟 + 极小的误差
+            frame_delay = timing.frame_interval + random.uniform(-0.002, 0.002)
+            time.sleep(frame_delay)
+
+            # 小概率触发手部微停顿 (浏览时卡住了)
+            if random.random() < timing.micro_pause_probability:
+                pause_duration = random.uniform(timing.micro_pause_min, timing.micro_pause_max)
+                time.sleep(pause_duration)
+                # 扣除停顿时间,保证运动总时间不变
+                start_time += pause_duration 
+
+        return scrolled_so_far
+
+    def _calculate_effective_distance(self, target_distance: float) -> float:
+        timing = self._timing
+        should_overshoot = random.random() < timing.overshoot_probability
+        overshoot_factor = (
+            random.uniform(timing.overshoot_factor_min, timing.overshoot_factor_max)
+            if should_overshoot else 1.0
+        )
+        return target_distance * overshoot_factor
+
+    def _calculate_duration(self, distance: float) -> float:
+        timing = self._timing
+        base_duration = random.uniform(timing.min_duration, timing.max_duration)
+        duration = base_duration * (1 + 0.2 * (distance / 1000))
+        return min(duration, 3.0)
+
+    def _scroll_correction(self, is_vertical: bool, direction: int, distance: float):
+        """修正滚动 (过度回弹)"""
+        timing = self._timing
+        scrolled = 0.0
+        min_correction_velocity = (distance * 0.15) / timing.frame_interval
+        correction_velocity = random.uniform(
+            max(200, min_correction_velocity), max(400, min_correction_velocity * 1.5)
+        )
+
+        while scrolled < distance:
+            frame_delta = correction_velocity * timing.frame_interval
+            frame_delta = min(frame_delta, distance - scrolled)
+
+            self._dispatch_scroll_event(
+                delta_x=0 if is_vertical else int(frame_delta * direction),
+                delta_y=int(frame_delta * direction) if is_vertical else 0,
+            )
+
+            scrolled += frame_delta
+            correction_velocity *= 0.85 # 阻尼减速
+
+            time.sleep(timing.frame_interval)
+
+    def _dispatch_scroll_event(self, delta_x: int, delta_y: int):
+        """
+        [核心反爬突破点]
+        调用 DrissionPage 的 CDP (Chrome DevTools Protocol) 发送原生的鼠标滚轮事件。
+        这会触发网页真实的 `wheel` 事件,完全避开了 window.scrollBy 被检测的风险。
+        """
+        viewport = self._get_viewport_center()
+        # 参数必须与 CDP 协议中的 Input.dispatchMouseEvent 一致
+        self._page.driver.run(
+            'Input.dispatchMouseEvent', 
+            type='mouseWheel',
+            x=viewport[0],
+            y=viewport[1],
+            deltaX=delta_x,
+            deltaY=delta_y
+        )
+
+    def _get_viewport_center(self) -> Tuple[int, int]:
+        """获取浏览器视口中心坐标"""
+        script = "return[window.innerWidth / 2 || 400, window.innerHeight / 2 || 300];"
+        try:
+            res = self._page.run_js(script)
+            return int(res[0]), int(res[1])
+        except Exception:
+            return 400, 300
+
+    def _get_current_scroll_y(self) -> float:
+        """获取当前 Y 轴滚动距离"""
+        return float(self._page.run_js("return window.scrollY || window.pageYOffset || 0;"))
+
+    def _get_remaining_scroll_to_bottom(self) -> float:
+        """获取距离底部的剩余距离"""
+        script = """
+        return Math.max(0, 
+            document.documentElement.scrollHeight - 
+            window.scrollY - 
+            window.innerHeight
+        );
+        """
+        return float(self._page.run_js(script))
+
+    @staticmethod
+    def _get_axis_and_distance(position: ScrollPosition, distance: int | float) -> Tuple[str, float]:
+        if position in {ScrollPosition.UP, ScrollPosition.DOWN}:
+            return 'top', -distance if position == ScrollPosition.UP else float(distance)
+        return 'left', -distance if position == ScrollPosition.LEFT else float(distance)

+ 10 - 8
vfs_registration_bot.py

@@ -516,10 +516,12 @@ class VFSRegistrationBot:
 
 # --- 主流程 ---
 
-def generate_account_details(config, pool_name):
+def generate_account_details(config):
     """生成账号数据字典"""
+    account_prefix = config.get('account_prefix', 'vfs')
+    pool_name = config.get('pool_name', 'vfs')
     rand_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
-    username = f"{pool_name}_{rand_suffix}@{config['email_domain']}.com"
+    username = f"{account_prefix}_{rand_suffix}@{config['email_domain']}.com"
     phone = VFSHelper.generate_mobile_number(config['phone_country_code'])
     
     return {
@@ -535,16 +537,16 @@ def generate_account_details(config, pool_name):
 def main():
     # 配置
     config = {
-        "pool_name": "ie.at.booker",
-        "account_prefix": "ie_at",
+        "pool_name": "ie.hu.booker",
+        "account_prefix": "ie_hu",
         "email_domain": "gmail-app", 
         "master_email": "visafly666@gmail.com",
         "proxy_url": "http://127.0.0.1:7890",
-        "target_count": 5,
+        "target_count": 10,
         "phone_country_code": 353,
         "country_code": "irl",
-        "mission_code": "aut",
-        "website": "https://visa.vfsglobal.com/irl/en/aut/register",
+        "mission_code": "hun",
+        "website": "https://visa.vfsglobal.com/irl/en/hun/register",
     }
     
     bot = VFSRegistrationBot(config)
@@ -553,7 +555,7 @@ def main():
     print(">>> Starting Registration Bot <<<")
     
     while len(success_accounts) < config['target_count']:
-        account = generate_account_details(config, config['account_prefix'])
+        account = generate_account_details(config)
         logger.info(f"Processing Account: {account['username']}")
         
         is_success = bot.register(account)