|
@@ -8,7 +8,7 @@ import shutil
|
|
|
import socket
|
|
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, parse_qs
|
|
|
|
|
|
|
|
# DrissionPage 核心
|
|
# DrissionPage 核心
|
|
|
from DrissionPage import ChromiumPage, ChromiumOptions
|
|
from DrissionPage import ChromiumPage, ChromiumOptions
|
|
@@ -58,7 +58,7 @@ class TlsPlugin(IVSPlg):
|
|
|
self.keyboard = None
|
|
self.keyboard = None
|
|
|
self.page: Optional[ChromiumPage] = None
|
|
self.page: Optional[ChromiumPage] = None
|
|
|
self.travel_group: Optional[Dict] = None
|
|
self.travel_group: Optional[Dict] = None
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
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")
|
|
@@ -126,7 +126,7 @@ class TlsPlugin(IVSPlg):
|
|
|
|
|
|
|
|
def create_session(self):
|
|
def create_session(self):
|
|
|
"""
|
|
"""
|
|
|
- 全浏览器会话创建:过盾 -> JS注入登录 -> 原生跳转
|
|
|
|
|
|
|
+ 全浏览器会话创建:过盾 -> JS注入登录 -> 状态机自动路由导航 -> 到达目标页
|
|
|
"""
|
|
"""
|
|
|
self._log(f"Initializing Session (ID: {self.instance_id})...")
|
|
self._log(f"Initializing Session (ID: {self.instance_id})...")
|
|
|
co = ChromiumOptions()
|
|
co = ChromiumOptions()
|
|
@@ -148,16 +148,12 @@ class TlsPlugin(IVSPlg):
|
|
|
|
|
|
|
|
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}...")
|
|
|
-
|
|
|
|
|
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}")
|
|
|
co.set_argument(f'--proxy-server={local_proxy}')
|
|
co.set_argument(f'--proxy-server={local_proxy}')
|
|
|
-
|
|
|
|
|
else:
|
|
else:
|
|
|
proxy_str = f"{p.proto}://{p.ip}:{p.port}"
|
|
proxy_str = f"{p.proto}://{p.ip}:{p.port}"
|
|
|
co.set_argument(f'--proxy-server={proxy_str}')
|
|
co.set_argument(f'--proxy-server={proxy_str}')
|
|
@@ -170,15 +166,17 @@ 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-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')
|
|
|
co.set_argument(f"--fingerprint={specific_fp.get('seed')}")
|
|
co.set_argument(f"--fingerprint={specific_fp.get('seed')}")
|
|
|
co.set_argument(f"--fingerprint-platform={specific_fp.get('platform')}")
|
|
co.set_argument(f"--fingerprint-platform={specific_fp.get('platform')}")
|
|
|
co.set_argument(f"--fingerprint-brand={specific_fp.get('brand')}")
|
|
co.set_argument(f"--fingerprint-brand={specific_fp.get('brand')}")
|
|
|
|
|
+
|
|
|
try:
|
|
try:
|
|
|
self.page = ChromiumPage(co)
|
|
self.page = ChromiumPage(co)
|
|
|
|
|
+
|
|
|
|
|
+ # --- 预检指纹信息 ---
|
|
|
if self.config.debug:
|
|
if self.config.debug:
|
|
|
self.page.get('https://example.com')
|
|
self.page.get('https://example.com')
|
|
|
js_script = """
|
|
js_script = """
|
|
@@ -210,160 +208,168 @@ class TlsPlugin(IVSPlg):
|
|
|
}
|
|
}
|
|
|
return getFingerprint();
|
|
return getFingerprint();
|
|
|
"""
|
|
"""
|
|
|
-
|
|
|
|
|
fp_data = self.page.run_js(js_script)
|
|
fp_data = self.page.run_js(js_script)
|
|
|
self._log("================ 预检浏览器指纹数据 ================")
|
|
self._log("================ 预检浏览器指纹数据 ================")
|
|
|
self._log(json.dumps(fp_data, indent=4, ensure_ascii=False))
|
|
self._log(json.dumps(fp_data, indent=4, ensure_ascii=False))
|
|
|
self._log("====================================================")
|
|
self._log("====================================================")
|
|
|
|
|
|
|
|
|
|
+ # --- 初始化访问与过盾 ---
|
|
|
tls_url = self.free_config.get('tls_url', '')
|
|
tls_url = self.free_config.get('tls_url', '')
|
|
|
self._log(f"Navigating: {tls_url}")
|
|
self._log(f"Navigating: {tls_url}")
|
|
|
self.page.get(tls_url)
|
|
self.page.get(tls_url)
|
|
|
time.sleep(5)
|
|
time.sleep(5)
|
|
|
|
|
+
|
|
|
cf_bypasser = CloudflareBypasser(self.page, log=True)
|
|
cf_bypasser = CloudflareBypasser(self.page, log=True)
|
|
|
if not cf_bypasser.bypass(max_retry=15):
|
|
if not cf_bypasser.bypass(max_retry=15):
|
|
|
raise BizLogicError("Cloudflare bypass timeout")
|
|
raise BizLogicError("Cloudflare bypass timeout")
|
|
|
time.sleep(3)
|
|
time.sleep(3)
|
|
|
cf_bypasser.handle_waiting_room()
|
|
cf_bypasser.handle_waiting_room()
|
|
|
|
|
|
|
|
|
|
+ # --- 初始化人类行为模拟工具 ---
|
|
|
self._log("Init humanize tools...")
|
|
self._log("Init humanize tools...")
|
|
|
self.mouse = HumanMouse(self.page, debug=True)
|
|
self.mouse = HumanMouse(self.page, debug=True)
|
|
|
self.keyboard = HumanKeyboard(self.page)
|
|
self.keyboard = HumanKeyboard(self.page)
|
|
|
- self._log("Random mouse start position...")
|
|
|
|
|
viewport_width = self.page.rect.viewport_size[0]
|
|
viewport_width = self.page.rect.viewport_size[0]
|
|
|
viewport_height = self.page.rect.viewport_size[1]
|
|
viewport_height = self.page.rect.viewport_size[1]
|
|
|
init_x = random.randint(10, viewport_width - 10)
|
|
init_x = random.randint(10, viewport_width - 10)
|
|
|
init_y = random.randint(10, viewport_height - 10)
|
|
init_y = random.randint(10, viewport_height - 10)
|
|
|
self.mouse.move(init_x, init_y)
|
|
self.mouse.move(init_x, init_y)
|
|
|
-
|
|
|
|
|
- btn_selector = 'tag:button@@text():Login'
|
|
|
|
|
- if not self.page.wait.ele_displayed(btn_selector, timeout=3):
|
|
|
|
|
- login_btn = self.page.ele("tag:a@@href:login")
|
|
|
|
|
- self.mouse.human_click_ele(login_btn)
|
|
|
|
|
- time.sleep(3)
|
|
|
|
|
- if not self.page.wait.ele_displayed(btn_selector, timeout=10):
|
|
|
|
|
- raise BizLogicError(message=f"Can't find selector={btn_selector}")
|
|
|
|
|
- time.sleep(random.uniform(0.5, 1))
|
|
|
|
|
-
|
|
|
|
|
- # recaptchav2_token = ""
|
|
|
|
|
- # if self.page.ele('.g-recaptcha') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]'):
|
|
|
|
|
- # self._log("Solving ReCaptcha...")
|
|
|
|
|
- # rc_params = {
|
|
|
|
|
- # "type": "ReCaptchaV2TaskProxyLess",
|
|
|
|
|
- # "page": self.page.url,
|
|
|
|
|
- # "siteKey": "6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0",
|
|
|
|
|
- # "apiToken": self.free_config.get("capsolver_key", "")
|
|
|
|
|
- # }
|
|
|
|
|
- # recaptchav2_token = self._solve_recaptcha(rc_params)
|
|
|
|
|
-
|
|
|
|
|
- username = self.config.account.username
|
|
|
|
|
- password = self.config.account.password
|
|
|
|
|
-
|
|
|
|
|
- input_ele = self.page.ele('tag:label@@text():Email').next()
|
|
|
|
|
- self.mouse.human_click_ele(input_ele)
|
|
|
|
|
- time.sleep(random.uniform(0.2, 0.6))
|
|
|
|
|
- self.keyboard.type_text(username, humanize=True)
|
|
|
|
|
-
|
|
|
|
|
- time.sleep(random.uniform(0.5, 1.2))
|
|
|
|
|
-
|
|
|
|
|
- input_ele = self.page.ele('tag:label@@text():Password').next()
|
|
|
|
|
- self.mouse.human_click_ele(input_ele)
|
|
|
|
|
- time.sleep(random.uniform(0.2, 0.6))
|
|
|
|
|
- self.keyboard.type_text(password, humanize=True)
|
|
|
|
|
-
|
|
|
|
|
- # if recaptchav2_token:
|
|
|
|
|
- # inject_recaptchav2_token_js = f"""
|
|
|
|
|
- # var g = document.getElementById('g-recaptcha-response');
|
|
|
|
|
- # if(g) {{ g.value = "{recaptchav2_token}"; }}
|
|
|
|
|
- # """
|
|
|
|
|
- # self._log("Inject ReCaptchaV2 Token via JS...")
|
|
|
|
|
- # self.page.run_js(inject_recaptchav2_token_js)
|
|
|
|
|
- # time.sleep(random.uniform(0.5, 1.0))
|
|
|
|
|
-
|
|
|
|
|
- self._log("Submitting Login...")
|
|
|
|
|
- time.sleep(random.uniform(0.3, 0.8))
|
|
|
|
|
- login_btn = self.page.ele('tag:button@@text():Login')
|
|
|
|
|
- self.mouse.human_click_ele(login_btn)
|
|
|
|
|
-
|
|
|
|
|
- self._log("Waiting for redirect...")
|
|
|
|
|
- self.page.wait.url_change('login-actions', exclude=True, timeout=45)
|
|
|
|
|
-
|
|
|
|
|
- time.sleep(3)
|
|
|
|
|
- if "login-actions" in self.page.url or "auth" in self.page.url:
|
|
|
|
|
- raise BizLogicError(message="Login Failed! Invalid credentials or Captcha rejected.")
|
|
|
|
|
-
|
|
|
|
|
- self.page.wait.load_start()
|
|
|
|
|
- time.sleep(5)
|
|
|
|
|
-
|
|
|
|
|
- # groups = self._parse_travel_groups(self.page.html)
|
|
|
|
|
- # location = self.free_config.get('location')
|
|
|
|
|
- # for g in groups:
|
|
|
|
|
- # if g['location'] == location:
|
|
|
|
|
- # self.travel_group = g
|
|
|
|
|
- # break
|
|
|
|
|
-
|
|
|
|
|
- # if not self.travel_group:
|
|
|
|
|
- # self._save_screenshot("group_not_found")
|
|
|
|
|
- # raise NotFoundError(f"Group not found for {location}")
|
|
|
|
|
-
|
|
|
|
|
- # formgroup_id = self.travel_group.get('group_number')
|
|
|
|
|
|
|
|
|
|
- # btn_selector = f'tag:button@@name=formGroupId@@value={formgroup_id}'
|
|
|
|
|
- # self._log(f"Waiting for visible button to render: {formgroup_id}...")
|
|
|
|
|
|
|
+ max_steps = 20
|
|
|
|
|
+ session_created = False
|
|
|
|
|
+ has_submitted_login = False
|
|
|
|
|
|
|
|
- # self.page.wait.eles_loaded(btn_selector, timeout=15)
|
|
|
|
|
-
|
|
|
|
|
- # buttons = self.page.eles(btn_selector)
|
|
|
|
|
- # select_btn = None
|
|
|
|
|
- # for btn in reversed(buttons):
|
|
|
|
|
- # try:
|
|
|
|
|
- # w, h = btn.rect.size
|
|
|
|
|
- # if w > 0 and h > 0:
|
|
|
|
|
- # select_btn = btn
|
|
|
|
|
- # break
|
|
|
|
|
- # except Exception:
|
|
|
|
|
- # continue
|
|
|
|
|
- # if not select_btn:
|
|
|
|
|
- # self._save_screenshot("visible_button_not_found")
|
|
|
|
|
- # raise BizLogicError(f"Can't find any visible Select button for group {formgroup_id}")
|
|
|
|
|
- # time.sleep(random.uniform(0.5, 1.2))
|
|
|
|
|
- # self.mouse.human_click_ele(select_btn)
|
|
|
|
|
-
|
|
|
|
|
- # 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.mouse.human_click_ele(self.page.ele(btn_selector))
|
|
|
|
|
-
|
|
|
|
|
- time.sleep(3)
|
|
|
|
|
- # 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!")
|
|
|
|
|
|
|
+ for step in range(max_steps):
|
|
|
|
|
+ self.page.wait.load_start()
|
|
|
|
|
+ current_url = self.page.url
|
|
|
|
|
+ self._log(f"--- [Router Step {step+1}] Current URL: {current_url} ---")
|
|
|
|
|
+
|
|
|
|
|
+ # 状态 1:到达终极目标页面 (成功退出条件)
|
|
|
|
|
+ if "appointment-booking" in current_url or self.page.ele('tag:button@text():Book your appointment', timeout=1):
|
|
|
|
|
+ btn_selector = 'tag:button@text():Book your appointment'
|
|
|
|
|
+ if self.page.wait.ele_displayed(btn_selector, timeout=10):
|
|
|
|
|
+ self.session_create_time = time.time()
|
|
|
|
|
+ self._log("✅ Login & Navigation Success! Reached appointment-booking.")
|
|
|
|
|
+ session_created = True
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ # 状态 2:遇到没有申请人的拦截页 (致命错误退出条件)
|
|
|
|
|
+ no_applicant_indicators = [
|
|
|
|
|
+ "Add a new applicant" in self.page.html,
|
|
|
|
|
+ "You have not yet added an applicant" in self.page.html,
|
|
|
|
|
+ "applicants-information" in current_url
|
|
|
|
|
+ ]
|
|
|
|
|
+ if any(no_applicant_indicators):
|
|
|
|
|
+ raise BizLogicError(message="No applicant added. Cannot proceed to booking.")
|
|
|
|
|
+
|
|
|
|
|
+ # 状态 3:首页/登录入口页 -> 需要点击进入登录
|
|
|
|
|
+ if self.page.ele("tag:a@@href:login", timeout=1) and not self.page.ele('tag:label@@text():Email', timeout=1):
|
|
|
|
|
+ self._log("State: Login Portal. Clicking login link...")
|
|
|
|
|
+ login_link = self.page.ele("tag:a@@href:login")
|
|
|
|
|
+ self.mouse.human_click_ele(login_link)
|
|
|
|
|
+ time.sleep(3)
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # 状态 4:真正的登录表单页
|
|
|
|
|
+ if self.page.ele('tag:label@@text():Email', timeout=1) and not has_submitted_login:
|
|
|
|
|
+ self._log("State: Login Form. Processing credentials and Captcha...")
|
|
|
|
|
+
|
|
|
|
|
+ recaptchav2_token = ""
|
|
|
|
|
+ if self.page.ele('.g-recaptcha') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]'):
|
|
|
|
|
+ rec_iframe = self.page.ele('xpath://iframe[contains(@src, "recaptcha")]')
|
|
|
|
|
+ rec_iframe_src = rec_iframe.attr('src')
|
|
|
|
|
+ rec_parsed = urlparse(rec_iframe_src)
|
|
|
|
|
+ rec_params = parse_qs(rec_parsed.query)
|
|
|
|
|
+ rec_sitekey = rec_params.get("k", [None])[0]
|
|
|
|
|
+ rec_size = rec_params.get("size", [None])[0]
|
|
|
|
|
+
|
|
|
|
|
+ if 'normal' == rec_size:
|
|
|
|
|
+ self._log(f"Solving ReCaptcha sitekey={rec_sitekey}...")
|
|
|
|
|
+ rc_params = {
|
|
|
|
|
+ "type": "ReCaptchaV2TaskProxyLess",
|
|
|
|
|
+ "page": current_url,
|
|
|
|
|
+ "siteKey": rec_sitekey,
|
|
|
|
|
+ "apiToken": self.free_config.get("capsolver_key", "")
|
|
|
|
|
+ }
|
|
|
|
|
+ recaptchav2_token = self._solve_recaptcha(rc_params)
|
|
|
|
|
|
|
|
- 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")
|
|
|
|
|
|
|
+ username = self.config.account.username
|
|
|
|
|
+ password = self.config.account.password
|
|
|
|
|
+
|
|
|
|
|
+ input_ele = self.page.ele('tag:label@@text():Email').next()
|
|
|
|
|
+ self.mouse.human_click_ele(input_ele)
|
|
|
|
|
+ time.sleep(random.uniform(0.2, 0.6))
|
|
|
|
|
+ self.keyboard.type_text(username, humanize=True)
|
|
|
|
|
+ time.sleep(random.uniform(0.5, 1.2))
|
|
|
|
|
+
|
|
|
|
|
+ input_ele = self.page.ele('tag:label@@text():Password').next()
|
|
|
|
|
+ self.mouse.human_click_ele(input_ele)
|
|
|
|
|
+ time.sleep(random.uniform(0.2, 0.6))
|
|
|
|
|
+ self.keyboard.type_text(password, humanize=True)
|
|
|
|
|
+
|
|
|
|
|
+ # 注入 Token
|
|
|
|
|
+ if recaptchav2_token:
|
|
|
|
|
+ inject_js = f"var g = document.getElementById('g-recaptcha-response'); if(g) {{ g.value = '{recaptchav2_token}'; }}"
|
|
|
|
|
+ self.page.run_js(inject_js)
|
|
|
|
|
+ time.sleep(random.uniform(0.5, 1.0))
|
|
|
|
|
+
|
|
|
|
|
+ self._log("Submitting Login...")
|
|
|
|
|
+ login_btn = self.page.ele('tag:button@@text():Login')
|
|
|
|
|
+ self.mouse.human_click_ele(login_btn)
|
|
|
|
|
+ has_submitted_login = True
|
|
|
|
|
+ time.sleep(3)
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # 状态 5:Travel Groups 页面
|
|
|
|
|
+ if "travel-groups" in current_url:
|
|
|
|
|
+ self._log("State: Travel Groups. Selecting targeted group...")
|
|
|
|
|
+ groups = self._parse_travel_groups(self.page.html)
|
|
|
|
|
+ location = self.free_config.get('location')
|
|
|
|
|
+ self.travel_group = next((g for g in groups if location in g['location']), None)
|
|
|
|
|
+
|
|
|
|
|
+ if not self.travel_group:
|
|
|
|
|
+ self._save_screenshot("group_not_found")
|
|
|
|
|
+ raise NotFoundError(f"Group not found for {location}")
|
|
|
|
|
|
|
|
- self.session_create_time = time.time()
|
|
|
|
|
- self._log(f"✅ Login & Navigation Success!")
|
|
|
|
|
|
|
+ formgroup_id = self.travel_group.get('group_number')
|
|
|
|
|
+ btn_selector = f'tag:button@@name=formGroupId@@value={formgroup_id}'
|
|
|
|
|
+
|
|
|
|
|
+ if self.page.wait.eles_loaded(btn_selector, timeout=10):
|
|
|
|
|
+ buttons = self.page.eles(btn_selector)
|
|
|
|
|
+ select_btn = next((btn for btn in reversed(buttons) if btn.rect.size[0] > 0 and btn.rect.size[1] > 0), None)
|
|
|
|
|
+
|
|
|
|
|
+ if select_btn:
|
|
|
|
|
+ time.sleep(random.uniform(0.5, 1.2))
|
|
|
|
|
+ self.mouse.human_click_ele(select_btn)
|
|
|
|
|
+ time.sleep(3)
|
|
|
|
|
+ continue
|
|
|
|
|
+ else:
|
|
|
|
|
+ self._log("[WARN] Select button found but not visible.")
|
|
|
|
|
+ else:
|
|
|
|
|
+ self._log(f"[WARN] Wait timeout for group button {formgroup_id}")
|
|
|
|
|
+
|
|
|
|
|
+ # 状态 6:中间过渡页,需点击 "Book Appointment" 继续往下走
|
|
|
|
|
+ if self.page.ele('#book-appointment-btn', timeout=1):
|
|
|
|
|
+ self._log("State: Intermediate Dashboard. Clicking Book Appointment button...")
|
|
|
|
|
+ self.mouse.human_click_ele(self.page.ele('#book-appointment-btn'))
|
|
|
|
|
+ time.sleep(3)
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # 状态 7:登录失败校验 或 未知加载状态
|
|
|
|
|
+ if "login-actions" in current_url and has_submitted_login:
|
|
|
|
|
+ self._log("Waiting on login-actions... (Might be authenticating or invalid credentials)")
|
|
|
|
|
+ time.sleep(2)
|
|
|
|
|
+ if self.page.ele('text:Invalid username or password', timeout=1): # 假设网页上有错误提示
|
|
|
|
|
+ raise BizLogicError(message="Login Failed! Invalid credentials or Captcha rejected.")
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # 兜底:未匹配到明确状态,等待页面渲染或重定向
|
|
|
|
|
+ self._log("State: Transitioning or Unknown. Waiting 2 seconds...")
|
|
|
|
|
+ time.sleep(2)
|
|
|
|
|
+
|
|
|
|
|
+ # 如果循环耗尽还没到达目标
|
|
|
|
|
+ if not session_created:
|
|
|
|
|
+ raise BizLogicError(f"Failed to reach appointment-booking after {max_steps} navigation steps. Stuck at: {self.page.url}")
|
|
|
|
|
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
self._log(f"Session Create Error: {e}")
|
|
self._log(f"Session Create Error: {e}")
|
|
@@ -383,8 +389,6 @@ class TlsPlugin(IVSPlg):
|
|
|
target_month_num = target_date_obj.month
|
|
target_month_num = target_date_obj.month
|
|
|
|
|
|
|
|
slots = []
|
|
slots = []
|
|
|
- all_slots = []
|
|
|
|
|
-
|
|
|
|
|
current_selected_ele = self.page.ele('@data-testid=btn-current-month-available')
|
|
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 ""
|
|
current_month_text = current_selected_ele.text.strip() if current_selected_ele else ""
|
|
|
|
|
|
|
@@ -392,76 +396,36 @@ class TlsPlugin(IVSPlg):
|
|
|
|
|
|
|
|
if not is_on_target_month:
|
|
if not is_on_target_month:
|
|
|
self._log(f"Current is '{current_month_text}', navigating to '{target_month_text}'...")
|
|
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)
|
|
|
|
|
|
|
+ reached_target = False
|
|
|
|
|
+ for step in range(12):
|
|
|
|
|
+ current_ele = self.page.ele('@data-testid=btn-current-month-available', timeout=2)
|
|
|
|
|
+ if current_ele and current_ele.text.strip().lower() == target_month_text.lower():
|
|
|
|
|
+ self._log(f"✅ Successfully navigated to target month: '{target_month_text}'!")
|
|
|
|
|
+ reached_target = True
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
- next_btn = self.page.ele('@data-testid=btn-next-month-available')
|
|
|
|
|
- if next_btn:
|
|
|
|
|
|
|
+ next_btn = self.page.ele('@data-testid=btn-next-month-available', timeout=2)
|
|
|
|
|
+
|
|
|
|
|
+ if next_btn and next_btn.tag.lower() == 'button':
|
|
|
|
|
+ self._log(f"Clicking next month: {next_btn.text.strip()} ...")
|
|
|
next_btn.click(by_js=True)
|
|
next_btn.click(by_js=True)
|
|
|
- time.sleep(2)
|
|
|
|
|
|
|
+ time.sleep(2.5)
|
|
|
else:
|
|
else:
|
|
|
- self._log("Warning: Cannot find target month or 'Next Month' button.")
|
|
|
|
|
|
|
+ self._log("⚠️ Reached the end of the calendar or 'Next Month' is disabled.")
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
|
|
+ if not reached_target:
|
|
|
|
|
+ self._log(f"❌ Could not navigate to target month: {target_month_text}. Stop parsing.")
|
|
|
|
|
+ res.success = False
|
|
|
|
|
+ res.availability_status = AvailabilityStatus.NoneAvailable
|
|
|
|
|
+ return res
|
|
|
self._log("Extracting slots from DOM using robust data-testid features...")
|
|
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 = self._scan_dom_for_slots(target_year, target_month_num)
|
|
|
else:
|
|
else:
|
|
|
self._log(f"Already on '{target_month_text}'. Executing silent JS fetch...")
|
|
self._log(f"Already on '{target_month_text}'. Executing silent JS fetch...")
|
|
|
resp = self._perform_request("GET", self.page.url, retry_count=1)
|
|
resp = self._perform_request("GET", self.page.url, retry_count=1)
|
|
|
self._check_page_is_session_expired_or_invalid('Book your appointment', resp.text)
|
|
self._check_page_is_session_expired_or_invalid('Book your appointment', resp.text)
|
|
|
- all_slots = self._parse_appointment_slots(resp.text)
|
|
|
|
|
-
|
|
|
|
|
- target_labels = self.free_config.get("target_labels", ["", "pta"])
|
|
|
|
|
- slots = [s for s in all_slots if s.get("label") in target_labels]
|
|
|
|
|
|
|
+ slots = self._parse_appointment_slots(resp.text)
|
|
|
|
|
|
|
|
if slots:
|
|
if slots:
|
|
|
res.success = True
|
|
res.success = True
|
|
@@ -646,7 +610,70 @@ class TlsPlugin(IVSPlg):
|
|
|
except Exception:
|
|
except Exception:
|
|
|
pass
|
|
pass
|
|
|
return res
|
|
return res
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
|
|
+ def _scan_dom_for_slots(self, target_year: int, target_month_num: int) -> list[dict]:
|
|
|
|
|
+ """
|
|
|
|
|
+ DOM-based slot scanning — 结合区块结构与类名/属性推断标签
|
|
|
|
|
+ """
|
|
|
|
|
+ slots = []
|
|
|
|
|
+
|
|
|
|
|
+ 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:
|
|
|
|
|
+ p_ele = block.ele('tag:p')
|
|
|
|
|
+ if not p_ele: continue
|
|
|
|
|
+
|
|
|
|
|
+ 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}"
|
|
|
|
|
+
|
|
|
|
|
+ btn_selectors = [
|
|
|
|
|
+ 'xpath:.//button[starts-with(@data-testid, "btn-available-slot")]',
|
|
|
|
|
+ 'xpath:.//button[contains(@class, "available")]',
|
|
|
|
|
+ 'xpath:.//div[contains(@class, "time-slot") and not(contains(@class, "unavailable"))]'
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ available_elements = []
|
|
|
|
|
+ for sel in btn_selectors:
|
|
|
|
|
+ elements = block.eles(sel)
|
|
|
|
|
+ if elements:
|
|
|
|
|
+ available_elements.extend(elements)
|
|
|
|
|
+ break
|
|
|
|
|
+ seen_times = set()
|
|
|
|
|
+ for el in available_elements:
|
|
|
|
|
+ classes = (el.attr("class") or "").lower()
|
|
|
|
|
+ test_id = (el.attr("data-testid") or "").lower()
|
|
|
|
|
+ combined_attrs = f"{classes} {test_id}"
|
|
|
|
|
+ if "disabled" in combined_attrs or "unavailable" in combined_attrs:
|
|
|
|
|
+ continue
|
|
|
|
|
+ time_match = re.search(r'\d{2}:\d{2}', el.html)
|
|
|
|
|
+ if not time_match: continue
|
|
|
|
|
+ time_str = time_match.group()
|
|
|
|
|
+
|
|
|
|
|
+ if time_str in seen_times:
|
|
|
|
|
+ continue
|
|
|
|
|
+ seen_times.add(time_str)
|
|
|
|
|
+ label = ''
|
|
|
|
|
+ if 'prime' in combined_attrs and 'weekend' in combined_attrs:
|
|
|
|
|
+ label = 'ptaw'
|
|
|
|
|
+ elif 'prime' in combined_attrs or 'premium' in combined_attrs:
|
|
|
|
|
+ label = 'pta'
|
|
|
|
|
+ elif any(k in combined_attrs for k in ['regular', 'standard', 'default']):
|
|
|
|
|
+ label = ''
|
|
|
|
|
+ else:
|
|
|
|
|
+ label = ''
|
|
|
|
|
+
|
|
|
|
|
+ slots.append({
|
|
|
|
|
+ 'date': full_date,
|
|
|
|
|
+ 'time': time_str,
|
|
|
|
|
+ 'label': label,
|
|
|
|
|
+ 'source': 'dom'
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return slots
|
|
|
|
|
+
|
|
|
def _get_proxy_url(self):
|
|
def _get_proxy_url(self):
|
|
|
# 构造代理
|
|
# 构造代理
|
|
|
proxy_url = ""
|
|
proxy_url = ""
|