|
@@ -279,6 +279,7 @@ class TlsPlugin(IVSPlg):
|
|
|
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)
|
|
|
# 检查是否失败
|
|
# 检查是否失败
|
|
|
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"
|
|
@@ -356,7 +357,9 @@ class TlsPlugin(IVSPlg):
|
|
|
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}")
|
|
|
else:
|
|
else:
|
|
|
|
|
+ self._log("No slots available.")
|
|
|
res.success = False
|
|
res.success = False
|
|
|
res.availability_status = AvailabilityStatus.NoneAvailable
|
|
res.availability_status = AvailabilityStatus.NoneAvailable
|
|
|
return res
|
|
return res
|
|
@@ -377,29 +380,26 @@ class TlsPlugin(IVSPlg):
|
|
|
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
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
- # 1. 过滤出符合用户日期范围要求的日期
|
|
|
|
|
|
|
+ # ---------------------------------------------------------
|
|
|
|
|
+ # 第一步:过滤出符合用户日期范围要求的日期,并随机选择一个 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")
|
|
|
|
|
|
|
|
- # --- 修改部分:计算所有可行的 slot ---
|
|
|
|
|
- all_possible_slots = []
|
|
|
|
|
-
|
|
|
|
|
|
|
+ all_possible_slots =[]
|
|
|
for da in slot_info.availability:
|
|
for da in slot_info.availability:
|
|
|
if not da.date:
|
|
if not da.date:
|
|
|
continue
|
|
continue
|
|
|
|
|
|
|
|
date_str = da.date.strftime("%Y-%m-%d")
|
|
date_str = da.date.strftime("%Y-%m-%d")
|
|
|
-
|
|
|
|
|
- # 如果该日期在用户过滤后的合法日期列表中
|
|
|
|
|
if date_str in valid_dates_list:
|
|
if date_str in valid_dates_list:
|
|
|
for t in da.times:
|
|
for t in da.times:
|
|
|
- # 检查标签是否符合要求
|
|
|
|
|
if t.label in target_labels:
|
|
if t.label in target_labels:
|
|
|
all_possible_slots.append({
|
|
all_possible_slots.append({
|
|
|
"date": date_str,
|
|
"date": date_str,
|
|
@@ -410,21 +410,76 @@ class TlsPlugin(IVSPlg):
|
|
|
if not all_possible_slots:
|
|
if not all_possible_slots:
|
|
|
raise NotFoundError(message="No suitable slot found (after label filtering)")
|
|
raise NotFoundError(message="No suitable slot found (after label filtering)")
|
|
|
|
|
|
|
|
- # 随机选择一个 slot
|
|
|
|
|
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"] # TimeSlot 对象
|
|
|
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}")
|
|
self._log(f"Found {len(all_possible_slots)} valid slots. Randomly selected: {selected_date} {selected_time.time}")
|
|
|
- # --- 修改结束 ---
|
|
|
|
|
-
|
|
|
|
|
- # 2. 解决 ReCaptcha V3
|
|
|
|
|
- page_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking?location={apt_config["code"]}&month={selected_date[:7]}'
|
|
|
|
|
|
|
|
|
|
|
|
+ # 基础 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 }};
|
|
|
|
|
+ }});
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ 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", "")
|
|
api_token = self.free_config.get("capsolver_key", "")
|
|
|
|
|
+
|
|
|
rc_params = {
|
|
rc_params = {
|
|
|
- "type": "ReCaptchaV3Task",
|
|
|
|
|
|
|
+ "type": "ReCaptchaV3TaskProxyLess",
|
|
|
"page": page_url,
|
|
"page": page_url,
|
|
|
"action": "book",
|
|
"action": "book",
|
|
|
"siteKey": "6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
|
|
"siteKey": "6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
|
|
@@ -433,14 +488,14 @@ class TlsPlugin(IVSPlg):
|
|
|
}
|
|
}
|
|
|
g_token = self._solve_recaptcha(rc_params)
|
|
g_token = self._solve_recaptcha(rc_params)
|
|
|
|
|
|
|
|
- # 3. 构造 Next.js Payload
|
|
|
|
|
- ACTION_ID = "6043cfd107081bc817cbb11a8c0db17d3a063401be"
|
|
|
|
|
- url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
|
|
|
|
|
|
|
+ # ---------------------------------------------------------
|
|
|
|
|
+ # 第四步:提交正式的 Appointment Booking 请求
|
|
|
|
|
+ # ---------------------------------------------------------
|
|
|
|
|
+ self._log("Submitting booking request via JS Fetch...")
|
|
|
|
|
+ bookAppointment_ACTION_ID = "6043cfd107081bc817cbb11a8c0db17d3a063401be"
|
|
|
|
|
|
|
|
- router_state = '%5B%22%22%2C%7B%22children%22%3A%5B%5B%22lang%22%2C%22en-us%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%5B%22groupId%22%2C%22'+str(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'
|
|
|
|
|
-
|
|
|
|
|
- js_script = f"""
|
|
|
|
|
- const url = "{url}";
|
|
|
|
|
|
|
+ bookAppointment_js_script = f"""
|
|
|
|
|
+ const url = "{base_url}";
|
|
|
const formData = new FormData();
|
|
const formData = new FormData();
|
|
|
|
|
|
|
|
formData.append('1_formGroupId', '{group_num}');
|
|
formData.append('1_formGroupId', '{group_num}');
|
|
@@ -450,44 +505,37 @@ class TlsPlugin(IVSPlg):
|
|
|
formData.append('1_date', '{selected_date}');
|
|
formData.append('1_date', '{selected_date}');
|
|
|
formData.append('1_time', '{selected_time.time}');
|
|
formData.append('1_time', '{selected_time.time}');
|
|
|
formData.append('1_appointmentLabel', '{selected_label}');
|
|
formData.append('1_appointmentLabel', '{selected_label}');
|
|
|
- formData.append('1_captcha_token', '{g_token}');
|
|
|
|
|
|
|
+ formData.append('1_captchaToken', '{g_token}');
|
|
|
formData.append('0', '[{{"status":"IDLE"}},"$K1"]');
|
|
formData.append('0', '[{{"status":"IDLE"}},"$K1"]');
|
|
|
|
|
|
|
|
const headers = {{
|
|
const headers = {{
|
|
|
- 'Next-Action': '{ACTION_ID}',
|
|
|
|
|
|
|
+ 'Next-Action': '{bookAppointment_ACTION_ID}',
|
|
|
'Next-Router-State-Tree': decodeURIComponent('{router_state}'),
|
|
'Next-Router-State-Tree': decodeURIComponent('{router_state}'),
|
|
|
'Accept': 'text/x-component'
|
|
'Accept': 'text/x-component'
|
|
|
}};
|
|
}};
|
|
|
|
|
|
|
|
- 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 }};
|
|
|
|
|
- }});
|
|
|
|
|
|
|
+ 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 }};
|
|
|
|
|
+ }});
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
- self._log("Submitting booking request via JS Fetch...")
|
|
|
|
|
- res_dict = self.page.run_js(js_script)
|
|
|
|
|
- resp = BrowserResponse(res_dict)
|
|
|
|
|
|
|
+ book_res_dict = self.page.run_js(bookAppointment_js_script)
|
|
|
|
|
+ resp = BrowserResponse(book_res_dict)
|
|
|
|
|
|
|
|
- # 4. 结果判定
|
|
|
|
|
|
|
+ # ---------------------------------------------------------
|
|
|
|
|
+ # 第五步:结果判定
|
|
|
|
|
+ # ---------------------------------------------------------
|
|
|
if resp.status_code == 303 or (resp.status_code == 200 and "appointment-confirmation" in resp.url):
|
|
if resp.status_code == 303 or (resp.status_code == 200 and "appointment-confirmation" in resp.url):
|
|
|
self._log(f"Booking Success! URL: {resp.url}")
|
|
self._log(f"Booking Success! URL: {resp.url}")
|
|
|
res.success = True
|
|
res.success = True
|
|
|
res.book_date = selected_date
|
|
res.book_date = selected_date
|
|
|
- res.book_time = selected_time
|
|
|
|
|
|
|
+ res.book_time = selected_time.time
|
|
|
return res
|
|
return res
|
|
|
|
|
|
|
|
if resp.status_code == 200:
|
|
if resp.status_code == 200:
|
|
@@ -605,8 +653,7 @@ class TlsPlugin(IVSPlg):
|
|
|
raise BizLogicError(f"Network Error: {resp.text}")
|
|
raise BizLogicError(f"Network Error: {resp.text}")
|
|
|
# TLS 业务错误
|
|
# 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:
|
|
|
"""
|
|
"""
|
|
|
主动刷新页面以触发 Cloudflare 挑战并尝试通过
|
|
主动刷新页面以触发 Cloudflare 挑战并尝试通过
|
|
@@ -652,14 +699,14 @@ class TlsPlugin(IVSPlg):
|
|
|
if params.get("action"):
|
|
if params.get("action"):
|
|
|
task["pageAction"] = params.get("action")
|
|
task["pageAction"] = params.get("action")
|
|
|
|
|
|
|
|
- if params.get("proxy"):
|
|
|
|
|
- p = urlparse(params.get("proxy"))
|
|
|
|
|
- task["proxyType"] = p.scheme
|
|
|
|
|
- task["proxyAddress"] = p.hostname
|
|
|
|
|
- task["proxyPort"] = p.port
|
|
|
|
|
- if p.username:
|
|
|
|
|
- task["proxyLogin"] = p.username
|
|
|
|
|
- task["proxyPassword"] = p.password
|
|
|
|
|
|
|
+ # if params.get("proxy"):
|
|
|
|
|
+ # p = urlparse(params.get("proxy"))
|
|
|
|
|
+ # task["proxyType"] = p.scheme
|
|
|
|
|
+ # task["proxyAddress"] = p.hostname
|
|
|
|
|
+ # task["proxyPort"] = p.port
|
|
|
|
|
+ # if p.username:
|
|
|
|
|
+ # task["proxyLogin"] = p.username
|
|
|
|
|
+ # task["proxyPassword"] = p.password
|
|
|
|
|
|
|
|
# 注意:使用 DrissionPage 后,通常是 ProxyLess 模式
|
|
# 注意:使用 DrissionPage 后,通常是 ProxyLess 模式
|
|
|
# 除非你想让 Capsolver 也用同样的代理(通常不需要,除非风控极严)
|
|
# 除非你想让 Capsolver 也用同样的代理(通常不需要,除非风控极严)
|