| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598 |
- import time
- import json
- import random
- import re
- import os
- import uuid
- import shutil
- import socket
- from datetime import date, datetime
- from typing import List, Dict, Optional, Any, Callable
- from urllib.parse import urljoin, urlparse, urlencode, parse_qs
- from concurrent.futures import ThreadPoolExecutor
- from DrissionPage import ChromiumPage, ChromiumOptions
- import configure
- from vs_plg import IVSPlg
- from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
- from toolkit.mihomo_tunnel import MihomoTunnel
- from utils.mouse import HumanMouse
- from utils.keyboard import HumanKeyboard
- from utils.fingerprint_utils import FingerprintGenerator
- from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
- class BrowserResponse:
- """模拟 requests.Response"""
- def __init__(self, result_dict):
- result_dict = result_dict or {}
- self.status_code = result_dict.get('status', 0)
- self.text = result_dict.get('body', '')
- self.headers = result_dict.get('headers', {})
- self.url = result_dict.get('url', '')
- self._json = None
- def json(self):
- if self._json is None:
- if not self.text:
- return {}
- try:
- self._json = json.loads(self.text)
- except:
- self._json = {}
- return self._json
- class UsaPlugin(IVSPlg):
-
- LOCATIONS = {
- "SHANGHAI": {"name": "SHANGHAI", "id": "096bf614-b0db-ec11-a7b4-001dd80234f6"},
- "WUHAN": {"name": "WUHAN", "id": "7b6af614-b0db-ec11-a7b4-001dd80234f6"},
- "SHENYANG": {"name": "SHENYANG", "id": "0f6bf614-b0db-ec11-a7b4-001dd80234f6"},
- }
- def __init__(self, group_id: str):
- self.group_id = group_id
- self.config: Optional[VSPlgConfig] = None
- self.free_config: Dict[str, Any] = {}
- self.is_healthy = True
- self.logger = None
-
- self.mouse = None
- self.keyboard = None
- self.page: Optional[ChromiumPage] = None
-
- 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.user_data_path = os.path.join(self.root_workspace, "user_data")
-
- if not os.path.exists(self.root_workspace):
- os.makedirs(self.root_workspace)
- self.tunnel = None
- self.session_create_time: float = 0
- def _log(self, message):
- if self.logger:
- self.logger(f'[UsaPlugin] [{self.group_id}] [{self.instance_id}] {message}')
- else:
- print(f'[UsaPlugin] [{self.group_id}] [{self.instance_id}] {message}')
- def _random_sleep(self, min_sec=30, max_sec=60):
- """
- 核心防限速控制:模拟人类阅读和操作的长时间停顿,确保网络请求间隔在30-60秒
- """
- sleep_time = random.uniform(min_sec, max_sec)
- self._log(f"Anti-Rate-Limit: Sleeping for {sleep_time:.2f} seconds...")
- time.sleep(sleep_time)
- def _get_free_port(self):
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
- s.bind(('', 0))
- return s.getsockname()[1]
- def set_config(self, config: VSPlgConfig):
- """设置 API 的配置信息"""
- self.config = config
- self.free_config = config.free_config or {}
- def set_log(self, logger: Callable[[str], None]) -> None:
- """设置日志输出工具"""
- self.logger = logger
-
- def keep_alive(self):
- pass
- def health_check(self) -> bool:
- if not self.is_healthy:
- return False
- if self.page is None:
- return False
- try:
- if not self.page.run_js("return 1;"):
- return False
- except:
- return False
-
- if self.config.session_max_life > 0:
- current_time = time.time()
- elapsed_time = current_time - self.session_create_time
- if elapsed_time > self.config.session_max_life:
- self._log(f"Session expired.")
- return False
- return True
-
- def _save_screenshot(self, name_prefix):
- try:
- timestamp = int(time.time())
- filename = f"{self.instance_id}_{name_prefix}_{timestamp}.jpg"
- save_path = os.path.join("data", filename)
- os.makedirs("data", exist_ok=True)
- self.page.get_screenshot(path=save_path, full_page=False)
- self._log(f"Screenshot saved to {save_path}")
- except Exception as e:
- self._log(f"Failed to save screenshot: {e}")
- def create_session(self) -> None:
- """创建一个新的会话 (包含初始化浏览器、过CF验证和执行登录)"""
-
- self._log(f"Initializing Session (ID: {self.instance_id})...")
- def get_free_port():
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
- s.bind(('', 0))
- return s.getsockname()[1]
-
- co = ChromiumOptions()
- debug_port = get_free_port()
- self._log(f"Assigned Debug Port: {debug_port}")
- self._log(f"Account id={self.config.account.id}, proxy id={self.config.proxy.id}")
-
- co.set_local_port(debug_port)
- co.set_user_data_path(self.user_data_path)
-
- chrome_path = configure.CHROME_PATH
- if not chrome_path:
- chrome_path = os.getenv("CHROME_BIN")
- if chrome_path and os.path.exists(chrome_path):
- co.set_paths(browser_path=chrome_path)
-
- if self.config.proxy and self.config.proxy.ip:
- p = self.config.proxy
- if p.username and p.password:
- self._log(f"Starting Proxy Tunnel for {p.ip}...")
- exit_node = {
- "name": "ExitNode",
- "type": p.proto,
- "server": p.ip,
- "port": p.port,
- "username": p.username,
- "password": p.password
- }
- relay_node = None
- if configure.MIHOMO_RELAY_NODES:
- relay_node = random.choice(configure.MIHOMO_RELAY_NODES)
- mihomo_path = configure.MIHOMO_BIN_PATH
- if not mihomo_path:
- mihomo_path = os.getenv("MIHOMO_BIN")
- if not mihomo_path:
- raise BizLogicError(message='Mihomo path is null, You need set mihomo bin path in configure or os env')
- self.tunnel = MihomoTunnel(mihomo_path, exit_node=exit_node, relay_node=relay_node)
- local_proxy = self.tunnel.start()
- self._log(f"Tunnel started at {local_proxy}")
- co.set_argument(f'--proxy-server={local_proxy}')
- else:
- proxy_str = f"{p.proto}://{p.ip}:{p.port}"
- co.set_argument(f'--proxy-server={proxy_str}')
- else:
- self._log("[WARN] No proxy configured!")
- specific_fp = FingerprintGenerator().generate(self.config.account.username)
- fp_seed = specific_fp.get("seed")
- fp_platform = specific_fp.get("platform")
- fp_brand = specific_fp.get("brand")
- self._log(f'browser fingerprint seed={fp_seed}')
-
- co.headless(False)
- co.set_argument('--no-sandbox')
- co.set_argument('--disable-dev-shm-usage')
- co.set_argument('--window-size=1920,1080')
- co.set_argument('--disable-blink-features=AutomationControlled')
- co.set_argument(f"--fingerprint={fp_seed}")
- co.set_argument(f"--fingerprint-platform={fp_platform}")
- co.set_argument(f"--fingerprint-brand={fp_brand}")
-
- self.page = ChromiumPage(co)
- # 获取基础 URL
- usa_url = self.free_config.get('usa_url', '')
- self._log(f"Navigating: {usa_url}")
- self.page.get(usa_url)
- self._random_sleep(3, 5)
-
- if 'Attention Required! | Cloudflare' in self.page.title and 'Sorry, you have been blocked' in self.page.html:
- self._log(f'Block by cloudflare, try refresh...')
- self.page.refresh()
- self.page.wait.doc_loaded()
-
- cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
- if not cf_bypasser.bypass(max_retry=6):
- raise BizLogicError("Cloudflare bypass timeout")
- self._random_sleep(3, 5)
- cf_bypasser.handle_waiting_room()
-
- self._log("Init humanize tools...")
- self.mouse = HumanMouse(self.page, debug=False)
- self.keyboard = HumanKeyboard(self.page)
-
- viewport_width = self.page.rect.viewport_size[0]
- viewport_height = self.page.rect.viewport_size[1]
- init_x = random.randint(10, viewport_width - 10)
- init_y = random.randint(10, viewport_height - 10)
- self.mouse.move(init_x, init_y)
-
- username = self.config.account.username
- password = self.config.account.password
- security = self.free_config.get('security', {})
- max_steps = 15 # 由于状态多,步数可以稍微调大一点
- stuck_counter = 0
- last_url = ""
- session_created = False
- has_submitted_login = False
-
- for step in range(max_steps):
- self.page.wait.doc_loaded()
- time.sleep(1) # 这个用于等待页面DOM渲染,保留短时,因为不是发请求
-
- current_url = self.page.url
- current_title = self.page.title.lower()
- current_html_content = self.page.html
- self._log(f"--- [Router Step {step+1}] Current URL: {current_url} ---")
-
- # --- [异常处理层] ---
- if current_url == last_url:
- stuck_counter += 1
- else:
- last_url = current_url
- stuck_counter = 0
- if stuck_counter >= 3:
- self._log("[WARN] Page stucked, try to refresh...")
- self.page.refresh()
- self._random_sleep(30, 40)
- stuck_counter = 0
- continue
-
- # 网络出现故障,直接重试
- server_error_indicators = ["502 Bad Gateway", "503 Service Temporarily Unavailable"]
- if any(err in current_html_content for err in server_error_indicators):
- self._log(f"[WARN] Server network error, try to refresh (Step: {step})...")
- self.page.refresh()
- self._random_sleep(30, 40)
- continue
-
- cloudflare_blocked_indicators = [
- "Sorry, you have been blocked" in current_html_content,
- "You are being rate limited" in current_html_content,
- "Cloudflare Ray ID" in current_html_content
- ]
- if any(cloudflare_blocked_indicators):
- raise BizLogicError(message="Blocked by Cloudflare WAF. Need to change IP or browser fingerprint.")
-
- # 遇到五秒盾先绕盾
- if "just a moment" in current_title:
- if not cf_bypasser.bypass(max_retry=3):
- continue
- self._random_sleep(3, 5)
- cf_bypasser.handle_waiting_room()
- continue
-
- if self.page.ele('#post_select', timeout=1):
- self._log("🎉 Successfully reached the Slot Search page (Target Page). Session created successfully!")
- self.session_create_time = time.time()
- session_created = True
- break
- # 状态 2: 密保问题页面
- elif self.page.ele('xpath://input[starts-with(@id, "kba") and contains(@id, "_response")]', timeout=1):
- self._log("[State] Security question verification detected. Filling in answers...")
- answer_eles = self.page.eles('xpath://input[starts-with(@id, "kba") and contains(@id, "_response")]')
- for ans_ele in answer_eles:
- ele_id = ans_ele.attr('id')
- match = re.search(r'kba(\d+)_response', ele_id)
- if match:
- q_num = match.group(1)
- config_key = f"{q_num}_quest"
- q_data = security.get(config_key)
- ans_text = q_data.get('a')
- self.mouse.human_click_ele(ans_ele)
- self.keyboard.type_text(ans_text, humanize=True)
- self._log(f"-> Find input {ele_id}, successfully filled in the answer for question {q_num}.")
-
- continue_btn = self.page.ele('#continue')
- self.mouse.human_click_ele(continue_btn)
- self._log("Security answers submitted. Waiting for redirection...")
- self._random_sleep(30, 40)
- continue
- # 状态 1: 登录页面
- elif self.page.ele('#signInName', timeout=1):
- self._log("[State] Login page detected. Submitting credentials...")
- username_selector = '#signInName'
- username_input = self.page.ele(username_selector)
- self.mouse.human_click_ele(username_input)
- username_input.clear()
- self.keyboard.type_text(username, humanize=True)
- self._random_sleep(3, 5)
-
- password_selector = '#password'
- password_input = self.page.ele(password_selector)
- self.mouse.human_click_ele(password_input)
- password_input.clear()
- self.keyboard.type_text(password, humanize=True)
- self._random_sleep(3, 5)
-
- continue_btn_selector = '#continue'
- continue_btn = self.page.ele(continue_btn_selector)
- self.mouse.human_click_ele(continue_btn)
- has_submitted_login = True
- self._log("Login form submitted. Waiting for the next step to load...")
- self._random_sleep(30, 40)
- continue
-
- elif self.page.ele('#continue_application', timeout=1):
- schedule_btn = self.page.ele('#continue_application', timeout=0.5)
- self._log("Currently in first-time booking mode, clicking to proceed...")
- self.mouse.human_click_ele(schedule_btn)
- self._random_sleep(30, 40)
-
- # 状态 3: 预约主页(控制台) -> 选择首签或改签
- elif self.page.ele('#atlas-sidebar', timeout=1):
- self._log("[State] At the main booking dashboard. Looking for navigation button...")
- reschedule_btn_selector = '#reschedule_appointment'
- schedule_btn_selector = '#schedule_appointment'
- reschedule_btn = self.page.ele(reschedule_btn_selector, timeout=0.5)
- if reschedule_btn:
- self._log("Currently in rescheduling mode, clicking to proceed...")
- self.mouse.human_click_ele(reschedule_btn)
- self._random_sleep(30, 40)
- else:
- schedule_btn = self.page.ele(schedule_btn_selector, timeout=0.5)
- if schedule_btn:
- self._log("Currently in first-time booking mode, clicking to proceed...")
- self.mouse.human_click_ele(schedule_btn)
- self._random_sleep(30, 40)
- else:
- self._log("Not found schedule or reschedule button. The page may still be loading...")
- time.sleep(2)
- continue
- else:
- self._log("[State] In unknown or transitional state. Waiting for next polling cycle...")
- 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}")
- def query(self, apt_type: AppointmentType) -> VSQueryResult:
- """查询可用的签证预约信息"""
- self._log("Querying available slots...")
- res = VSQueryResult()
- res.success = False
-
- self.page.refresh()
-
- if "just a moment" in self.page.title:
- cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
- if not cf_bypasser.bypass(max_retry=5):
- raise BizLogicError("Cloudflare bypass timeout")
- self._random_sleep(3, 5)
- cf_bypasser.handle_waiting_room()
-
- current_url = self.page.url.lower()
- if 'auth' in current_url or 'login' in current_url:
- self.is_healthy = False
- raise SessionExpiredOrInvalidError()
-
- applicant = self.free_config.get('applicant')
- location_name = self.free_config.get('location')
- location_id = self.LOCATIONS.get(location_name.upper(), {}).get('id')
- # 2. 等待页面元素
- self.page.ele(f"xpath://label[text()='{applicant}']", timeout=60)
- post_select = self.page.ele('#post_select', timeout=60)
-
- # ==================== 新增:网络监听逻辑 ====================
-
- # 开启监听目标 API
- target_api = 'get-family-consular-schedule-days'
- self.page.listen.start(target_api)
-
- # 3. 选择领事馆 (此操作会触发上述 API 的 AJAX 请求)
- self.page.ele(f"xpath://select[@id='post_select']/option[@value='{location_id}']", timeout=60)
- post_select.select.by_value(location_id)
-
- # 4. 等待拦截 API 响应 (设置超时时间)
- self._log("Waiting for schedule dates API response...")
- packet = self.page.listen.wait(timeout=30)
- self.page.listen.stop()
-
- if not packet:
- raise BizLogicError("Timeout waiting for schedule API response")
-
- status_code = packet.response.status
- raw_resp = packet.response.raw_body
- self._log(f"API Response Status: {status_code}")
-
- # 处理 HTTP 返回码不是200的情况
- if status_code != 200:
- if status_code == 403:
- raise PermissionDeniedError(f"HTTP 403: {raw_resp[:512]}")
- if status_code == 429:
- self.is_healthy = False
- raise RateLimiteddError(f"HTTP 429: {raw_resp[:512]}")
- raise BizLogicError(f"HTTP {status_code} error. resp={raw_resp[0:512]}")
- # 5. 解析返回的数据
- available_dates = []
- match = re.search(r'(\{.*\})', raw_resp, re.DOTALL)
- if match:
- json_str = match.group(1)
- data = json.loads(json_str)
- else:
- data = json.loads(raw_resp)
- schedule_days = data.get("ScheduleDays", [])
- if schedule_days:
- for day_obj in schedule_days:
- date_str = day_obj.get("Date")
- if date_str:
- available_dates.append(date_str)
- # 6. 处理最终结果 (保持与你原有返回结构一致)
- if available_dates:
- # 确保日期是有序的
- available_dates.sort()
-
- res.success = True
- res.availability_status = AvailabilityStatus.Available
- earliest_date = available_dates[0]
- res.earliest_date = datetime.strptime(earliest_date, "%Y-%m-%d")
- res.availability = [
- DateAvailability(date=datetime.strptime(d, "%Y-%m-%d"), times=[])
- for d in available_dates
- ]
- self._log(f"Slot Found! earliest_date={earliest_date}, size={len(available_dates)}")
- else:
- res.success = False
- res.availability_status = AvailabilityStatus.NoneAvailable
- self._log("No slots available.")
-
- return res
- def book(self, slot_info: VSQueryResult, user_inputs) -> VSBookResult:
- """进行预约操作"""
- res = VSBookResult()
- res.success = False
-
- exp_start = user_inputs.get('expected_date_start', '')
- exp_end = user_inputs.get('expected_date_end', '')
- available_dates_str =[
- da.date.strftime("%Y-%m-%d")
- for da in slot_info.availability if da.date
- ]
-
- valid_dates_list = self._filter_dates(available_dates_str, exp_start, exp_end)
- if not valid_dates_list:
- raise NotFoundError(message="No dates match user constraints")
-
- selected_slot_date = random.choice(valid_dates_list)
- book_date_obj = datetime.strptime(selected_slot_date, "%Y-%m-%d").date()
-
- # jQuery UI Datepicker 月份是 0-11
- target_day = str(book_date_obj.day)
- target_month = str(book_date_obj.month - 1)
- target_year = str(book_date_obj.year)
-
- self._log(f"Target booking date: {selected_slot_date}. Navigating calendar...")
-
- # 1. 适配不在当前日历的情况:通过下拉框选择年份和月份
- year_select = self.page.ele('.ui-datepicker-year', timeout=10) # 查找年份下拉框
- if year_select and year_select.value != target_year:
- self._log(f"Changing year to {target_year}")
- year_select.select.by_value(target_year)
- self._random_sleep(0.5, 1)
- month_select = self.page.ele('.ui-datepicker-month', timeout=10) # 查找月份下拉框
- if month_select and month_select.value != target_month:
- self._log(f"Changing month to {target_month}")
- month_select.select.by_value(target_month)
- self._random_sleep(0.5, 1)
- # 2. 点击目标日期
- self._log(f"Clicking date {selected_slot_date}...")
- target_cell_selector = f"xpath://td[@data-year='{target_year}' and @data-month='{target_month}']//a[text()='{target_day}']"
-
- target_date_cell = self.page.ele(target_cell_selector, timeout=10)
- if not target_date_cell:
- raise BizLogicError(f"Target date element not found for {selected_slot_date} after navigating.")
-
- self.mouse.human_click_ele(target_date_cell)
-
- self._log("Waiting for available times to load...")
- self._random_sleep(3, 5)
- # 3. 等待并选择时间
- self._log("Selecting earliest available time...")
- slot_time_selector = "css:#time_select input[name='schedule-entries']"
- first_time_radio = self.page.ele(slot_time_selector, timeout=15)
- if not first_time_radio:
- raise BizLogicError("Failed to load time slots after clicking date. Might be rate limited or slots gone.")
-
- selected_slot_time = first_time_radio.parent().text.strip()
- self.mouse.human_click_ele(first_time_radio)
-
- # 4. 点击提交
- self._random_sleep(1, 2)
- self._log("Submitting booking...")
- submit_appointment_selector = "#submitbtn"
- submit_button = self.page.ele(submit_appointment_selector, timeout=10)
- self.mouse.human_click_ele(submit_button)
- self._log("Booking submitted successfully!")
- # 构造返回结果
- res = VSBookResult()
- res.success = True
- res.book_date = selected_slot_date
- res.book_time = selected_slot_time
- res.account = self.config.account.username
- return res
-
- def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
- if not start_str or not end_str:
- return dates
- valid_dates = []
- s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
- e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
- for date_str in dates:
- curr_date = datetime.strptime(date_str, "%Y-%m-%d")
- if s_date <= curr_date <= e_date:
- valid_dates.append(date_str)
- random.shuffle(valid_dates)
- return valid_dates
-
- # --- 资源清理核心方法 ---
- def cleanup(self):
- """
- 销毁浏览器并彻底删除临时文件
- """
- if self.page:
- try:
- self.page.quit(force=True)
- except Exception:
- pass
- self.page = None
-
- if os.path.exists(self.root_workspace):
- for _ in range(3):
- try:
- time.sleep(0.2)
- shutil.rmtree(self.root_workspace, ignore_errors=True)
- break
- except Exception as e:
- self._log(f"Cleanup retry: {e}")
- time.sleep(0.5)
-
- if os.path.exists(self.root_workspace):
- self._log(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
- if self.tunnel:
- try: self.tunnel.stop()
- except: pass
- self.tunnel = None
-
- def __del__(self):
- """
- 析构函数:当对象被垃圾回收时自动调用
- """
- self.cleanup()
|