Ver código fonte

grc plugin update and france visa registration bot

hujiarui 2 dias atrás
pai
commit
994cd9d422
3 arquivos alterados com 561 adições e 109 exclusões
  1. 325 0
      france_visa_registration_bot.py
  2. 187 109
      plugins/grc_plugin.py
  3. 49 0
      toolkit/captcha_breaker.py

+ 325 - 0
france_visa_registration_bot.py

@@ -0,0 +1,325 @@
+import time
+import json
+import os
+import re
+import uuid
+import socket
+import shutil
+import random
+import requests
+import argparse
+import concurrent.futures
+import base64
+from urllib.parse import urlencode
+from datetime import datetime, timedelta
+from typing import Optional, Dict
+from DrissionPage.common import Keys
+from DrissionPage import ChromiumPage, ChromiumOptions
+
+import configure
+from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
+from toolkit.vs_cloud_api import VSCloudApi
+from toolkit.proxy_tunnel import ProxyTunnel
+from vs_types import NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
+from utils.mouse import HumanMouse
+from utils.keyboard import HumanKeyboard
+from utils.scroll import HumanScroll
+from utils.fingerprint_utils import FingerprintGenerator
+from toolkit.captcha_breaker import recognize_captcha_with_qwen
+
+    
+def load_proxies(pool_name):
+    """从 config/proxies.json 读取对应的代理池"""
+    config_path = os.path.join(os.path.dirname(__file__), 'config', 'proxies.json')
+    try:
+        with open(config_path, 'r', encoding='utf-8') as f:
+            data = json.load(f)
+            proxies = data.get(pool_name, [])
+            if not proxies:
+                raise ValueError(f"代理池 '{pool_name}' 为空或不存在!")
+            return proxies
+    except Exception as e:
+        print(f"读取代理配置文件失败: {e}")
+        exit(1) 
+
+class FranceVisaRegistrator:
+    def __init__(self, france_visa_url, proxy_config: Optional[Dict]=None, capsolver_key: Optional[str]=None, user_inputs: Optional[Dict]=None):
+        self.proxy_config = proxy_config
+        self.capsolver_key = capsolver_key
+        self.user_inputs = user_inputs
+        # 隔离的用户数据目录
+        self.instance_id = uuid.uuid4().hex[:8]
+        self.france_visa_url = france_visa_url
+        # self.instance_id = '18d389e9'
+        self.workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"reg_session_{self.instance_id}"))
+        self.page = None
+        self.mouse = None
+        self.keyboard = None
+        
+        # 持有隧道实例
+        self.tunnel = None
+
+    def _log(self, msg):
+        now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        print(f"[{now}][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 save_screenshot(self, name_prefix):
+        try:
+            timestamp = int(time.time())
+            filename = f"{self.instance_id}_{name_prefix}_{timestamp}.jpg"
+            save_path = os.path.join("data", filename)
+            os.makedirs("data", exist_ok=True)
+            self.page.get_screenshot(path=save_path, full_page=False)
+            self._log(f"Screenshot saved to {save_path}")
+        except Exception as e:
+            self._log(f"Failed to save screenshot: {e}")
+
+    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)
+        
+        chrome_path = configure.CHROME_PATH
+        if not chrome_path:
+            chrome_path = os.getenv("CHROME_BIN")
+        if chrome_path and os.path.exists(chrome_path):
+            co.set_paths(browser_path=chrome_path)
+        
+        # 2. 代理配置 (支持账号密码)
+        if self.proxy_config and self.proxy_config.get("ip"):
+            p = self.proxy_config
+            if p.get("username") and p.get("password"):
+                self.tunnel = ProxyTunnel(p['ip'], p['port'], p['username'], p['password'])
+                local_proxy = self.tunnel.start()
+                self._log(f"Tunnel started at {local_proxy}")
+                co.set_argument(f'--proxy-server={local_proxy}')
+            else:
+                proxy_str = f"{p.get('proto', 'http')}://{p['ip']}:{p['port']}"
+                co.set_argument(f'--proxy-server={proxy_str}')
+        else:
+            self._log("[WARN] No proxy configured!")
+
+        fingerprint_gen = FingerprintGenerator()
+        specific_fp = fingerprint_gen.generate(self.instance_id)
+        self._log(f'browser fingerprint={specific_fp}')
+        # 3. 反爬及稳定性配置
+        co.headless(False)
+        co.set_argument('--no-sandbox')
+        co.set_argument('--lang=en-us')
+        co.set_argument('--accept-lang=en-us')
+        # 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(f"--fingerprint={specific_fp.get('seed')}")
+        co.set_argument(f"--fingerprint-platform={specific_fp.get('platform')}")
+        co.set_argument(f"--fingerprint-brand={specific_fp.get('brand')}")
+        self.page = ChromiumPage(co)
+        self.page.get(self.france_visa_url)
+        time.sleep(5)
+        cf_bypasser = CloudflareBypasser(self.page, log=True)
+        cf_bypasser.bypass(max_retry=8)
+        time.sleep(3)
+        cf_bypasser.handle_waiting_room()
+        
+        self._log("正在初始化拟人化工具...")
+        self.mouse = HumanMouse(self.page, debug=True)
+        self.keyboard = HumanKeyboard(self.page)
+        self._log("随机化鼠标开始位置...")
+        viewport_width = self.page.rect.viewport_size[0]
+        viewport_height = self.page.rect.viewport_size[1]
+        init_x = random.randint(10, viewport_width - 10)
+        init_y = random.randint(10, viewport_height - 10)
+        self.mouse.move(init_x, init_y)
+        
+    def register(self):
+        username = self.user_inputs.get('username')
+        first_name = self.user_inputs.get('first_name')
+        last_name = self.user_inputs.get('last_name')
+        password = f'Visafly@1234'
+        self.page.wait.ele_deleted('tag:h2@@text():Log in to France-Visas', timeout=5)
+        self.page.ele('tag:button@@text():Create an account').click()
+        time.sleep(5)
+        
+        self.page.ele('tag:input@@name=lastName').input(last_name)
+        self.page.ele('tag:input@@name=firstName').input(first_name)
+        self.page.ele('tag:input@@name=email').input(username)
+        self.page.ele('tag:input@@name=emailVerif').input(username)
+        self.page.ele('tag:input@@name=password').input(password)
+        self.page.ele('tag:input@@name=password-confirm').input(password)
+        self.page.ele('tag:select@@name=ddeLanguage').select('English')
+        captcha = self.page.ele('#captchaComponent').ele('tag:img')
+        src = captcha.attr("src")
+        print(src)
+        base64_data = src.split(",")[1]
+        with open("captcha.png", "wb") as f:
+            f.write(base64.b64decode(base64_data))
+        result = recognize_captcha_with_qwen("captcha.png", "sk-893e895724c6403d81374e515ffaf427")
+        print(f'captcha result={result}')
+        self.page.ele('tag:input@@name=captchaFormulaireExtInput').input(result)
+        self.page.ele("tag:button@@text():Create an account").click()
+        
+    def activate(self, sent_at=None):
+        username = self.user_inputs.get('username')
+        email_box = 'visafly666@gmail.com'
+        sender = 'noreply at interieur.gouv.fr'
+        recipient = username
+        subject_keywords = 'Create your France-Visas account'
+        body_keywords = ''
+        
+        if not sent_at:
+            now_utc = datetime.utcnow()
+            sent_at = now_utc.strftime("%Y-%m-%d %H:%M:%S")
+        
+        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
+        )
+        self._log(f'activate email content={content_out}')
+        match = re.search(r'https://\S+', content_out)
+        activate_link = match.group(0) if match else None
+        self.page.get(activate_link)
+        time.sleep(3)
+    
+    def make_account_useful(self):
+        
+        def fill_date_field(page, selector, date_str):
+            if not date_str:
+                return
+                
+            ele = page.ele(selector)
+            ele.scroll.to_see(center=True)
+            
+            js_detect_format = """
+                const parts = new Intl.DateTimeFormat().formatToParts(new Date(2023, 11, 31));
+                let format = [];
+                for (let part of parts) {
+                    if (part.type === 'year') format.push('Y');
+                    if (part.type === 'month') format.push('M');
+                    if (part.type === 'day') format.push('D');
+                }
+                return format;
+            """
+            date_format = page.run_js(js_detect_format)
+            
+            year, month, day = date_str.split('-')
+            date_dict = {
+                'Y': year,
+                'M': month.zfill(2),
+                'D': day.zfill(2)
+            }
+            ele.click()
+            time.sleep(0.1)
+            page.actions.type(Keys.LEFT * 3)
+            time.sleep(0.1)
+            for i, char in enumerate(date_format):
+                val = date_dict[char]
+                page.actions.type(val)
+                time.sleep(0.1)
+                if char == 'Y':
+                    if i < 2:
+                        page.actions.type(Keys.RIGHT)
+                        time.sleep(0.1)
+                else:
+                    pass
+        
+        passport_no = self.user_inputs.get('passport_no')
+        passport_issue_date = self.user_inputs.get('passport_issue_date')
+        passport_expiry_date = self.user_inputs.get('passport_expiry_date')
+        nationality = self.user_inputs.get('nationality')
+        passport_issue_from = self.user_inputs.get('passport_issue_from')
+        self.page.ele('#formHeader:navigationLanguage_input').select('English')
+        time.sleep(3)
+        self.page.ele('#formAccueilUsager:ajouterGroupe').click()
+        time.sleep(5)
+        self.page.ele('#formStep1:visas-selected-nationality_input').select(nationality)
+        self.page.ele('#formStep1:Visas-selected-deposit-country_input').select('Ireland')
+        self.page.ele('#formStep1:Visas-selected-stayDuration_input').select('Short-stay (≤ 90 days)')
+        self.page.ele('#formStep1:Visas-selected-destination_input').select('France')
+        self.page.ele('#formStep1:Visas-selected-deposit-town_input').select('Dublin')
+        self.page.ele('#formStep1:Visas-selected-authority_input').select(passport_issue_from)
+        self.page.ele('#formStep1:Visas-dde-travel-document_input').select('Ordinary passport')
+        self.page.ele('#formStep1:Visas-dde-travel-document-number').input(passport_no)
+        fill_date_field(self.page, '#formStep1:Visas-dde-release_date_real_input', passport_issue_date)
+        fill_date_field(self.page, '#formStep1:Visas-dde-expiration_date_input', passport_expiry_date)
+        self.page.ele('#formStep1:Visas-selected-purposeCategory_input').select('Tourism')
+        self.page.ele('#formStep1:Visas-selected-purpose_input').select('Tourism / Private visit')
+        self.page.ele('#formStep1:btnVerifier').click()
+        time.sleep(3)
+        self.page.ele('#formStep1:btnSuivant').click()
+        time.sleep(3)
+        self.page.ele('#formStep1:btnValiderModal').click()
+        time.sleep(3)
+        self.page.ele('.iconeDDEIdPanel').click()
+        time.sleep(0.5)
+        self.page.ele('text():My applications').click()
+        time.sleep(3)
+        html_content = self.page.html
+        match = re.search(r'FRA1[A-Z0-9]+', html_content)
+        if not match:
+            raise BizLogicError(message='FRA1 not found')
+        fra_number = match.group(0)
+        print(fra_number)
+        return fra_number
+    
+    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)
+            
+
+def main():
+    france_visa_url = 'https://application-form.france-visas.gouv.fr/fv-fo-dde/'
+    proxy_config = {
+        'ip': '127.0.0.1',
+        'port': 7890,
+        'username': '',
+        'password': ''
+    }
+    capsolver_key = ''
+    user_inputs = {
+        "username": "ManaliAshokGaikwad26@gmail-app.com",
+        "first_name": "Manali Ashok",
+        "last_name": "Gaikwad",
+        "nationality": "Indian",
+        "passport_issue_from": "India",
+        "passport_no": "Z4413123",
+        "passport_issue_date": "2018-01-15",
+        "passport_expiry_date": "2028-01-14",
+    }
+    bot = FranceVisaRegistrator(
+        france_visa_url, 
+        proxy_config=proxy_config, 
+        capsolver_key=capsolver_key, 
+        user_inputs=user_inputs
+    )
+    bot.init_browser()
+    now_utc = datetime.utcnow()
+    sent_at = now_utc.strftime("%Y-%m-%d %H:%M:%S")
+    bot.register() 
+    bot.activate(sent_at=sent_at)
+    bot.make_account_useful()
+
+if __name__ == "__main__":
+    main()

+ 187 - 109
plugins/grc_plugin.py

@@ -12,18 +12,12 @@ from vs_plg import IVSPlg
 from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 
 MODERN_BROWSERS: List[str] = [
-    # Chrome (124+)
-    "chrome124", "chrome131", "chrome133a", "chrome136", "chrome142", "chrome145", "chrome146",
-    "chrome131_android",
-    
-    # Safari (18+)
-    "safari180", "safari184", "safari260", "safari2601",
-    "safari180_ios", "safari184_ios", "safari260_ios",
-    
-    # Firefox (133+)
-    "firefox133", "firefox135", "firefox144", "firefox147",
-    
-    "tor145"
+    "chrome",
+    "edge",
+    "safari",
+    "safari_ios",
+    "chrome_android",
+    "firefox",
 ]
 
 class GrcPlugin(IVSPlg):
@@ -41,8 +35,8 @@ class GrcPlugin(IVSPlg):
         self.logger = None
         self.session: Optional[requests.Session] = None
         self.resource_id = '1123832'
-        self.rp_id = None            # 用于 AJAX 查询 (例如 778129)
-        self.token = None            # 用于 AJAX 查询
+        self.rp_id = None
+        self.token = None
         self.session_create_time: float = 0
 
     def get_group_id(self) -> str:
@@ -72,7 +66,6 @@ class GrcPlugin(IVSPlg):
         return True
 
     def create_session(self):   
-        # 1. 初始化 Session
         curlopt = {
             const.CurlOpt.MAXAGE_CONN: 1800,
             const.CurlOpt.MAXLIFETIME_CONN: 1800,
@@ -108,10 +101,7 @@ class GrcPlugin(IVSPlg):
         
         resp = self._perform_request('POST', login_url, headers=headers, data=data)
         
-        # 判断是否登录成功
         if "Sign out" in resp.text or "Signed in as" in resp.text:
-            
-            # [新增修复点]: 检查账号是否已达最大预约数限制
             if "reached the maximum number" in resp.text or "You cannot create new reservations" in resp.text:
                 self.is_healthy = False
                 self._save_debug_html(resp.text, prefix='login_quota_exceeded')
@@ -121,35 +111,66 @@ class GrcPlugin(IVSPlg):
             self.session_create_time = time.time()
             self._log(f"Session created successfully. (User: {self.config.account.username})")
         
-        # 如果登录失败,SuperSaaS 通常会留在当前页面并显示错误信息
         elif "Invalid email or password" in resp.text:
             self._save_debug_html(resp.text, prefix='login_auth_fail')
             raise BizLogicError(message='Login failed: Invalid email or password')
             
         else:
-            # 其他未知错误
             self._save_debug_html(resp.text, prefix='login_unknown_fail')
             self._log(f"Login check failed. Current URL: {resp.url}")
             raise BizLogicError(message='Login failed: Unknown response')
+        
+    def _extract_int(self, pattern: str, text: str, default: int = 0) -> int:
+        """辅助正则提取数字的函数"""
+        match = re.search(pattern, text)
+        if match:
+            return int(match.group(1))
+        return default
             
-    def _get_daily_schedule(self, open_times, date_obj):
-        """根据 open_times 获取当天的开始和结束分钟数"""
+    def _get_daily_bounds_and_breaks(self, open_times: list, bit_prefs: int, date_ts: int, js_day_index: int) -> Tuple[Optional[int], Optional[int], Optional[Tuple[int, int]]]:
+        """完美还原JS逻辑,解析每天的营业起始时间,并提取隐藏的午休时间"""
         if not open_times:
-            return None, None
-        weekday_py = date_obj.weekday()
-        if weekday_py >= 4: 
-            return None, None
-        js_day_index = (date_obj.weekday() + 1) % 7 
-        start_min = open_times[js_day_index]
-        end_min = open_times[js_day_index + 7]
-        return start_min, end_min
-    
-    def _is_blocked_by_ecache(self, ecache_blocked, timestamp):
-        """检查某个时间点是否在临时关闭范围内 (如节假日)"""
-        for block in ecache_blocked:
-            if block[0] <= timestamp < block[1]:
-                return True
-        return False
+            return None, None, None
+
+        # =========================================================
+        # 【终极拦截器】:解析 bit_prefs 位掩码!
+        # 最低的 7 位控制着周日(0)到周六(6)的物理营业开关。
+        # 如果为 0,这天就是雷打不动的休息日,直接返回空!
+        # =========================================================
+        if bit_prefs > 0:
+            if not (bit_prefs & (1 << js_day_index)):
+                return None, None, None
+
+        def safe_get(idx):
+            if idx < len(open_times):
+                return open_times[idx]
+            return None
+
+        # [0-6] 是周日到周六的每天开始时间(分), [7-13] 是结束时间(分)
+        start_min = safe_get(js_day_index)
+        end_min = safe_get(js_day_index + 7)
+
+        # Null 意味着当天不营业
+        if start_min is None or end_min is None or start_min >= end_min:
+            return None, None, None
+
+        # JS 中的 day_base_ts = 严格的当天 00:00:00 (根据绝对时间戳截断)
+        day_base_ts = date_ts - (date_ts % 86400)
+        
+        start_ts = day_base_ts + start_min * 60
+        end_ts = day_base_ts + end_min * 60
+        
+        # [14-20] 是午休开始时间, [21-27] 是午休结束时间
+        break_block = None
+        break_start_min = safe_get(js_day_index + 14)
+        break_end_min = safe_get(js_day_index + 21)
+        
+        if break_start_min is not None and break_end_min is not None:
+            b_start = day_base_ts + break_start_min * 60
+            b_end = day_base_ts + break_end_min * 60
+            break_block = (b_start, b_end)
+
+        return start_ts, end_ts, break_block
     
     def _fetch_schedule_data(self, start_dt: datetime, days: int) -> Dict:
         """发送一次 AJAX 请求,获取指定时间范围内的所有数据"""
@@ -194,134 +215,187 @@ class GrcPlugin(IVSPlg):
             self.is_healthy = False
             raise SessionExpiredOrInvalidError(message='Session expired.')
         
-        # 提取核心参数
-        res_id_match = re.search(r'resource\[(\d+)\]\s*=', resp.text)
-        if res_id_match:
-            self.resource_id = res_id_match.group(1)
-            
-        rp_match = re.search(r'rp_id=(\d+)', resp.text)
-        if rp_match:
-            self.rp_id = rp_match.group(1)
-            
-        tok_match = re.search(r'token=(\d+)', resp.text)
-        if tok_match:
-            self.token = tok_match.group(1)
+        # 1. 提取核心会话参数
+        self.resource_id = str(self._extract_int(r'resource\[(\d+)\]\s*=', resp.text))
+        self.rp_id = str(self._extract_int(r'rp_id=(\d+)', resp.text))
+        self.token = str(self._extract_int(r'token=(\d+)', resp.text))
 
-        if not getattr(self, 'rp_id', None) or not getattr(self, 'token', None):
+        if not self.rp_id or not self.token or self.resource_id == '0':
             self._log("Failed to extract rp_id or token from HTML")
             raise NotFoundError(message='rp_id or token not found')
 
-        # 默认一小时长度 (3600秒)
-        default_length = 3600
-        len_match = re.search(r'default_length\s*=\s*(\d+)', resp.text)
-        if len_match:
-            default_length = int(len_match.group(1))
-            
-        # 开放时间规则
-        open_times = None
+        # 2. 提取并还原所有的 JS 限制变量
+        default_length = self._extract_int(r'default_length\s*=\s*(\d+)', resp.text, 3600)
+        rounding = self._extract_int(r'rounding\s*=\s*(\d+)', resp.text, 3600)
+        buffer_time = self._extract_int(r'buffer\s*=\s*(\d+)', resp.text, 0)
+        add_limit = self._extract_int(r'add_limit\s*=\s*(\d+)', resp.text, 0)
+        early_limit = self._extract_int(r'early_limit\s*=\s*(\d+)', resp.text, 0)
+        early_snap = self._extract_int(r'early_snap\s*=\s*(\d+)', resp.text, 0)
+        bit_prefs = self._extract_int(r'bit_prefs\s*=\s*(\d+)', resp.text, 0)
+
+        # 3. 提取 open_times
+        open_times = []
         ot_match = re.search(r'open_times\s*=\s*\[(.*?)\]', resp.text)
         if ot_match:
-            open_times =[int(x) for x in ot_match.group(1).split(',')]
+            # 巧妙利用 json.loads 处理包含 null 的 JS 数组字符串
+            open_times_str = f"[{ot_match.group(1)}]"
+            open_times = json.loads(open_times_str)
             
-        # 排期结束时间
-        season_end_ts = 9999999999
-        season_match = re.search(r'season\s*=\s*\[(\d+),(\d+)\]', resp.text)
+        # 4. 提取放号排期 (Season)
+        season_start_ts = 0
+        season_end_ts = float('inf')
+        season_match = re.search(r'season\s*=\s*\[(\d+),\s*(\d+)\]', resp.text)
         if season_match:
+            season_start_ts = int(season_match.group(1))
             season_end_ts = int(season_match.group(2))
             
-        # 提取全局页面屏蔽的假日例外期 (ecache)
-        ecache_blocks =[]
+        # 5. 提取全局页面屏蔽的假日例外期 (ecache)
+        ecache_blocks = []
         ecache_match = re.search(r'ecache\s*=\s*\{data:\s*\[(.*?)\]\}', resp.text)
         if ecache_match:
-            # 匹配形如[1775433600,1775519970,0] 的数据
             triplets = re.findall(r'\[(\d+),\s*(\d+),\s*\d+\]', ecache_match.group(1))
             for t0, t1 in triplets:
                 ecache_blocks.append((int(t0), int(t1)))
 
         scan_start_dt = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
-        current_time_ts = int(time.time())
+        chunk_start_ts_initial = int(scan_start_dt.timestamp())
+        
         days_total_scan = 60
         chunk_size = 30
-        valid_slots_map: dict[datetime.date, list[TimeSlot]] = {}
+        valid_slots_map: Dict[datetime.date, List[TimeSlot]] = {}
 
+        # Chunk 循环
         for i in range(0, days_total_scan, chunk_size):
-            chunk_start = scan_start_dt + timedelta(days=i)
-            json_data = self._fetch_schedule_data(chunk_start, chunk_size)
+            chunk_start_dt = scan_start_dt + timedelta(days=i)
+            json_data = self._fetch_schedule_data(chunk_start_dt, chunk_size)
             
-            # 把已经预定的订单和节假日统一收集为“阻挡物实体墙”
+            # 把已经预定的订单、节假日、以及Gcal双向同步块统一收集为“阻挡物实体墙”
             all_blocks: List[Tuple[int, int]] = ecache_blocks.copy()
-            if 'app' in json_data:
-                for item in json_data['app']:
-                    all_blocks.append((int(item[0]), int(item[1])))
-            if 'exc' in json_data:
-                for item in json_data['exc']:
-                    all_blocks.append((int(item[0]), int(item[1])))
+            for key in ['app', 'exc', 'gcal']:
+                if key in json_data:
+                    for item in json_data[key]:
+                        all_blocks.append((int(item[0]), int(item[1])))
 
             for day_offset in range(chunk_size):
-                current_day = chunk_start + timedelta(days=day_offset)
-                start_min, end_min = self._get_daily_schedule(open_times, current_day)
-                if start_min is None or start_min >= end_min: continue
+                # 这里必须使用纯净的绝对 UNIX 时间进行加法,避免夏令时导致时间漂移
+                current_day_ts = int(chunk_start_dt.timestamp()) + day_offset * 86400
+                
+                # 计算出当前天对应的 JS Weekday (0=Sun, 1=Mon ... 6=Sat)
+                js_day_index = int((4 + (current_day_ts // 86400)) % 7) 
+                
+                # 这里传入了 bit_prefs,彻底过滤掉伪装成开门的休息日!
+                start_ts, end_ts, break_block = self._get_daily_bounds_and_breaks(open_times, bit_prefs, current_day_ts, js_day_index)
+                if start_ts is None: 
+                    continue
 
-                start_ts = int(current_day.timestamp()) + start_min * 60
-                end_ts = int(current_day.timestamp()) + end_min * 60
+                # 每天专属的围墙:加入当天的午休时间
+                daily_blocks = all_blocks.copy()
+                if break_block:
+                    daily_blocks.append(break_block)
 
                 curr_ts = start_ts
+                current_time_ts = int(time.time())
+                
+                # --- 完全还原 JS 的 limits 以及 bit_prefs (early_snap) 机制 ---
+                min_bookable_ts = current_time_ts + add_limit + 90
+                max_bookable_ts = float('inf')
                 
-                # 开始执行官方引擎 1:1 的“碰撞与吸附算法”
+                if early_limit > 0:
+                    limit_ts = current_time_ts + early_limit
+                    if early_snap:
+                        k = limit_ts % 86400
+                        y = int((4 + (limit_ts // 86400)) % 7)
+                        # 这里正是 bit_prefs 的用武之地:检测位掩码
+                        if (bit_prefs & (1 << y)) and open_times[y] is not None and open_times[y] <= (k / 60):
+                            limit_ts += (86400 - k)
+                    max_bookable_ts = limit_ts + default_length
+                
+                # ================= 核心碰撞与吸附算法 =================
                 while curr_ts + default_length <= end_ts:
-                    if curr_ts < current_time_ts or curr_ts >= season_end_ts:
-                        curr_ts += 1800  # 安全步进
+                    
+                    # 1. 不能早于排期放号的绝对起始时间
+                    if curr_ts < season_start_ts:
+                        curr_ts = season_start_ts
+                        continue
+                        
+                    # 2. 如果撞到了排期尽头,或超过了后台设置的最远可预约天数,当天循环直接结束
+                    if curr_ts >= season_end_ts or curr_ts >= max_bookable_ts:
+                        break
+                        
+                    # 3. 拦截提前量 (比如不允许预约1小时内的票)
+                    if curr_ts < min_bookable_ts:
+                        curr_ts = min_bookable_ts
                         continue
 
-                    # 【核心机制 1】:强制网格吸附 (Snap to grid)
-                    # 官方代码 start=precalc_constraints('0') 意味着仅允许在整点 (0分) 建立预约
-                    dt = datetime.fromtimestamp(curr_ts, tz=timezone.utc)
-                    if dt.minute != 0:
-                        # 发现不在整点 (如11:30),立刻强制向前吸附到下一个整点 (加上剩余的分钟数)
-                        minutes_to_add = 60 - dt.minute
-                        curr_ts += minutes_to_add * 60
-                        continue  # 时间已改变,重新循环执行检查
-                    
+                    # 4. 【核心机制 1】:完美网格吸附 (Rounding Snapping)
+                    # 动态适配 10分、15分、30分、60分的网格,废弃单纯整点逻辑
+                    rem = curr_ts % rounding
+                    if rem > 0:
+                        curr_ts += (rounding - rem)
+                        continue
+                        
                     slot_end = curr_ts + default_length
                     
-                    # 【核心机制 2】:贪婪碰撞检测 (Collision detection)
+                    # 5. 【核心机制 2】:带 Buffer 的贪婪碰撞检测
                     overlapping_end = 0
-                    for b_start, b_end in all_blocks:
-                        # 区间交集判断:起点早于障碍物终点 且 终点晚于障碍物起点 -> 发生碰撞
-                        if curr_ts < b_end and slot_end > b_start:
-                            if b_end > overlapping_end:
-                                overlapping_end = b_end
+                    for b_start, b_end in daily_blocks:
+                        # 障碍物向前后各膨胀 buffer 的大小
+                        blocked_start = b_start - buffer_time
+                        blocked_end = b_end + buffer_time
+                        
+                        # 区间交集判断逻辑
+                        if curr_ts < blocked_end and slot_end > blocked_start:
+                            if blocked_end > overlapping_end:
+                                overlapping_end = blocked_end
                                 
                     if overlapping_end > 0:
-                        # 碰撞触发:指针直接抛弃当前区间,跳跃到“阻挡物最晚结束的时间点”
+                        # 碰撞触发:指针直接跳跃到带缓冲区的“障碍物最晚结束时间点”
                         curr_ts = overlapping_end
-                        # 下一轮循环时,【机制1】会自动将其吸附回合理的网格位
+                        # 进入下个 while 循环时,机制 1 会将其吸附回最近的合法网格上
                     else:
-                        # 完美过检:没有碰撞且处于正确网格,记录合法 Slot
+                        # ========================================================
+                        # 完美过检:记录合法 Slot (在此处增加 Finish Time 的提取)
+                        # ========================================================
+                        dt_start = datetime.fromtimestamp(curr_ts, tz=timezone.utc)
+                        
+                        # 计算结束时间戳与时间对象
+                        finish_ts = curr_ts + default_length
+                        dt_finish = datetime.fromtimestamp(finish_ts, tz=timezone.utc)
                         payload = {
                             "resource_id": self.resource_id,
                             "timestamp": curr_ts,
-                            "datetime": dt.strftime("%Y-%m-%d %H:%M:%S")
+                            "datetime": dt_start.strftime("%Y-%m-%d %H:%M:%S"),
+                            "finish_timestamp": finish_ts,
+                            "finish_datetime": dt_finish.strftime("%Y-%m-%d %H:%M:%S")
                         }
                         time_slot = TimeSlot(
-                            time=dt.strftime("%H:%M"),
+                            time=dt_start.strftime("%H:%M"),
                             label=json.dumps(payload)
                         )
-                        date_key = dt.date()
+                        
+                        date_key = dt_start.date()
                         if date_key not in valid_slots_map:
-                            valid_slots_map[date_key] =[]
+                            valid_slots_map[date_key] = []
                             
                         valid_slots_map[date_key].append(time_slot)
                         
-                        # 已放入一个格后,指针按订单长度向后推移
+                        # 推进指针,寻找下一个槽位
                         curr_ts += default_length
 
+        # === 结果聚合与整理 ===
         if valid_slots_map:
             res.success = True
             res.availability_status = AvailabilityStatus.Available
             sorted_dates = sorted(valid_slots_map.keys())
             res.earliest_date = datetime.combine(sorted_dates[0], datetime.min.time())
-            res.availability =[DateAvailability(date=datetime.combine(d, datetime.min.time()), times=valid_slots_map[d]) for d in sorted_dates]
+            
+            for d in sorted_dates:
+                da = DateAvailability(
+                    date=datetime.combine(d, datetime.min.time()), 
+                    times=valid_slots_map[d]
+                )
+                res.availability.append(da)
+                
             self._log(f"Found slots on {len(sorted_dates)} days.")
         else:
             self._log("No slots found.")
@@ -380,10 +454,14 @@ class GrcPlugin(IVSPlg):
             try:
                 slot_data = json.loads(target_slot.label)
                 start_time_str = slot_data.get('datetime')
+                finish_time_str = slot_data.get('finish_datetime')
                 bk_res_id = slot_data.get('resource_id', self.resource_id)
                 
                 start_dt = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
-                finish_dt = start_dt + timedelta(hours=1)
+                if finish_time_str:
+                    finish_dt = datetime.strptime(finish_time_str, "%Y-%m-%d %H:%M:%S")
+                else:
+                    finish_dt = start_dt + timedelta(hours=1)
                 
                 # 处理抵离日期确保不为空
                 arrival_date = (user_inputs.get('arrival_date') or '').strip()

+ 49 - 0
toolkit/captcha_breaker.py

@@ -0,0 +1,49 @@
+import base64
+import os
+from openai import OpenAI
+
+def encode_image_to_base64(image_path):
+    with open(image_path, "rb") as image_file:
+        return base64.b64encode(image_file.read()).decode('utf-8')
+
+def recognize_captcha_with_qwen(image_path, api_key):
+    # 利用 OpenAI 的包,调用阿里云的兼容 API 接口
+    client = OpenAI(
+        api_key=api_key,
+        base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
+    )
+    
+    base64_image = encode_image_to_base64(image_path)
+    
+    prompt = "你是一个精确的OCR机器人。请识别图片中的验证码字符。只输出验证码本身的内容,不要任何多余的汉字或标点符号。"
+
+    try:
+        response = client.chat.completions.create(
+            model="qwen-vl-max", # 也可以用更便宜的 qwen-vl-plus
+            messages=[
+                {
+                    "role": "user",
+                    "content":[
+                        {"type": "text", "text": prompt},
+                        {
+                            "type": "image_url",
+                            "image_url": {
+                                "url": f"data:image/png;base64,{base64_image}"
+                            }
+                        }
+                    ]
+                }
+            ],
+            temperature=0.0
+        )
+        return response.choices[0].message.content.strip()
+    except Exception as e:
+        print(f"Qwen 识别错误: {e}")
+        return None
+
+if __name__ == "__main__":
+    # 填入阿里云百炼 (DashScope) 的 API-KEY
+    API_KEY = "sk-893e895724c6403d81374e515ffaf427"
+    IMAGE_PATH = "captcha.png"
+    
+    print(f"Qwen-VL 识别结果: {recognize_captcha_with_qwen(IMAGE_PATH, API_KEY)}")