|
|
@@ -17,197 +17,6 @@ from urllib.parse import urlencode
|
|
|
from DrissionPage import ChromiumPage, ChromiumOptions
|
|
|
|
|
|
|
|
|
-class ProxyTunnel:
|
|
|
- """
|
|
|
- 【修复优化版】管理本地代理隧道
|
|
|
- 1. 启用 TCP_NODELAY 消除握手延迟 (关键修复)
|
|
|
- 2. 开启 KeepAlive 防止链路中断
|
|
|
- 3. 修复非阻塞模式下 sendall 导致的数据丢失问题
|
|
|
- """
|
|
|
- def __init__(self, upstream_ip, upstream_port, username, password):
|
|
|
- self.upstream_ip = upstream_ip
|
|
|
- self.upstream_port = int(upstream_port)
|
|
|
- self.username = username
|
|
|
- self.password = password
|
|
|
-
|
|
|
- # 预先计算 Proxy-Authorization 头
|
|
|
- auth_str = f"{username}:{password}"
|
|
|
- b64_auth = base64.b64encode(auth_str.encode()).decode()
|
|
|
- self.auth_header = f"Proxy-Authorization: Basic {b64_auth}\r\n"
|
|
|
-
|
|
|
- self.server_socket = None
|
|
|
- self.local_port = 0
|
|
|
- self.running = False
|
|
|
- self.listen_thread = None
|
|
|
-
|
|
|
- def start(self):
|
|
|
- try:
|
|
|
- self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
|
- self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
|
-
|
|
|
- self.server_socket.bind(('127.0.0.1', 0))
|
|
|
- self.local_port = self.server_socket.getsockname()[1]
|
|
|
- self.server_socket.listen(128) # 增加连接队列长度
|
|
|
-
|
|
|
- self.running = True
|
|
|
-
|
|
|
- self.listen_thread = threading.Thread(target=self._accept_loop, daemon=True)
|
|
|
- self.listen_thread.start()
|
|
|
-
|
|
|
- return f"127.0.0.1:{self.local_port}"
|
|
|
- except Exception as e:
|
|
|
- self.stop()
|
|
|
- raise RuntimeError(f"Failed to start tunnel: {e}")
|
|
|
-
|
|
|
- def stop(self):
|
|
|
- self.running = False
|
|
|
- if self.server_socket:
|
|
|
- try:
|
|
|
- self.server_socket.close()
|
|
|
- except Exception:
|
|
|
- pass
|
|
|
- self.server_socket = None
|
|
|
-
|
|
|
- def _accept_loop(self):
|
|
|
- while self.running:
|
|
|
- try:
|
|
|
- if self.server_socket:
|
|
|
- # 使用 select 替代 settimeout,减少 CPU 空转
|
|
|
- r, _, _ = select.select([self.server_socket], [], [], 1.0)
|
|
|
- if r:
|
|
|
- try:
|
|
|
- client_sock, _ = self.server_socket.accept()
|
|
|
- t = threading.Thread(target=self._handle_client, args=(client_sock,), daemon=True)
|
|
|
- t.start()
|
|
|
- except OSError:
|
|
|
- break
|
|
|
- except Exception:
|
|
|
- continue
|
|
|
-
|
|
|
- def _optimize_socket(self, sock):
|
|
|
- """核心优化:设置 Socket 选项"""
|
|
|
- try:
|
|
|
- # 1. 禁用 Nagle 算法:数据包立即发送,不等待填满缓冲区
|
|
|
- # 这是解决 "HttpClient Timeout" 的关键
|
|
|
- sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
|
-
|
|
|
- # 2. 开启 KeepAlive:防止防火墙切断空闲连接
|
|
|
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
|
-
|
|
|
- # 3. 增大缓冲区 (Linux/Mac 可选,Windows 一般自动管理)
|
|
|
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 32*1024)
|
|
|
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 32*1024)
|
|
|
- except Exception:
|
|
|
- pass
|
|
|
-
|
|
|
- def _handle_client(self, client_sock):
|
|
|
- upstream_sock = None
|
|
|
- try:
|
|
|
- self._optimize_socket(client_sock)
|
|
|
- client_sock.settimeout(30)
|
|
|
-
|
|
|
- # 1. 读取首包 (32KB 缓冲区)
|
|
|
- try:
|
|
|
- first_packet = client_sock.recv(32768)
|
|
|
- except socket.timeout:
|
|
|
- return # 客户端连上但不发数据
|
|
|
-
|
|
|
- if not first_packet:
|
|
|
- return
|
|
|
-
|
|
|
- # 2. 连接上游
|
|
|
- upstream_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
|
- self._optimize_socket(upstream_sock) # 同样优化上游 Socket
|
|
|
-
|
|
|
- upstream_sock.settimeout(10) # 连接超时 10s
|
|
|
- upstream_sock.connect((self.upstream_ip, self.upstream_port))
|
|
|
-
|
|
|
- # 连接建立后,将超时设为 None (阻塞模式),交由 select 控制
|
|
|
- upstream_sock.settimeout(None)
|
|
|
- client_sock.settimeout(None)
|
|
|
-
|
|
|
- # 3. 注入 Header
|
|
|
- sep = b'\r\n'
|
|
|
- idx = first_packet.find(sep)
|
|
|
- if idx != -1:
|
|
|
- new_packet = first_packet[:idx+2] + self.auth_header.encode() + first_packet[idx+2:]
|
|
|
- else:
|
|
|
- new_packet = first_packet
|
|
|
-
|
|
|
- # 4. 发送首包 (阻塞式发送,保证数据完整)
|
|
|
- upstream_sock.sendall(new_packet)
|
|
|
-
|
|
|
- # 5. 双向转发
|
|
|
- self._pipe_sockets(client_sock, upstream_sock)
|
|
|
-
|
|
|
- except Exception:
|
|
|
- pass
|
|
|
- finally:
|
|
|
- self._close_socket(client_sock)
|
|
|
- self._close_socket(upstream_sock)
|
|
|
-
|
|
|
- def _pipe_sockets(self, sock1, sock2):
|
|
|
- """
|
|
|
- 修复后的转发逻辑:
|
|
|
- 保持 Socket 为阻塞模式,利用 select 监听可读状态。
|
|
|
- """
|
|
|
- sockets = [sock1, sock2]
|
|
|
- last_activity = time.time()
|
|
|
- IDLE_TIMEOUT = 120 # 延长空闲超时
|
|
|
-
|
|
|
- while self.running:
|
|
|
- try:
|
|
|
- # 监听可读事件
|
|
|
- r, _, x = select.select(sockets, [], sockets, 1.0)
|
|
|
-
|
|
|
- if x: break # Socket 异常
|
|
|
-
|
|
|
- if not r:
|
|
|
- if time.time() - last_activity > IDLE_TIMEOUT:
|
|
|
- break
|
|
|
- continue
|
|
|
-
|
|
|
- for s in r:
|
|
|
- try:
|
|
|
- # 尝试读取
|
|
|
- data = s.recv(32768)
|
|
|
- except ConnectionResetError:
|
|
|
- data = None
|
|
|
-
|
|
|
- if not data:
|
|
|
- return # 连接关闭
|
|
|
-
|
|
|
- # 确定发送目标
|
|
|
- target = sock2 if s is sock1 else sock1
|
|
|
-
|
|
|
- # 关键修改:使用阻塞式 sendall
|
|
|
- # 如果网络卡顿,线程会在这里暂停等待,而不是抛出错误或丢包
|
|
|
- try:
|
|
|
- target.sendall(data)
|
|
|
- except BrokenPipeError:
|
|
|
- return
|
|
|
-
|
|
|
- last_activity = time.time()
|
|
|
-
|
|
|
- except Exception:
|
|
|
- break
|
|
|
-
|
|
|
- def _close_socket(self, sock):
|
|
|
- """优雅关闭 Socket"""
|
|
|
- if sock:
|
|
|
- try:
|
|
|
- # 发送 FIN 包,通知对端数据发送完毕
|
|
|
- sock.shutdown(socket.SHUT_RDWR)
|
|
|
- except Exception:
|
|
|
- pass
|
|
|
- try:
|
|
|
- sock.close()
|
|
|
- except Exception:
|
|
|
- pass
|
|
|
-
|
|
|
- def __del__(self):
|
|
|
- self.stop()
|
|
|
-
|
|
|
class BrowserResponse:
|
|
|
"""模拟 requests.Response 的轻量级对象"""
|
|
|
def __init__(self, result_dict):
|
|
|
@@ -227,7 +36,6 @@ class TlsAutoBot:
|
|
|
self.workspace = os.path.abspath(os.path.join("data", f"tls_session_{self.instance_id}"))
|
|
|
self.page = None
|
|
|
self.travel_group = None
|
|
|
- self.tunnel = None
|
|
|
|
|
|
def _log(self, msg):
|
|
|
print(f"[TLS-Bot-{self.instance_id}] {msg}")
|
|
|
@@ -249,24 +57,9 @@ class TlsAutoBot:
|
|
|
# 2. 代理配置
|
|
|
proxy_cfg = self.config.get('proxy', {})
|
|
|
|
|
|
-
|
|
|
- if proxy_cfg.get("username") and proxy_cfg.get("password"):
|
|
|
- self._log(f"Starting Proxy Tunnel for {proxy_cfg.get('ip')}...")
|
|
|
-
|
|
|
- # 1. 启动本地隧道
|
|
|
- self.tunnel = ProxyTunnel(proxy_cfg.get('ip'), proxy_cfg.get('port'), proxy_cfg.get('username'), proxy_cfg.get('password'))
|
|
|
- local_proxy = self.tunnel.start()
|
|
|
-
|
|
|
- self._log(f"Tunnel started at {local_proxy}")
|
|
|
-
|
|
|
- # 2. Chrome 连接本地免密端口
|
|
|
- # 必须使用 --proxy-server 强制指定,绝对稳健
|
|
|
- co.set_argument(f'--proxy-server={local_proxy}')
|
|
|
-
|
|
|
- else:
|
|
|
- # 无密码代理,直接用
|
|
|
- proxy_str = f"{proxy_cfg.get('schema')}://{proxy_cfg.get('ip')}:{proxy_cfg.get('port')}"
|
|
|
- co.set_argument(f'--proxy-server={proxy_str}')
|
|
|
+ proxy_str = f"{proxy_cfg.get('schema')}://{proxy_cfg.get('ip')}:{proxy_cfg.get('port')}"
|
|
|
+ print(f'set proxy={proxy_str}')
|
|
|
+ co.set_argument(f'--proxy-server={proxy_str}')
|
|
|
|
|
|
# 3. 反爬配置
|
|
|
co.headless(False)
|
|
|
@@ -278,7 +71,7 @@ class TlsAutoBot:
|
|
|
|
|
|
self.page = ChromiumPage(co)
|
|
|
|
|
|
- def solve_captcha(self, page_url: str, task_type: str, site_key: str, use_proxy = False, action: str = None) -> str:
|
|
|
+ def solve_captcha(self, page_url: str, task_type: str, site_key: str, use_proxy = False, action: str = None, api_domain: str = None) -> str:
|
|
|
"""通用解决验证码 (同步 User-Agent 防止被盾识别为高风险)"""
|
|
|
capsolver_key = self.config.get('capsolver_key')
|
|
|
if not capsolver_key:
|
|
|
@@ -289,6 +82,10 @@ class TlsAutoBot:
|
|
|
"websiteURL": page_url,
|
|
|
"websiteKey": site_key,
|
|
|
}
|
|
|
+
|
|
|
+ if api_domain:
|
|
|
+ task["apiDomain"] = api_domain
|
|
|
+
|
|
|
if use_proxy:
|
|
|
proxy = self.config['proxy']
|
|
|
task["proxyType"] = proxy.get('scheme', 'http')
|
|
|
@@ -297,6 +94,7 @@ class TlsAutoBot:
|
|
|
if proxy.get('username'):
|
|
|
task["proxyLogin"] = proxy.get('username')
|
|
|
task["proxyPassword"] = proxy.get('password')
|
|
|
+
|
|
|
if action:
|
|
|
task["pageAction"] = action
|
|
|
|
|
|
@@ -425,57 +223,185 @@ class TlsAutoBot:
|
|
|
|
|
|
if not self.travel_group:
|
|
|
raise Exception(f"Travel Group not found for city: {target_city}")
|
|
|
+
|
|
|
+ formgroup_id = self.travel_group.get('formGroupId')
|
|
|
+ self._log(f"Waiting for group button to render: {formgroup_id}")
|
|
|
+
|
|
|
+ # 隐患修复:确保按钮渲染出来后再点,防止 JS 找不到元素
|
|
|
+ btn_selector = f'tag:button@@name=formGroupId@@value={formgroup_id}'
|
|
|
+ self.page.wait.ele_displayed(btn_selector, timeout=15)
|
|
|
+
|
|
|
+ self._log(f"Select group_id={formgroup_id} via JS...")
|
|
|
+ # 替代繁琐的 run_js,直接用内置的 by_js=True 触发
|
|
|
+ self.page.ele(btn_selector).click(by_js=True)
|
|
|
+
|
|
|
+ self._log("Waiting for service-level 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 Exception("Redirect to service-level Failed!")
|
|
|
+
|
|
|
+ # ==========================================
|
|
|
+ # 2. 点击进入 Appointment Booking
|
|
|
+ # ==========================================
|
|
|
+ self._log("Waiting for book-appointment button to render...")
|
|
|
+
|
|
|
+ # 隐患修复:同样必须等待 Continue 按钮渲染完成
|
|
|
+ self.page.wait.ele_displayed('#book-appointment-btn', timeout=15)
|
|
|
+
|
|
|
+ self._log("Clicking 'Continue' to appointment booking via JS...")
|
|
|
+ self.page.ele('#book-appointment-btn').click(by_js=True)
|
|
|
|
|
|
- self._log(f"Login Success! Target Group ID: {self.travel_group['formGroupId']}")
|
|
|
+ self._log("Waiting for appointment-booking 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 Exception("Redirect to appointment-booking Failed!")
|
|
|
+
|
|
|
+ self._log("Waiting for appointment-booking page to fully load...")
|
|
|
+ self.page.wait.load_start()
|
|
|
+ time.sleep(3)
|
|
|
+
|
|
|
+ self._log(f"✅ Login & Navigation Success! Target Group ID: {formgroup_id}")
|
|
|
|
|
|
def query_slots(self) -> list:
|
|
|
- """通过 JS Fetch 获取可用日期并解析"""
|
|
|
- self._log("Querying available slots...")
|
|
|
+ """根据当前 UI 状态自动判断路由,并使用高鲁棒性特征提取 Slot"""
|
|
|
group_num = self.travel_group['formGroupId']
|
|
|
apt_config = self.config['apt_config']
|
|
|
interest_month = self.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}
|
|
|
-
|
|
|
- # 组装完整的 query url
|
|
|
- query_url = f"{url}?{urlencode(params)}"
|
|
|
+ # 转换工具
|
|
|
+ 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
|
|
|
|
|
|
- js_script = f"""
|
|
|
- return fetch("{query_url}", {{ credentials: "include" }})
|
|
|
- .then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
|
|
|
- .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
|
|
|
- """
|
|
|
- res_dict = self.page.run_js(js_script)
|
|
|
- resp = BrowserResponse(res_dict)
|
|
|
+ slots = []
|
|
|
|
|
|
- if resp.status_code != 200:
|
|
|
- raise Exception(f"Query Failed: {resp.status_code} - {resp.text[:100]}")
|
|
|
+ # 获取选中的月份:通过 data-testid 找到当前月份按钮并提取文本
|
|
|
+ # 注意:这里改用 data-testid 寻找当前月份,更稳健
|
|
|
+ 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 ""
|
|
|
|
|
|
- # 解析正则 (保持原样)
|
|
|
- slots = []
|
|
|
- pattern = r'"availableAppointments\\":\s*(\[.*?\]),\\"showFlexiAppointment'
|
|
|
- match = re.search(pattern, resp.text, re.DOTALL)
|
|
|
- if match:
|
|
|
- json_str = match.group(1).replace(r'\"', '"')
|
|
|
- data = json.loads(json_str)
|
|
|
- for day in data:
|
|
|
- d_str = day.get('day')
|
|
|
- for s in day.get('slots', []):
|
|
|
- labels = s.get('labels', [])
|
|
|
- if not labels:
|
|
|
- continue # 空数组说明 unavailable
|
|
|
-
|
|
|
- lbl = ""
|
|
|
- if 'pta' in labels: lbl = 'pta'
|
|
|
- elif 'ptaw' in labels: lbl = 'ptaw'
|
|
|
- elif '' in labels: lbl = ''
|
|
|
+ is_on_target_month = (current_month_text.lower() == target_month_text.lower())
|
|
|
+
|
|
|
+ if not is_on_target_month:
|
|
|
+ # ==========================================
|
|
|
+ # 模式 A: 不在目标月份 (UI 路由 + HTML 强鲁棒性解析)
|
|
|
+ # ==========================================
|
|
|
+ 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...")
|
|
|
+
|
|
|
+ # 【高鲁棒性提取】
|
|
|
+ # 特征定义:寻找所有的“日期块”。
|
|
|
+ # 什么是一个日期块?它是一个 div,里面直接包含一个 p 标签(放日期),
|
|
|
+ # 且同时包含另一个 div,其内部有带有 'slot' 字样的按钮。
|
|
|
+ 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 = ''
|
|
|
+
|
|
|
slots.append({
|
|
|
- 'date': d_str,
|
|
|
- 'time': s.get('time'),
|
|
|
+ 'date': full_date,
|
|
|
+ 'time': time_str,
|
|
|
'label': lbl
|
|
|
})
|
|
|
+
|
|
|
+ else:
|
|
|
+ # ==========================================
|
|
|
+ # 模式 B: 已经在目标月份 (JS Fetch + 正则 JSON 解析)
|
|
|
+ # ==========================================
|
|
|
+ self._log(f"Already on '{target_month_text}'. Executing silent JS fetch...")
|
|
|
+
|
|
|
+ base_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
|
|
|
+ params = {'location': apt_config["code"], 'month': interest_month}
|
|
|
+ query_url = f"{base_url}?{urlencode(params)}"
|
|
|
+
|
|
|
+ js_script = f"""
|
|
|
+ return fetch("{query_url}", {{ credentials: "include" }})
|
|
|
+ .then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
|
|
|
+ .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
|
|
|
+ """
|
|
|
+ res_dict = self.page.run_js(js_script)
|
|
|
+ resp = BrowserResponse(res_dict)
|
|
|
+
|
|
|
+ if resp.status_code != 200:
|
|
|
+ raise Exception(f"Silent Query Failed: {resp.status_code}")
|
|
|
+
|
|
|
+ self._log("Extracting slots from JSON response...")
|
|
|
+ pattern = r'"availableAppointments\\":\s*(\[.*?\]),\\"showFlexiAppointment'
|
|
|
+ match = re.search(pattern, resp.text, re.DOTALL)
|
|
|
+
|
|
|
+ if match:
|
|
|
+ json_str = match.group(1).replace(r'\"', '"')
|
|
|
+ data = json.loads(json_str)
|
|
|
+ for day in data:
|
|
|
+ d_str = day.get('day')
|
|
|
+ for s in day.get('slots', []):
|
|
|
+ labels = s.get('labels', [])
|
|
|
+ if not labels: continue
|
|
|
+
|
|
|
+ lbl = ""
|
|
|
+ if 'pta' in labels: lbl = 'pta'
|
|
|
+ elif 'ptaw' in labels: lbl = 'ptaw'
|
|
|
+ elif '' in labels: lbl = ''
|
|
|
+
|
|
|
+ slots.append({
|
|
|
+ 'date': d_str,
|
|
|
+ 'time': s.get('time'),
|
|
|
+ 'label': lbl
|
|
|
+ })
|
|
|
+
|
|
|
+ self._log(f"Found {len(slots)} valid slots.")
|
|
|
return slots
|
|
|
|
|
|
def _filter_dates(self, available_dates: list, start_str: str, end_str: str) -> list:
|
|
|
@@ -489,7 +415,7 @@ class TlsAutoBot:
|
|
|
if s_date <= curr <= e_date:
|
|
|
valid.append(d)
|
|
|
return valid
|
|
|
-
|
|
|
+
|
|
|
def book(self, all_slots: list) -> bool:
|
|
|
"""执行预定流程"""
|
|
|
if not all_slots:
|
|
|
@@ -501,11 +427,10 @@ class TlsAutoBot:
|
|
|
exp_start = self.config.get('expected_start_date', '')
|
|
|
exp_end = self.config.get('expected_end_date', '')
|
|
|
|
|
|
- # 提取唯一的可用日期列表
|
|
|
unique_dates = list(set([s['date'] for s in all_slots]))
|
|
|
valid_dates = self._filter_dates(unique_dates, exp_start, exp_end)
|
|
|
|
|
|
- possible_slots = [
|
|
|
+ possible_slots =[
|
|
|
s for s in all_slots
|
|
|
if s['date'] in valid_dates and s['label'] in target_labels
|
|
|
]
|
|
|
@@ -524,100 +449,262 @@ class TlsAutoBot:
|
|
|
|
|
|
group_num = self.travel_group['formGroupId']
|
|
|
apt_config = self.config['apt_config']
|
|
|
-
|
|
|
- base_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
|
|
|
- # [关键修复] Next.js Action 必须带上正确的 Query 参数
|
|
|
- year, month, day = sel_date.split('-')
|
|
|
- formatted_month = f"{month}-{year}"
|
|
|
-
|
|
|
- full_url = f'{base_url}?location={apt_config["code"]}&month={formatted_month}'
|
|
|
-
|
|
|
+ current_url = self.page.url
|
|
|
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'
|
|
|
|
|
|
- # 3. 获取金额 (Basket Cost)
|
|
|
- self._log("Fetching basket cost...")
|
|
|
- cost_payload = [{"groupId": str(group_num), "lang": "en-us", "labels": [sel_label]}]
|
|
|
- cost_body_json = json.dumps(cost_payload)
|
|
|
-
|
|
|
- js_cost = f"""
|
|
|
- return fetch("{full_url}", {{
|
|
|
- method: 'POST',
|
|
|
- headers: {{
|
|
|
- 'Next-Action': '40124cc90acef520d4fd2daf60ad3c8e21fc2c11d8',
|
|
|
- 'Next-Router-State-Tree': '{router_state}',
|
|
|
- 'Accept': 'text/x-component',
|
|
|
- 'Content-Type': 'text/plain;charset=UTF-8'
|
|
|
- }},
|
|
|
- body: `{cost_body_json}`
|
|
|
- }}).then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
|
|
|
- .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
|
|
|
- """
|
|
|
- cost_res = BrowserResponse(self.page.run_js(js_cost))
|
|
|
- if cost_res.status_code != 200:
|
|
|
- self._log(f"Basket cost check failed: {cost_res.status_code}")
|
|
|
- return False
|
|
|
+ # 3. 获取金额 (Basket Cost)
|
|
|
+ # self._log("Fetching basket cost...")
|
|
|
+ # cost_payload =[{"groupId": str(group_num), "lang": "en-us", "labels": [sel_label]}]
|
|
|
+ # cost_body_json = json.dumps(cost_payload)
|
|
|
+
|
|
|
+ # js_cost = f"""
|
|
|
+ # return fetch("{current_url}", {{
|
|
|
+ # method: 'POST',
|
|
|
+ # headers: {{
|
|
|
+ # 'Next-Action': '40124cc90acef520d4fd2daf60ad3c8e21fc2c11d8',
|
|
|
+ # 'Next-Router-State-Tree': '{router_state}',
|
|
|
+ # 'Accept': 'text/x-component',
|
|
|
+ # 'Content-Type': 'text/plain;charset=UTF-8'
|
|
|
+ # }},
|
|
|
+ # body: `{cost_body_json}`
|
|
|
+ # }}).then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
|
|
|
+ # .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
|
|
|
+ # """
|
|
|
+ # cost_res = BrowserResponse(self.page.run_js(js_cost))
|
|
|
+ # if cost_res.status_code != 200:
|
|
|
+ # self._log(f"Basket cost check failed: {cost_res.status_code}")
|
|
|
+ # return False
|
|
|
|
|
|
# 4. 解决 ReCaptcha V3
|
|
|
- self._log("Solving Booking ReCaptcha V3...")
|
|
|
- g_token = self.solve_captcha(
|
|
|
- page_url=full_url,
|
|
|
- task_type="ReCaptchaV3Task",
|
|
|
- site_key="6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
|
|
|
- use_proxy=True,
|
|
|
- action="book"
|
|
|
- )
|
|
|
-
|
|
|
- # 5. 提交 Booking
|
|
|
- self._log("Submitting final booking request...")
|
|
|
- js_book = f"""
|
|
|
- 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', '{sel_date}');
|
|
|
- formData.append('1_time', '{sel_time}');
|
|
|
- formData.append('1_appointmentLabel', '{sel_label}');
|
|
|
- formData.append('1_captchaToken', '{g_token}');
|
|
|
- formData.append('0', '[{{"status":"IDLE"}},"$K1"]');
|
|
|
-
|
|
|
- return fetch("{full_url}", {{
|
|
|
- method: 'POST',
|
|
|
- headers: {{
|
|
|
- 'Next-Action': '6043cfd107081bc817cbb11a8c0db17d3a063401be',
|
|
|
- 'Next-Router-State-Tree': '{router_state}',
|
|
|
- 'Accept': 'text/x-component'
|
|
|
- }},
|
|
|
- body: formData
|
|
|
- }}).then(async r => {{
|
|
|
- const hdrs = {{}};
|
|
|
- r.headers.forEach((v, k) => hdrs[k] = v);
|
|
|
- return {{ status: r.status, body: await r.text(), headers: hdrs, url: r.url }};
|
|
|
- }}).catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
|
|
|
+ # self._log("Solving Booking ReCaptcha V3...")
|
|
|
+ # g_token = self.solve_captcha(
|
|
|
+ # page_url=current_url,
|
|
|
+ # task_type="ReCaptchaV3TaskProxyLess",
|
|
|
+ # site_key="6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
|
|
|
+ # use_proxy=False,
|
|
|
+ # action="book",
|
|
|
+ # api_domain="recaptcha.net"
|
|
|
+ # )
|
|
|
+
|
|
|
+ # 5. 注入 Hook 并点击表单
|
|
|
+ # self._log("Injecting reCAPTCHA hook and modifying form...")
|
|
|
+
|
|
|
+ # # 5.1 注入你提供的劫持 JS
|
|
|
+ # hook_js = f"""
|
|
|
+ # // 1. 填充可能存在的标准隐藏域
|
|
|
+ # var input = document.getElementById('g-recaptcha-response');
|
|
|
+ # if(input) {{
|
|
|
+ # input.value = "{g_token}";
|
|
|
+ # // 派发React识别的事件
|
|
|
+ # input.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
|
|
+ # input.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
|
|
+ # }}
|
|
|
+
|
|
|
+ # // 2. 劫持 grecaptcha.execute
|
|
|
+ # var mockExecute = function() {{
|
|
|
+ # console.log("[Hook] Recaptcha execution intercepted!");
|
|
|
+ # return Promise.resolve("{g_token}");
|
|
|
+ # }};
|
|
|
+
|
|
|
+ # // 无论网页是否已加载完毕,保证对象存在并被劫持
|
|
|
+ # if (!window.grecaptcha) {{
|
|
|
+ # window.grecaptcha = {{}};
|
|
|
+ # }}
|
|
|
+ # window.grecaptcha.execute = mockExecute;
|
|
|
+
|
|
|
+ # if (!window.grecaptcha.enterprise) {{
|
|
|
+ # window.grecaptcha.enterprise = {{}};
|
|
|
+ # }}
|
|
|
+ # window.grecaptcha.enterprise.execute = mockExecute;
|
|
|
+ # """
|
|
|
+ # self.page.run_js(hook_js)
|
|
|
+
|
|
|
+ # 5.2 注入表单数据并原样点击
|
|
|
+ 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"]'), '2026-06-01');
|
|
|
+ setReactValue(form.querySelector('input[name="time"]'), '12:00');
|
|
|
+ setReactValue(form.querySelector('input[name="appointmentLabel"]'), '{sel_label}');
|
|
|
+
|
|
|
+ // 解禁并点击 Submit 按钮
|
|
|
+ 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();
|
|
|
+ }}
|
|
|
"""
|
|
|
|
|
|
- book_res_dict = self.page.run_js(js_book)
|
|
|
- book_resp = BrowserResponse(book_res_dict)
|
|
|
+ inject_res = self.page.run_js(js_inject_and_click)
|
|
|
+ self._log(f"Form submission triggered: {inject_res}")
|
|
|
+
|
|
|
+ if inject_res != 'clicked':
|
|
|
+ self._log("❌ Failed to inject form or click the submit button.")
|
|
|
+ return False
|
|
|
+
|
|
|
+ # 6. 验证是否抢单成功 (轮询页面跳转)
|
|
|
+ self._log("Waiting for Next.js to process the form submission...")
|
|
|
+
|
|
|
+ for _ in range(15):
|
|
|
+ time.sleep(1.0)
|
|
|
+ current_page_url = self.page.url
|
|
|
+
|
|
|
+ # Next.js 跳转进入确认页
|
|
|
+ if "appointment-confirmation" in current_page_url:
|
|
|
+ self._log(f"✅ BOOKING SUCCESS! Redirected to: {current_page_url}")
|
|
|
+ return True
|
|
|
+
|
|
|
+ try:
|
|
|
+ body_text = str(self.page.run_js("return document.body.innerText || '';"))
|
|
|
+ if "APPOINTMENT_LIMIT_REACHED" in body_text or "appointment limit" in body_text.lower():
|
|
|
+ self._log("❌ BOOKING FAILED! Reason: Appointment Limit Reached")
|
|
|
+ return False
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+ self._log("❌ BOOKING FAILED! Timeout waiting for redirect confirmation.")
|
|
|
+ return False
|
|
|
+
|
|
|
+ # def book(self, all_slots: list) -> bool:
|
|
|
+ # """执行预定流程"""
|
|
|
+ # if not all_slots:
|
|
|
+ # self._log("No slots provided to book.")
|
|
|
+ # return False
|
|
|
+
|
|
|
+ # # 1. 过滤日期 & 筛选标签
|
|
|
+ # target_labels = self.config.get('target_labels', [''])
|
|
|
+ # exp_start = self.config.get('expected_start_date', '')
|
|
|
+ # exp_end = self.config.get('expected_end_date', '')
|
|
|
+
|
|
|
+ # # 提取唯一的可用日期列表
|
|
|
+ # unique_dates = list(set([s['date'] for s in all_slots]))
|
|
|
+ # valid_dates = self._filter_dates(unique_dates, exp_start, exp_end)
|
|
|
+
|
|
|
+ # possible_slots = [
|
|
|
+ # s for s in all_slots
|
|
|
+ # if s['date'] in valid_dates and s['label'] in target_labels
|
|
|
+ # ]
|
|
|
+
|
|
|
+ # if not possible_slots:
|
|
|
+ # self._log("No slots match target dates and labels.")
|
|
|
+ # return False
|
|
|
+
|
|
|
+ # # 2. 随机选择一个 Slot
|
|
|
+ # selected = random.choice(possible_slots)
|
|
|
+ # sel_date = selected['date']
|
|
|
+ # sel_time = selected['time']
|
|
|
+ # sel_label = selected['label']
|
|
|
|
|
|
- # 6. 解析结果 (判定 Next.js 跳转)
|
|
|
- headers_lower = {str(k).lower(): v for k, v in book_resp.headers.items()}
|
|
|
- action_redirect = headers_lower.get('x-action-redirect', '')
|
|
|
+ # self._log(f"Selected Slot -> Date: {sel_date}, Time: {sel_time}, Label: {sel_label or 'standard'}")
|
|
|
|
|
|
- is_success = (
|
|
|
- book_resp.status_code == 303 or
|
|
|
- (book_resp.status_code == 200 and ("appointment-confirmation" in action_redirect or "appointment-confirmation" in book_resp.url))
|
|
|
- )
|
|
|
+ # group_num = self.travel_group['formGroupId']
|
|
|
+ # apt_config = self.config['apt_config']
|
|
|
+
|
|
|
+ # current_url = self.page.url
|
|
|
+ # 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'
|
|
|
|
|
|
- if is_success:
|
|
|
- self._log(f"✅ BOOKING SUCCESS! Redirected to: {action_redirect or book_resp.url}")
|
|
|
- return True
|
|
|
- else:
|
|
|
- self._log(f"❌ BOOKING FAILED! Status: {book_resp.status_code}")
|
|
|
- if "APPOINTMENT_LIMIT_REACHED" in book_resp.text:
|
|
|
- self._log("-> Reason: Appointment Limit Reached")
|
|
|
- else:
|
|
|
- self._log(f"-> Response Body: {book_resp.text[:300]}")
|
|
|
- return False
|
|
|
+ # # 3. 获取金额 (Basket Cost)
|
|
|
+ # self._log("Fetching basket cost...")
|
|
|
+ # cost_payload = [{"groupId": str(group_num), "lang": "en-us", "labels": [sel_label]}]
|
|
|
+ # cost_body_json = json.dumps(cost_payload)
|
|
|
+
|
|
|
+ # js_cost = f"""
|
|
|
+ # return fetch("{current_url}", {{
|
|
|
+ # method: 'POST',
|
|
|
+ # headers: {{
|
|
|
+ # 'Next-Action': '40124cc90acef520d4fd2daf60ad3c8e21fc2c11d8',
|
|
|
+ # 'Next-Router-State-Tree': '{router_state}',
|
|
|
+ # 'Accept': 'text/x-component',
|
|
|
+ # 'Content-Type': 'text/plain;charset=UTF-8'
|
|
|
+ # }},
|
|
|
+ # body: `{cost_body_json}`
|
|
|
+ # }}).then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
|
|
|
+ # .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
|
|
|
+ # """
|
|
|
+ # cost_res = BrowserResponse(self.page.run_js(js_cost))
|
|
|
+ # if cost_res.status_code != 200:
|
|
|
+ # self._log(f"Basket cost check failed: {cost_res.status_code}")
|
|
|
+ # return False
|
|
|
+
|
|
|
+ # # 4. 解决 ReCaptcha V3
|
|
|
+ # self._log("Solving Booking ReCaptcha V3...")
|
|
|
+ # g_token = self.solve_captcha(
|
|
|
+ # page_url=current_url,
|
|
|
+ # task_type="ReCaptchaV3M1TaskProxyLess",
|
|
|
+ # site_key="6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
|
|
|
+ # use_proxy=False,
|
|
|
+ # action="book",
|
|
|
+ # api_domain="recaptcha.net"
|
|
|
+ # )
|
|
|
+
|
|
|
+ # # 5. 提交 Booking
|
|
|
+ # self._log("Submitting final booking request...")
|
|
|
+ # js_book = f"""
|
|
|
+ # 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', '{sel_date}');
|
|
|
+ # formData.append('1_time', '{sel_time}');
|
|
|
+ # formData.append('1_appointmentLabel', '{sel_label}');
|
|
|
+ # formData.append('1_captchaToken', '{g_token}');
|
|
|
+ # formData.append('0', '[{{"status":"IDLE"}},"$K1"]');
|
|
|
+
|
|
|
+ # return fetch("{current_url}", {{
|
|
|
+ # method: 'POST',
|
|
|
+ # headers: {{
|
|
|
+ # 'Next-Action': '6043cfd107081bc817cbb11a8c0db17d3a063401be',
|
|
|
+ # 'Next-Router-State-Tree': '{router_state}',
|
|
|
+ # 'Accept': 'text/x-component'
|
|
|
+ # }},
|
|
|
+ # body: formData
|
|
|
+ # }}).then(async r => {{
|
|
|
+ # const hdrs = {{}};
|
|
|
+ # r.headers.forEach((v, k) => hdrs[k] = v);
|
|
|
+ # return {{ status: r.status, body: await r.text(), headers: hdrs, url: r.url }};
|
|
|
+ # }}).catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
|
|
|
+ # """
|
|
|
+
|
|
|
+ # book_res_dict = self.page.run_js(js_book)
|
|
|
+ # book_resp = BrowserResponse(book_res_dict)
|
|
|
+
|
|
|
+ # # 6. 解析结果 (判定 Next.js 跳转)
|
|
|
+ # headers_lower = {str(k).lower(): v for k, v in book_resp.headers.items()}
|
|
|
+ # action_redirect = headers_lower.get('x-action-redirect', '')
|
|
|
+
|
|
|
+ # is_success = (
|
|
|
+ # book_resp.status_code == 303 or
|
|
|
+ # (book_resp.status_code == 200 and ("appointment-confirmation" in action_redirect or "appointment-confirmation" in book_resp.url))
|
|
|
+ # )
|
|
|
+
|
|
|
+ # if is_success:
|
|
|
+ # self._log(f"✅ BOOKING SUCCESS! Redirected to: {action_redirect or book_resp.url}")
|
|
|
+ # return True
|
|
|
+ # else:
|
|
|
+ # self._log(f"❌ BOOKING FAILED! Status: {book_resp.status_code}")
|
|
|
+ # if "APPOINTMENT_LIMIT_REACHED" in book_resp.text:
|
|
|
+ # self._log("-> Reason: Appointment Limit Reached")
|
|
|
+ # else:
|
|
|
+ # self._log(f"-> Response Body: {book_resp.text[:300]}")
|
|
|
+ # return False
|
|
|
|
|
|
def cleanup(self):
|
|
|
self._log("Cleaning up resources...")
|
|
|
@@ -637,7 +724,7 @@ if __name__ == "__main__":
|
|
|
MY_CONFIG = {
|
|
|
# 账号信息
|
|
|
"account": {
|
|
|
- "username": "zhangsan06@gmail-app.com",
|
|
|
+ "username": "mayun06@gmail-app.com",
|
|
|
"password": "Visafly@111"
|
|
|
},
|
|
|
# 目标签证中心信息 (例如广州 TLS: cnCNG2fr)
|
|
|
@@ -648,11 +735,11 @@ if __name__ == "__main__":
|
|
|
},
|
|
|
# 代理配置
|
|
|
"proxy": {
|
|
|
- "scheme": "http",
|
|
|
- "ip": "95.135.130.10",
|
|
|
- "port": "46107",
|
|
|
- "username": "Iz1WuKKwt1KUzEe",
|
|
|
- "password": "G7syngmdyGURblY"
|
|
|
+ "schema": "http",
|
|
|
+ "ip": "127.0.0.1",
|
|
|
+ "port": "7890",
|
|
|
+ "username": "",
|
|
|
+ "password": ""
|
|
|
},
|
|
|
# Capsolver API Key
|
|
|
"capsolver_key": "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A",
|
|
|
@@ -665,7 +752,7 @@ if __name__ == "__main__":
|
|
|
"expected_end_date": "2026-06-30",
|
|
|
|
|
|
# 目标标签: '' 是普通号, 'pta' 是 Prime 黄金时间号
|
|
|
- "target_labels": ["", "pta"]
|
|
|
+ "target_labels": [""]
|
|
|
}
|
|
|
|
|
|
bot = TlsAutoBot(config=MY_CONFIG)
|