|
@@ -5,6 +5,7 @@ import re
|
|
|
import os
|
|
import os
|
|
|
import uuid
|
|
import uuid
|
|
|
import shutil
|
|
import shutil
|
|
|
|
|
+import socket
|
|
|
from datetime import datetime
|
|
from datetime import datetime
|
|
|
from typing import List, Dict, Optional, Any, Callable
|
|
from typing import List, Dict, Optional, Any, Callable
|
|
|
from urllib.parse import urljoin, urlparse, urlencode
|
|
from urllib.parse import urljoin, urlparse, urlencode
|
|
@@ -50,25 +51,17 @@ class TlsPlugin(IVSPlg):
|
|
|
self.is_healthy = True
|
|
self.is_healthy = True
|
|
|
self.logger = None
|
|
self.logger = None
|
|
|
|
|
|
|
|
- # 浏览器实例
|
|
|
|
|
- self.page: Optional[ChromiumPage] = None
|
|
|
|
|
-
|
|
|
|
|
|
|
+ self.page: Optional[ChromiumPage] = None
|
|
|
self.travel_group: Optional[Dict] = None
|
|
self.travel_group: Optional[Dict] = None
|
|
|
|
|
|
|
|
- # --- [核心修改] 并发隔离与资源管理 ---
|
|
|
|
|
- # 生成唯一实例 ID
|
|
|
|
|
self.instance_id = uuid.uuid4().hex[:8]
|
|
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.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")
|
|
self.user_data_path = os.path.join(self.root_workspace, "user_data")
|
|
|
|
|
|
|
|
- # 确保根目录存在 (子目录由具体逻辑创建)
|
|
|
|
|
if not os.path.exists(self.root_workspace):
|
|
if not os.path.exists(self.root_workspace):
|
|
|
os.makedirs(self.root_workspace)
|
|
os.makedirs(self.root_workspace)
|
|
|
|
|
|
|
|
- # 持有隧道实例
|
|
|
|
|
self.tunnel = None
|
|
self.tunnel = None
|
|
|
-
|
|
|
|
|
self.session_create_time: float = 0
|
|
self.session_create_time: float = 0
|
|
|
|
|
|
|
|
def get_group_id(self) -> str:
|
|
def get_group_id(self) -> str:
|
|
@@ -115,11 +108,7 @@ class TlsPlugin(IVSPlg):
|
|
|
filename = f"{self.instance_id}_{name_prefix}_{timestamp}.jpg"
|
|
filename = f"{self.instance_id}_{name_prefix}_{timestamp}.jpg"
|
|
|
save_path = os.path.join("data", filename)
|
|
save_path = os.path.join("data", filename)
|
|
|
os.makedirs("data", exist_ok=True)
|
|
os.makedirs("data", exist_ok=True)
|
|
|
-
|
|
|
|
|
- # [修改] 改为 full_page=False,防止页面结构异常导致截图失败
|
|
|
|
|
- # 这样能截取到浏览器当前可视区域,最适合调试“卡住”的情况
|
|
|
|
|
self.page.get_screenshot(path=save_path, full_page=False)
|
|
self.page.get_screenshot(path=save_path, full_page=False)
|
|
|
-
|
|
|
|
|
self._log(f"Screenshot saved to {save_path}")
|
|
self._log(f"Screenshot saved to {save_path}")
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
self._log(f"Failed to save screenshot: {e}")
|
|
self._log(f"Failed to save screenshot: {e}")
|
|
@@ -130,13 +119,6 @@ class TlsPlugin(IVSPlg):
|
|
|
"""
|
|
"""
|
|
|
self._log(f"Initializing Session (ID: {self.instance_id})...")
|
|
self._log(f"Initializing Session (ID: {self.instance_id})...")
|
|
|
co = ChromiumOptions()
|
|
co = ChromiumOptions()
|
|
|
- # -------------------------------------------------------------
|
|
|
|
|
- # [核心修复] 解决 'not enough values to unpack'
|
|
|
|
|
- # -------------------------------------------------------------
|
|
|
|
|
- # 1. 不要用 co.auto_port(),因为它依赖解析 stdout,会被 DBus 报错干扰
|
|
|
|
|
- # 2. 我们手动随机生成一个端口
|
|
|
|
|
- import random
|
|
|
|
|
- import socket
|
|
|
|
|
|
|
|
|
|
def get_free_port():
|
|
def get_free_port():
|
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
@@ -146,38 +128,26 @@ class TlsPlugin(IVSPlg):
|
|
|
debug_port = get_free_port()
|
|
debug_port = get_free_port()
|
|
|
self._log(f"Assigned Debug Port: {debug_port}")
|
|
self._log(f"Assigned Debug Port: {debug_port}")
|
|
|
|
|
|
|
|
- # 3. 强制指定端口,DrissionPage 就会直接连接,不再解析日志
|
|
|
|
|
- co.set_local_port(debug_port)
|
|
|
|
|
-
|
|
|
|
|
- # --- [关键配置] 设置独立的用户数据目录 ---
|
|
|
|
|
- # 这样每个实例的 Cache, Cookies, LocalStorage 都是完全隔离的
|
|
|
|
|
- # 同时也防止了多进程争抢同一个 Default 文件夹导致的崩溃
|
|
|
|
|
|
|
+ co.set_local_port(debug_port)
|
|
|
co.set_user_data_path(self.user_data_path)
|
|
co.set_user_data_path(self.user_data_path)
|
|
|
|
|
|
|
|
- # --- 1. 指定浏览器路径 (适配 Docker) ---
|
|
|
|
|
chrome_path = os.getenv("CHROME_BIN")
|
|
chrome_path = os.getenv("CHROME_BIN")
|
|
|
if chrome_path and os.path.exists(chrome_path):
|
|
if chrome_path and os.path.exists(chrome_path):
|
|
|
co.set_paths(browser_path=chrome_path)
|
|
co.set_paths(browser_path=chrome_path)
|
|
|
|
|
|
|
|
- # --- [核心修改] 代理配置 ---
|
|
|
|
|
if self.config.proxy and self.config.proxy.ip:
|
|
if self.config.proxy and self.config.proxy.ip:
|
|
|
p = self.config.proxy
|
|
p = self.config.proxy
|
|
|
|
|
|
|
|
if p.username and p.password:
|
|
if p.username and p.password:
|
|
|
self._log(f"Starting Proxy Tunnel for {p.ip}...")
|
|
self._log(f"Starting Proxy Tunnel for {p.ip}...")
|
|
|
|
|
|
|
|
- # 1. 启动本地隧道
|
|
|
|
|
self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
|
|
self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
|
|
|
local_proxy = self.tunnel.start()
|
|
local_proxy = self.tunnel.start()
|
|
|
|
|
|
|
|
self._log(f"Tunnel started at {local_proxy}")
|
|
self._log(f"Tunnel started at {local_proxy}")
|
|
|
-
|
|
|
|
|
- # 2. Chrome 连接本地免密端口
|
|
|
|
|
- # 必须使用 --proxy-server 强制指定,绝对稳健
|
|
|
|
|
co.set_argument(f'--proxy-server={local_proxy}')
|
|
co.set_argument(f'--proxy-server={local_proxy}')
|
|
|
|
|
|
|
|
else:
|
|
else:
|
|
|
- # 无密码代理,直接用
|
|
|
|
|
proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
|
|
proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
|
|
|
co.set_argument(f'--proxy-server={proxy_str}')
|
|
co.set_argument(f'--proxy-server={proxy_str}')
|
|
|
else:
|
|
else:
|
|
@@ -186,7 +156,6 @@ class TlsPlugin(IVSPlg):
|
|
|
co.headless(False)
|
|
co.headless(False)
|
|
|
co.set_argument('--no-sandbox')
|
|
co.set_argument('--no-sandbox')
|
|
|
co.set_argument('--disable-gpu')
|
|
co.set_argument('--disable-gpu')
|
|
|
- # Docker 默认 /dev/shm 只有 64MB,Chromium 很容易爆内存崩溃
|
|
|
|
|
co.set_argument('--disable-dev-shm-usage')
|
|
co.set_argument('--disable-dev-shm-usage')
|
|
|
co.set_argument('--window-size=1920,1080')
|
|
co.set_argument('--window-size=1920,1080')
|
|
|
co.set_argument('--disable-blink-features=AutomationControlled')
|
|
co.set_argument('--disable-blink-features=AutomationControlled')
|
|
@@ -210,29 +179,24 @@ class TlsPlugin(IVSPlg):
|
|
|
self._log(f"Navigating: {full_login_url}")
|
|
self._log(f"Navigating: {full_login_url}")
|
|
|
self.page.get(full_login_url)
|
|
self.page.get(full_login_url)
|
|
|
|
|
|
|
|
- # --- Cloudflare 过盾 ---
|
|
|
|
|
cf = CloudflareBypasser(self.page, log=self.config.debug)
|
|
cf = CloudflareBypasser(self.page, log=self.config.debug)
|
|
|
if not cf.bypass(max_retry=15):
|
|
if not cf.bypass(max_retry=15):
|
|
|
raise BizLogicError("Cloudflare bypass timeout")
|
|
raise BizLogicError("Cloudflare bypass timeout")
|
|
|
|
|
|
|
|
wait_start = time.time()
|
|
wait_start = time.time()
|
|
|
while True:
|
|
while True:
|
|
|
- # 获取页面 HTML,转小写
|
|
|
|
|
- # 注意:如果此处报错 "页面被刷新",是 DrissionPage 的机制问题,
|
|
|
|
|
- # 但你要求先不处理复杂错误,所以这里保持最简单的写法。
|
|
|
|
|
html = self.page.html.lower()
|
|
html = self.page.html.lower()
|
|
|
-
|
|
|
|
|
- # 检查是否在排队室 (法语或英语)
|
|
|
|
|
- if "file d'attente" in html or "waiting room" in html:
|
|
|
|
|
- # 如果等太久(比如1小时),就强制停止
|
|
|
|
|
- if time.time() - wait_start > 6 * 60:
|
|
|
|
|
- self._log("Waiting room timeout (1h).")
|
|
|
|
|
- break
|
|
|
|
|
|
|
+ cloudflare_waitingroom_indicators = [
|
|
|
|
|
+ "file d'attente" in html,
|
|
|
|
|
+ 'waiting room' in html
|
|
|
|
|
+ ]
|
|
|
|
|
+ if any(cloudflare_waitingroom_indicators):
|
|
|
|
|
+ if time.time() - wait_start > 60 * 60:
|
|
|
|
|
+ raise BizLogicError(message="Cloudflare waiting room timeout (1h).")
|
|
|
|
|
|
|
|
self._log("In Waiting Room... Waiting for auto-refresh.")
|
|
self._log("In Waiting Room... Waiting for auto-refresh.")
|
|
|
- time.sleep(30) # 截图说页面会自动刷新,所以这里只sleep,不动浏览器
|
|
|
|
|
|
|
+ time.sleep(30)
|
|
|
else:
|
|
else:
|
|
|
- # 页面里没有“等候室”的字了,说明出来了
|
|
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
# --- 登录页面检查 ---
|
|
# --- 登录页面检查 ---
|
|
@@ -248,7 +212,8 @@ class TlsPlugin(IVSPlg):
|
|
|
if self.page.ele('.g-recaptcha') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]'):
|
|
if self.page.ele('.g-recaptcha') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]'):
|
|
|
self._log("Solving ReCaptcha...")
|
|
self._log("Solving ReCaptcha...")
|
|
|
rc_params = {
|
|
rc_params = {
|
|
|
- "type": "ReCaptchaV2TaskProxyLess", "page": self.page.url,
|
|
|
|
|
|
|
+ "type": "ReCaptchaV2TaskProxyLess",
|
|
|
|
|
+ "page": self.page.url,
|
|
|
"siteKey": "6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0",
|
|
"siteKey": "6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0",
|
|
|
"apiToken": self.free_config.get("capsolver_key", "")
|
|
"apiToken": self.free_config.get("capsolver_key", "")
|
|
|
}
|
|
}
|
|
@@ -257,7 +222,6 @@ class TlsPlugin(IVSPlg):
|
|
|
username = self.config.account.username
|
|
username = self.config.account.username
|
|
|
password = self.config.account.password
|
|
password = self.config.account.password
|
|
|
|
|
|
|
|
- # 使用 JS 直接操作 DOM 并 click,让浏览器处理 302
|
|
|
|
|
js_login = f"""
|
|
js_login = f"""
|
|
|
var u = document.getElementById('email-input-field');
|
|
var u = document.getElementById('email-input-field');
|
|
|
if(u) {{ u.value = "{username}"; u.dispatchEvent(new Event('input', {{bubbles:true}})); }}
|
|
if(u) {{ u.value = "{username}"; u.dispatchEvent(new Event('input', {{bubbles:true}})); }}
|
|
@@ -273,29 +237,26 @@ class TlsPlugin(IVSPlg):
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
self._log("Submitting Login via JS...")
|
|
self._log("Submitting Login via JS...")
|
|
|
- if not self.page.run_js(js_login): raise BizLogicError("Login button missing")
|
|
|
|
|
|
|
+ if not self.page.run_js(js_login):
|
|
|
|
|
+ raise BizLogicError("Login button missing")
|
|
|
|
|
|
|
|
- # --- 等待跳转 ---
|
|
|
|
|
self._log("Waiting for redirect...")
|
|
self._log("Waiting for redirect...")
|
|
|
self.page.wait.url_change('login-actions', exclude=True, timeout=45)
|
|
self.page.wait.url_change('login-actions', exclude=True, timeout=45)
|
|
|
|
|
|
|
|
time.sleep(3)
|
|
time.sleep(3)
|
|
|
- # 检查是否失败
|
|
|
|
|
if "login-actions" in self.page.url or "auth" in self.page.url:
|
|
if "login-actions" in self.page.url or "auth" in self.page.url:
|
|
|
err = "Unknown Login Error"
|
|
err = "Unknown Login Error"
|
|
|
- if "Invalid username" in self.page.html: err = "Invalid Credentials"
|
|
|
|
|
|
|
+ if "Invalid username" in self.page.html:
|
|
|
|
|
+ err = "Invalid Credentials"
|
|
|
self._save_screenshot("login_submit_fail")
|
|
self._save_screenshot("login_submit_fail")
|
|
|
raise BizLogicError(f"Login Failed: {err}")
|
|
raise BizLogicError(f"Login Failed: {err}")
|
|
|
-
|
|
|
|
|
- # --- 提取 Dashboard 信息 ---
|
|
|
|
|
- self._log("Waiting for dashboard...")
|
|
|
|
|
- self.page.wait.load_start()
|
|
|
|
|
- time.sleep(5)
|
|
|
|
|
-
|
|
|
|
|
- html = self.page.html
|
|
|
|
|
- self._check_page_is_session_expired_or_invalid("My travel group", html)
|
|
|
|
|
- groups = self._parse_travel_groups(html)
|
|
|
|
|
|
|
|
|
|
|
|
+ self._log("Waiting for dashboard...")
|
|
|
|
|
+ btn_selector = 'xpath://button[.//span[@data-testid="btn-create-new-travel-group"]]'
|
|
|
|
|
+ if not self.page.wait.ele_displayed(btn_selector, timeout=10):
|
|
|
|
|
+ raise BizLogicError(message=f"Waiting for selector={btn_selector} timeout")
|
|
|
|
|
+ html_content = self.page.html
|
|
|
|
|
+ groups = self._parse_travel_groups(html_content)
|
|
|
target_city = apt_config['city'].lower()
|
|
target_city = apt_config['city'].lower()
|
|
|
for g in groups:
|
|
for g in groups:
|
|
|
if g['location'].lower() == target_city:
|
|
if g['location'].lower() == target_city:
|
|
@@ -305,9 +266,46 @@ class TlsPlugin(IVSPlg):
|
|
|
if not self.travel_group:
|
|
if not self.travel_group:
|
|
|
self._save_screenshot("group_not_found")
|
|
self._save_screenshot("group_not_found")
|
|
|
raise NotFoundError(f"Group not found for {target_city}")
|
|
raise NotFoundError(f"Group not found for {target_city}")
|
|
|
|
|
+
|
|
|
|
|
+ formgroup_id = self.travel_group.get('group_number')
|
|
|
|
|
+ btn_selector = f'tag:button@@name=formGroupId@@value={formgroup_id}'
|
|
|
|
|
+ self._log(f"Select group_id={formgroup_id} via JS...")
|
|
|
|
|
+ self.page.ele(btn_selector).click(by_js=True)
|
|
|
|
|
+
|
|
|
|
|
+ self._log("Waiting for url redirect...")
|
|
|
|
|
+ self.page.wait.url_change('travel-groups', exclude=True, timeout=45)
|
|
|
|
|
+ time.sleep(2)
|
|
|
|
|
+
|
|
|
|
|
+ if "travel-groups" in self.page.url or "auth" in self.page.url:
|
|
|
|
|
+ raise BizLogicError(message="Redirect to service-level Failed!")
|
|
|
|
|
|
|
|
|
|
+ no_applicant_indicators = [
|
|
|
|
|
+ "Add a new applicant" in self.page.html,
|
|
|
|
|
+ "You have not yet added an applicant. Please click the button below to add one." in self.page.html,
|
|
|
|
|
+ "applicants-information" in self.page.url
|
|
|
|
|
+ ]
|
|
|
|
|
+ if any(no_applicant_indicators):
|
|
|
|
|
+ raise BizLogicError(message=f"No applicant added")
|
|
|
|
|
+
|
|
|
|
|
+ btn_selector = '#book-appointment-btn'
|
|
|
|
|
+ self._log(f"Waiting for selector={btn_selector} to render...")
|
|
|
|
|
+ if not self.page.wait.ele_displayed(btn_selector, timeout=15):
|
|
|
|
|
+ raise BizLogicError(message=f"Waiting for selector={btn_selector} timeout")
|
|
|
|
|
+ self.page.ele(btn_selector).click(by_js=True)
|
|
|
|
|
+
|
|
|
|
|
+ self._log("Waiting for url redirect...")
|
|
|
|
|
+ self.page.wait.url_change('service-level', exclude=True, timeout=45)
|
|
|
|
|
+ time.sleep(2)
|
|
|
|
|
+
|
|
|
|
|
+ if "service-level" in self.page.url or "auth" in self.page.url:
|
|
|
|
|
+ raise BizLogicError(message="Redirect to appointment-booking Failed!")
|
|
|
|
|
+
|
|
|
|
|
+ btn_selector = 'tag:button@text():Book your appointment'
|
|
|
|
|
+ if not self.page.wait.ele_displayed(btn_selector, timeout=10):
|
|
|
|
|
+ raise BizLogicError(message=f"Waiting for selector={btn_selector} timeout")
|
|
|
|
|
+
|
|
|
self.session_create_time = time.time()
|
|
self.session_create_time = time.time()
|
|
|
- self._log(f"Session Ready. Group: {self.travel_group['group_number']}")
|
|
|
|
|
|
|
+ self._log(f"✅ Login & Navigation Success! Target Group ID: {formgroup_id}")
|
|
|
|
|
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
self._log(f"Session Create Error: {e}")
|
|
self._log(f"Session Create Error: {e}")
|
|
@@ -317,47 +315,118 @@ class TlsPlugin(IVSPlg):
|
|
|
def query(self, apt_type: AppointmentType) -> VSQueryResult:
|
|
def query(self, apt_type: AppointmentType) -> VSQueryResult:
|
|
|
res = VSQueryResult()
|
|
res = VSQueryResult()
|
|
|
res.success = False
|
|
res.success = False
|
|
|
- apt_config = self.free_config.get('apt_config', {})
|
|
|
|
|
group_num = self.travel_group['group_number']
|
|
group_num = self.travel_group['group_number']
|
|
|
|
|
+ apt_config = self.free_config.get('apt_config', {})
|
|
|
interest_month = self.free_config.get("interest_month", time.strftime("%m-%Y"))
|
|
interest_month = self.free_config.get("interest_month", time.strftime("%m-%Y"))
|
|
|
|
|
|
|
|
- url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
|
|
|
|
|
- params = {
|
|
|
|
|
- 'location': apt_config["code"],
|
|
|
|
|
- 'month': interest_month,
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ target_date_obj = datetime.strptime(interest_month, "%m-%Y")
|
|
|
|
|
+ target_month_text = target_date_obj.strftime("%B %Y")
|
|
|
|
|
+ target_year = target_date_obj.year
|
|
|
|
|
+ target_month_num = target_date_obj.month
|
|
|
|
|
|
|
|
- # DrissionPage 自动处理 Cloudflare,直接 fetch 即可
|
|
|
|
|
- try:
|
|
|
|
|
- resp = self._perform_request("GET", url, params=params, retry_count=1)
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- self._log(f"Query request failed: {e}")
|
|
|
|
|
- raise e
|
|
|
|
|
|
|
+ slots = []
|
|
|
|
|
+ all_slots = []
|
|
|
|
|
+
|
|
|
|
|
+ current_selected_ele = self.page.ele('@data-testid=btn-current-month-available')
|
|
|
|
|
+ current_month_text = current_selected_ele.text.strip() if current_selected_ele else ""
|
|
|
|
|
|
|
|
- self._check_page_is_session_expired_or_invalid('Book your appointment', resp.text)
|
|
|
|
|
|
|
+ is_on_target_month = (current_month_text.lower() == target_month_text.lower())
|
|
|
|
|
+
|
|
|
|
|
+ if not is_on_target_month:
|
|
|
|
|
+ self._log(f"Current is '{current_month_text}', navigating to '{target_month_text}'...")
|
|
|
|
|
+ for _ in range(12):
|
|
|
|
|
+ target_btn_xpath = f'xpath://a[contains(@href, "month={interest_month}")]'
|
|
|
|
|
+ target_btn = self.page.ele(target_btn_xpath)
|
|
|
|
|
+
|
|
|
|
|
+ if target_btn:
|
|
|
|
|
+ target_btn.click(by_js=True)
|
|
|
|
|
+ time.sleep(3)
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ next_btn = self.page.ele('@data-testid=btn-next-month-available')
|
|
|
|
|
+ if next_btn:
|
|
|
|
|
+ next_btn.click(by_js=True)
|
|
|
|
|
+ time.sleep(2)
|
|
|
|
|
+ else:
|
|
|
|
|
+ self._log("Warning: Cannot find target month or 'Next Month' button.")
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ self._log("Extracting slots from DOM using robust data-testid features...")
|
|
|
|
|
+
|
|
|
|
|
+ day_blocks_xpath = '//div[p and div//button[contains(@data-testid, "slot")]]'
|
|
|
|
|
+ day_blocks = self.page.eles(f'xpath:{day_blocks_xpath}')
|
|
|
|
|
+
|
|
|
|
|
+ for block in day_blocks:
|
|
|
|
|
+ # 1. 提取日期:只要是这个 block 下的 p 标签,必定是 "Mon 01" 这种
|
|
|
|
|
+ p_ele = block.ele('tag:p')
|
|
|
|
|
+ if not p_ele:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # 直接从 p 标签的纯文本里抽取出数字,忽略前面的字母
|
|
|
|
|
+ day_match = re.search(r'\d+', p_ele.text)
|
|
|
|
|
+ if not day_match:
|
|
|
|
|
+ continue
|
|
|
|
|
+ day_str = day_match.group()
|
|
|
|
|
+
|
|
|
|
|
+ full_date = f"{target_year}-{target_month_num:02d}-{int(day_str):02d}"
|
|
|
|
|
+
|
|
|
|
|
+ # 2. 提取可用按钮:利用 data-testid 前缀匹配
|
|
|
|
|
+ # 完美过滤掉 btn-unavailable-slot (灰色的不可用按钮)
|
|
|
|
|
+ available_btns = block.eles('xpath:.//button[starts-with(@data-testid, "btn-available-slot")]')
|
|
|
|
|
+
|
|
|
|
|
+ for btn in available_btns:
|
|
|
|
|
+ # 提取时间:无视内部各种 span 的变动,只要 html 里有 00:00 这种格式就被截取
|
|
|
|
|
+ time_match = re.search(r'\d{2}:\d{2}', btn.html)
|
|
|
|
|
+ if not time_match:
|
|
|
|
|
+ continue
|
|
|
|
|
+ time_str = time_match.group()
|
|
|
|
|
+
|
|
|
|
|
+ # 提取 Label:完全依赖测试工程师留下的 testid
|
|
|
|
|
+ test_id = btn.attr('data-testid') or ""
|
|
|
|
|
+ if 'prime' in test_id and 'weekend' in test_id:
|
|
|
|
|
+ lbl = 'ptaw'
|
|
|
|
|
+ elif 'prime' in test_id:
|
|
|
|
|
+ lbl = 'pta'
|
|
|
|
|
+ else:
|
|
|
|
|
+ lbl = ''
|
|
|
|
|
+
|
|
|
|
|
+ all_slots.append({
|
|
|
|
|
+ 'date': full_date,
|
|
|
|
|
+ 'time': time_str,
|
|
|
|
|
+ 'label': lbl
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
- # 解析 Slots
|
|
|
|
|
- all_slots = self._parse_appointment_slots(resp.text)
|
|
|
|
|
|
|
+ else:
|
|
|
|
|
+ self._log(f"Already on '{target_month_text}'. Executing silent JS fetch...")
|
|
|
|
|
+ url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
|
|
|
|
|
+ params = {
|
|
|
|
|
+ 'location': apt_config["code"],
|
|
|
|
|
+ 'month': interest_month
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ resp = self._perform_request("GET", url, params=params, retry_count=1)
|
|
|
|
|
+ html_content = resp.text
|
|
|
|
|
+ self._check_page_is_session_expired_or_invalid('Book your appointment', html_content)
|
|
|
|
|
+ all_slots = self._parse_appointment_slots(html_content)
|
|
|
|
|
|
|
|
target_labels = self.free_config.get("target_labels", ["", "pta"])
|
|
target_labels = self.free_config.get("target_labels", ["", "pta"])
|
|
|
- # 根据配置过滤
|
|
|
|
|
- available = [s for s in all_slots if s.get("label") in target_labels]
|
|
|
|
|
|
|
+ slots = [s for s in all_slots if s.get("label") in target_labels]
|
|
|
|
|
|
|
|
- if available:
|
|
|
|
|
|
|
+ if slots:
|
|
|
res.success = True
|
|
res.success = True
|
|
|
- earliest_date = available[0]["date"]
|
|
|
|
|
|
|
+ earliest_date = slots[0]["date"]
|
|
|
earliest_dt = datetime.strptime(earliest_date, "%Y-%m-%d")
|
|
earliest_dt = datetime.strptime(earliest_date, "%Y-%m-%d")
|
|
|
res.availability_status = AvailabilityStatus.Available
|
|
res.availability_status = AvailabilityStatus.Available
|
|
|
res.earliest_date = earliest_dt
|
|
res.earliest_date = earliest_dt
|
|
|
date_map: dict[datetime, list[TimeSlot]] = {}
|
|
date_map: dict[datetime, list[TimeSlot]] = {}
|
|
|
- for s in available:
|
|
|
|
|
|
|
+ for s in slots:
|
|
|
date_str = s["date"]
|
|
date_str = s["date"]
|
|
|
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
|
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
|
|
date_map.setdefault(dt, []).append(
|
|
date_map.setdefault(dt, []).append(
|
|
|
TimeSlot(time=s["time"], label=str(s.get("label", "")))
|
|
TimeSlot(time=s["time"], label=str(s.get("label", "")))
|
|
|
)
|
|
)
|
|
|
res.availability = [DateAvailability(date=d, times=slots) for d, slots in date_map.items()]
|
|
res.availability = [DateAvailability(date=d, times=slots) for d, slots in date_map.items()]
|
|
|
- self._log(f"Slot Found! -> {available}")
|
|
|
|
|
|
|
+ self._log(f"Slot Found! -> {slots}")
|
|
|
else:
|
|
else:
|
|
|
self._log("No slots available.")
|
|
self._log("No slots available.")
|
|
|
res.success = False
|
|
res.success = False
|
|
@@ -368,9 +437,6 @@ class TlsPlugin(IVSPlg):
|
|
|
res = VSBookResult()
|
|
res = VSBookResult()
|
|
|
res.success = False
|
|
res.success = False
|
|
|
|
|
|
|
|
- apt_config = self.free_config.get('apt_config', {})
|
|
|
|
|
- group_num = self.travel_group['group_number']
|
|
|
|
|
-
|
|
|
|
|
exp_start = user_inputs.get('expected_start_date', '')
|
|
exp_start = user_inputs.get('expected_start_date', '')
|
|
|
exp_end = user_inputs.get('expected_end_date', '')
|
|
exp_end = user_inputs.get('expected_end_date', '')
|
|
|
support_pta = user_inputs.get('support_pta', True)
|
|
support_pta = user_inputs.get('support_pta', True)
|
|
@@ -379,15 +445,11 @@ class TlsPlugin(IVSPlg):
|
|
|
if support_pta:
|
|
if support_pta:
|
|
|
target_labels.append('pta')
|
|
target_labels.append('pta')
|
|
|
|
|
|
|
|
- # 获取所有可用的日期字符串用于过滤
|
|
|
|
|
available_dates_str =[
|
|
available_dates_str =[
|
|
|
da.date.strftime("%Y-%m-%d")
|
|
da.date.strftime("%Y-%m-%d")
|
|
|
for da in slot_info.availability if da.date
|
|
for da in slot_info.availability if da.date
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- # 第一步:过滤出符合用户日期范围要求的日期,并随机选择一个 slot
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
valid_dates_list = self._filter_dates(available_dates_str, exp_start, exp_end)
|
|
valid_dates_list = self._filter_dates(available_dates_str, exp_start, exp_end)
|
|
|
if not valid_dates_list:
|
|
if not valid_dates_list:
|
|
|
raise NotFoundError(message="No dates match user constraints")
|
|
raise NotFoundError(message="No dates match user constraints")
|
|
@@ -412,142 +474,76 @@ class TlsPlugin(IVSPlg):
|
|
|
|
|
|
|
|
selected_slot = random.choice(all_possible_slots)
|
|
selected_slot = random.choice(all_possible_slots)
|
|
|
selected_date = selected_slot["date"]
|
|
selected_date = selected_slot["date"]
|
|
|
- selected_time = selected_slot["time_obj"] # TimeSlot 对象
|
|
|
|
|
|
|
+ selected_time = selected_slot["time_obj"]
|
|
|
selected_label = selected_slot["label"]
|
|
selected_label = selected_slot["label"]
|
|
|
|
|
|
|
|
- self._log(f"Found {len(all_possible_slots)} valid slots. Randomly selected: {selected_date} {selected_time.time}")
|
|
|
|
|
-
|
|
|
|
|
- # 基础 URL 和路由状态 (Next.js 专用)
|
|
|
|
|
- base_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
|
|
|
|
|
- router_state = f'%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%5B%22groupId%22%2C%22{group_num}%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%22workflow%22%2C%7B%22children%22%3A%5B%22appointment-booking%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D'
|
|
|
|
|
-
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- # 第二步:调用 getBasketCost 获取订单金额 (预定前置条件)
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- self._log("Fetching basket cost...")
|
|
|
|
|
- getBasketCost_ACTION_ID = "40124cc90acef520d4fd2daf60ad3c8e21fc2c11d8"
|
|
|
|
|
-
|
|
|
|
|
- payload =[{
|
|
|
|
|
- "groupId": str(group_num),
|
|
|
|
|
- "lang": "en-us",
|
|
|
|
|
- "labels": [selected_label]
|
|
|
|
|
- }]
|
|
|
|
|
- body_data_str = json.dumps(payload)
|
|
|
|
|
-
|
|
|
|
|
- getBasketCost_js_script = f"""
|
|
|
|
|
- const url = "{base_url}";
|
|
|
|
|
- const headers = {{
|
|
|
|
|
- 'Next-Action': '{getBasketCost_ACTION_ID}',
|
|
|
|
|
- 'Next-Router-State-Tree': decodeURIComponent('{router_state}'),
|
|
|
|
|
- 'Accept': 'text/x-component',
|
|
|
|
|
- 'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
|
|
|
- 'Content-Type': 'text/plain;charset=UTF-8'
|
|
|
|
|
- }};
|
|
|
|
|
- const bodyData = '{body_data_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: {{}}, url: url }};
|
|
|
|
|
- }});
|
|
|
|
|
|
|
+ self._log(f"Found {len(all_possible_slots)} valid slots. selected slot: {selected_date} {selected_time.time} {selected_label}")
|
|
|
|
|
+
|
|
|
|
|
+ js_inject_and_click = f"""
|
|
|
|
|
+ try {{
|
|
|
|
|
+ const form = document.querySelector('form');
|
|
|
|
|
+ if (!form) return 'Form not found';
|
|
|
|
|
+
|
|
|
|
|
+ function setReactValue(input, value) {{
|
|
|
|
|
+ if (!input) return;
|
|
|
|
|
+ input.value = value;
|
|
|
|
|
+ input.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
|
|
|
|
+ input.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
|
|
|
|
+ }}
|
|
|
|
|
+ setReactValue(form.querySelector('input[name="date"]'), '{selected_date}');
|
|
|
|
|
+ setReactValue(form.querySelector('input[name="time"]'), '{selected_time.time}');
|
|
|
|
|
+ setReactValue(form.querySelector('input[name="appointmentLabel"]'), '{selected_label}');
|
|
|
|
|
+ const submitBtn = form.querySelector('button[type="submit"]');
|
|
|
|
|
+ if (submitBtn) {{
|
|
|
|
|
+ submitBtn.removeAttribute('disabled');
|
|
|
|
|
+ submitBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
|
|
|
+ submitBtn.click();
|
|
|
|
|
+ return 'clicked';
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ return 'Submit button not found';
|
|
|
|
|
+ }}
|
|
|
|
|
+ }} catch (e) {{
|
|
|
|
|
+ return e.toString();
|
|
|
|
|
+ }}
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
- cost_res_dict = self.page.run_js(getBasketCost_js_script)
|
|
|
|
|
- cost_resp = BrowserResponse(cost_res_dict)
|
|
|
|
|
-
|
|
|
|
|
- if cost_resp.status_code != 200:
|
|
|
|
|
- self._log(f"Failed to get basket cost! Status: {cost_resp.status_code}. Aborting booking.")
|
|
|
|
|
- return res
|
|
|
|
|
-
|
|
|
|
|
- # 尝试解析并打印金额信息,例如总价和币种
|
|
|
|
|
- cost_match = re.search(r'"total":"([^"]+)","currency":"([^"]+)"', cost_resp.text)
|
|
|
|
|
- if cost_match:
|
|
|
|
|
- total_cost, currency = cost_match.groups()
|
|
|
|
|
- self._log(f"Basket cost checked successfully: {total_cost} {currency}")
|
|
|
|
|
- else:
|
|
|
|
|
- self._log("Basket cost checked successfully (could not parse exact amount).")
|
|
|
|
|
-
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- # 第三步:解决 ReCaptcha V3
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- self._log("Solving ReCaptcha V3...")
|
|
|
|
|
- page_url = f'{base_url}?location={apt_config["code"]}&month={selected_date[:7]}'
|
|
|
|
|
- api_token = self.free_config.get("capsolver_key", "")
|
|
|
|
|
-
|
|
|
|
|
- rc_params = {
|
|
|
|
|
- "type": "ReCaptchaV3TaskProxyLess",
|
|
|
|
|
- "page": page_url,
|
|
|
|
|
- "action": "book",
|
|
|
|
|
- "siteKey": "6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
|
|
|
|
|
- "apiToken": api_token,
|
|
|
|
|
- "proxy": self._get_proxy_url()
|
|
|
|
|
- }
|
|
|
|
|
- g_token = self._solve_recaptcha(rc_params)
|
|
|
|
|
-
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- # 第四步:提交正式的 Appointment Booking 请求
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- self._log("Submitting booking request via JS Fetch...")
|
|
|
|
|
- bookAppointment_ACTION_ID = "6043cfd107081bc817cbb11a8c0db17d3a063401be"
|
|
|
|
|
-
|
|
|
|
|
- bookAppointment_js_script = f"""
|
|
|
|
|
- const url = "{base_url}";
|
|
|
|
|
- const formData = new FormData();
|
|
|
|
|
-
|
|
|
|
|
- formData.append('1_formGroupId', '{group_num}');
|
|
|
|
|
- formData.append('1_lang', 'en-us');
|
|
|
|
|
- formData.append('1_process', 'APPOINTMENT');
|
|
|
|
|
- formData.append('1_location', '{apt_config["code"]}');
|
|
|
|
|
- formData.append('1_date', '{selected_date}');
|
|
|
|
|
- formData.append('1_time', '{selected_time.time}');
|
|
|
|
|
- formData.append('1_appointmentLabel', '{selected_label}');
|
|
|
|
|
- formData.append('1_captchaToken', '{g_token}');
|
|
|
|
|
- formData.append('0', '[{{"status":"IDLE"}},"$K1"]');
|
|
|
|
|
-
|
|
|
|
|
- const headers = {{
|
|
|
|
|
- 'Next-Action': '{bookAppointment_ACTION_ID}',
|
|
|
|
|
- 'Next-Router-State-Tree': decodeURIComponent('{router_state}'),
|
|
|
|
|
- 'Accept': 'text/x-component'
|
|
|
|
|
- }};
|
|
|
|
|
|
|
+ inject_res = self.page.run_js(js_inject_and_click)
|
|
|
|
|
+ self._log(f"Form submission triggered: {inject_res}")
|
|
|
|
|
|
|
|
- return fetch(url, {{ method: 'POST', headers: headers, body: formData }})
|
|
|
|
|
- .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: {{}}, url: url }};
|
|
|
|
|
- }});
|
|
|
|
|
- """
|
|
|
|
|
-
|
|
|
|
|
- book_res_dict = self.page.run_js(bookAppointment_js_script)
|
|
|
|
|
- resp = BrowserResponse(book_res_dict)
|
|
|
|
|
-
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- # 第五步:结果判定
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- if resp.status_code == 303 or (resp.status_code == 200 and "appointment-confirmation" in resp.url):
|
|
|
|
|
- self._log(f"Booking Success! URL: {resp.url}")
|
|
|
|
|
- res.success = True
|
|
|
|
|
- res.book_date = selected_date
|
|
|
|
|
- res.book_time = selected_time.time
|
|
|
|
|
- return res
|
|
|
|
|
-
|
|
|
|
|
- if resp.status_code == 200:
|
|
|
|
|
- if "APPOINTMENT_LIMIT_REACHED" in resp.text:
|
|
|
|
|
- self._log("Failed: Appointment Limit Reached")
|
|
|
|
|
- elif "Invalid captcha" in resp.text:
|
|
|
|
|
- self._log("Failed: Invalid Captcha")
|
|
|
|
|
- else:
|
|
|
|
|
- self._log(f"Booking Failed (Unknown 200): {resp.text[:200]}")
|
|
|
|
|
- else:
|
|
|
|
|
- self._log(f"Booking Failed. Status: {resp.status_code}")
|
|
|
|
|
|
|
+ if inject_res != 'clicked':
|
|
|
|
|
+ raise BizLogicError(message="Failed to inject form or click the submit button")
|
|
|
|
|
|
|
|
|
|
+ self._log("Waiting for Next.js to process the form submission...")
|
|
|
|
|
+ for _ in range(10):
|
|
|
|
|
+ try:
|
|
|
|
|
+ current_page_url = self.page.url
|
|
|
|
|
+ current_page_html = self.page.html
|
|
|
|
|
+ appointment_confirmation_indicators = [
|
|
|
|
|
+ "order-summary" in current_page_url,
|
|
|
|
|
+ "partner-services" in current_page_url,
|
|
|
|
|
+ "appointment-confirmation" in current_page_url,
|
|
|
|
|
+ "Change my appointment" in current_page_html,
|
|
|
|
|
+ "Book a new appointment" in current_page_html,
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ if any(appointment_confirmation_indicators):
|
|
|
|
|
+ self._log(f"✅ BOOKING SUCCESS! Redirected to: {current_page_url}")
|
|
|
|
|
+ res.success = True
|
|
|
|
|
+ res.label = selected_label
|
|
|
|
|
+ res.book_date = selected_date
|
|
|
|
|
+ res.book_time = selected_time.time
|
|
|
|
|
+ self._save_screenshot("book_slot_success")
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ toast_selector = 'tag:div@role=alert'
|
|
|
|
|
+ toast_ele = self.page.ele(toast_selector, timeout=0.5)
|
|
|
|
|
+ if toast_ele:
|
|
|
|
|
+ error_msg = toast_ele.text
|
|
|
|
|
+ self._log(f"❌ BOOKING FAILED! Detected popup: {error_msg}")
|
|
|
|
|
+ break
|
|
|
|
|
+ time.sleep(0.5)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
return res
|
|
return res
|
|
|
|
|
|
|
|
def _get_proxy_url(self):
|
|
def _get_proxy_url(self):
|
|
@@ -629,29 +625,20 @@ class TlsPlugin(IVSPlg):
|
|
|
self.is_healthy = False
|
|
self.is_healthy = False
|
|
|
raise SessionExpiredOrInvalidError()
|
|
raise SessionExpiredOrInvalidError()
|
|
|
elif resp.status_code == 403:
|
|
elif resp.status_code == 403:
|
|
|
- # [关键修改] 遇到 403 Forbidden,尝试绕盾并重试
|
|
|
|
|
- # 最多重试 2 次
|
|
|
|
|
if retry_count < 2:
|
|
if retry_count < 2:
|
|
|
self._log(f"HTTP 403 Detected. Cloudflare session expired? Attempting refresh (Try {retry_count+1}/2)...")
|
|
self._log(f"HTTP 403 Detected. Cloudflare session expired? Attempting refresh (Try {retry_count+1}/2)...")
|
|
|
-
|
|
|
|
|
- # 尝试刷新盾
|
|
|
|
|
if self._refresh_firewall_session():
|
|
if self._refresh_firewall_session():
|
|
|
self._log("Firewall session refreshed. Retrying request...")
|
|
self._log("Firewall session refreshed. Retrying request...")
|
|
|
- # 递归重试
|
|
|
|
|
return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
|
|
return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
|
|
|
else:
|
|
else:
|
|
|
- self._log("Failed to refresh firewall session.")
|
|
|
|
|
-
|
|
|
|
|
- # 如果重试失败,抛出异常
|
|
|
|
|
|
|
+ self._log("Failed to refresh firewall session.")
|
|
|
raise PermissionDeniedError(f"HTTP 403: {resp.text[:100]}")
|
|
raise PermissionDeniedError(f"HTTP 403: {resp.text[:100]}")
|
|
|
elif resp.status_code == 429:
|
|
elif resp.status_code == 429:
|
|
|
self.is_healthy = False
|
|
self.is_healthy = False
|
|
|
raise RateLimiteddError()
|
|
raise RateLimiteddError()
|
|
|
else:
|
|
else:
|
|
|
- # 如果是 0,可能是 fetch 报错
|
|
|
|
|
if resp.status_code == 0:
|
|
if resp.status_code == 0:
|
|
|
raise BizLogicError(f"Network Error: {resp.text}")
|
|
raise BizLogicError(f"Network Error: {resp.text}")
|
|
|
- # TLS 业务错误
|
|
|
|
|
raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
|
|
raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
|
|
|
|
|
|
|
|
def _refresh_firewall_session(self) -> bool:
|
|
def _refresh_firewall_session(self) -> bool:
|
|
@@ -727,10 +714,10 @@ class TlsPlugin(IVSPlg):
|
|
|
time.sleep(3)
|
|
time.sleep(3)
|
|
|
raise BizLogicError(message="Capsolver task timeout")
|
|
raise BizLogicError(message="Capsolver task timeout")
|
|
|
|
|
|
|
|
- def _parse_travel_groups(self, html: str) -> List[Dict]:
|
|
|
|
|
|
|
+ def _parse_travel_groups(self, html_content) -> List[Dict]:
|
|
|
groups = []
|
|
groups = []
|
|
|
js_pattern = r'\\"travelGroups\\":\s*(\[.*?\]),\\"availableCountriesToCreateGroups'
|
|
js_pattern = r'\\"travelGroups\\":\s*(\[.*?\]),\\"availableCountriesToCreateGroups'
|
|
|
- js_match = re.search(js_pattern, html, re.DOTALL)
|
|
|
|
|
|
|
+ js_match = re.search(js_pattern, html_content, re.DOTALL)
|
|
|
if js_match:
|
|
if js_match:
|
|
|
json_str = js_match.group(1).replace(r'\"', '"')
|
|
json_str = js_match.group(1).replace(r'\"', '"')
|
|
|
data = json.loads(json_str)
|
|
data = json.loads(json_str)
|
|
@@ -744,10 +731,10 @@ class TlsPlugin(IVSPlg):
|
|
|
self._log('Parsed travel group page, but not found travelGroups')
|
|
self._log('Parsed travel group page, but not found travelGroups')
|
|
|
return groups
|
|
return groups
|
|
|
|
|
|
|
|
- def _parse_appointment_slots(self, html: str) -> List[Dict]:
|
|
|
|
|
|
|
+ def _parse_appointment_slots(self, html_content) -> List[Dict]:
|
|
|
slots = []
|
|
slots = []
|
|
|
pattern = r'"availableAppointments\\":\s*(\[.*\]),\\"showFlexiAppointment'
|
|
pattern = r'"availableAppointments\\":\s*(\[.*\]),\\"showFlexiAppointment'
|
|
|
- match = re.search(pattern, html, re.DOTALL)
|
|
|
|
|
|
|
+ match = re.search(pattern, html_content, re.DOTALL)
|
|
|
|
|
|
|
|
if match:
|
|
if match:
|
|
|
json_str = match.group(1).replace(r'\"', '"')
|
|
json_str = match.group(1).replace(r'\"', '"')
|
|
@@ -774,18 +761,16 @@ class TlsPlugin(IVSPlg):
|
|
|
self.is_healthy = False
|
|
self.is_healthy = False
|
|
|
raise SessionExpiredOrInvalidError()
|
|
raise SessionExpiredOrInvalidError()
|
|
|
|
|
|
|
|
- # 将 html 转小写检查
|
|
|
|
|
html_lower = html.lower()
|
|
html_lower = html.lower()
|
|
|
if keyword.lower() not in html_lower:
|
|
if keyword.lower() not in html_lower:
|
|
|
- if 'redirected automatically' in html_lower:
|
|
|
|
|
- self.is_healthy = False
|
|
|
|
|
- raise SessionExpiredOrInvalidError("Redirected automatically")
|
|
|
|
|
- if 'login' in html_lower and 'password' in html_lower:
|
|
|
|
|
- self.is_healthy = False
|
|
|
|
|
- raise SessionExpiredOrInvalidError("Redirected to login")
|
|
|
|
|
- if 'session expired' in html_lower:
|
|
|
|
|
|
|
+ session_expire_or_invalid_indicators = [
|
|
|
|
|
+ 'redirected automatically' in html_lower,
|
|
|
|
|
+ 'login' in html_lower and 'password' in html_lower,
|
|
|
|
|
+ 'session expired' in html_lower
|
|
|
|
|
+ ]
|
|
|
|
|
+ if any(session_expire_or_invalid_indicators):
|
|
|
self.is_healthy = False
|
|
self.is_healthy = False
|
|
|
- raise SessionExpiredOrInvalidError("Session expired")
|
|
|
|
|
|
|
+ raise SessionExpiredOrInvalidError()
|
|
|
|
|
|
|
|
def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
|
|
def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
|
|
|
if not start_str or not end_str:
|
|
if not start_str or not end_str:
|