|
|
@@ -5,6 +5,8 @@ import time
|
|
|
import json
|
|
|
import random
|
|
|
import base64
|
|
|
+import uuid
|
|
|
+import shutil
|
|
|
import re
|
|
|
import urllib.parse
|
|
|
from datetime import datetime
|
|
|
@@ -21,7 +23,9 @@ from cryptography.hazmat.backends import default_backend
|
|
|
|
|
|
from vs_plg import IVSPlg
|
|
|
from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, DateAvailability, AvailabilityStatus, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
|
|
|
-from toolkit.vs_cloud_api import VSCloudApi
|
|
|
+from toolkit.vs_cloud_api import VSCloudApi
|
|
|
+from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
|
|
|
+
|
|
|
|
|
|
# ----------------- 静态常量与辅助数据 -----------------
|
|
|
|
|
|
@@ -35,11 +39,36 @@ t92towriKoH75BhiazY0mghm4LjmAWrV0u/GNpV3tk9bxbtHEXGaFmxCJqjg+7x6
|
|
|
GQIDAQAB
|
|
|
-----END PUBLIC KEY-----"""
|
|
|
|
|
|
-# (Country Map 省略以节省篇幅,请保持原样)
|
|
|
COUNTRY_MAP = {
|
|
|
"afghanistan": "AFG", "albania": "ALB", "algeria": "DZA", "andorra": "AND", "angola": "AGO",
|
|
|
- "china": "CHN", "united kingdom": "GBR", "netherlands": "NLD",
|
|
|
- # ... 请保留你原来的完整映射 ...
|
|
|
+ "antigua and barbuda": "ATG", "argentina": "ARG", "armenia": "ARM", "australia": "AUS", "austria": "AUT",
|
|
|
+ "azerbaijan": "AZE", "bahamas": "BHS", "bahrain": "BHR", "bangladesh": "BGD", "barbados": "BRB", "belarus": "BLR",
|
|
|
+ "belgium": "BEL", "belize": "BLZ", "benin": "BEN", "bhutan": "BTN", "bolivia": "BOL", "bosnia and herzegovina": "BIH",
|
|
|
+ "botswana": "BWA", "brazil": "BRA", "brunei": "BRN", "bulgaria": "BGR", "burkina faso": "BFA", "burundi": "BDI",
|
|
|
+ "cabo verde": "CPV", "cambodia": "KHM", "cameroon": "CMR", "canada": "CAN", "central african republic": "CAF",
|
|
|
+ "chad": "TCD", "chile": "CHL", "china": "CHN", "colombia": "COL", "comoros": "COM", "congo (brazzaville)": "COG",
|
|
|
+ "congo (kinshasa)": "COD", "costa rica": "CRI", "croatia": "HRV", "cuba": "CUB", "cyprus": "CYP", "czech republic": "CZE",
|
|
|
+ "denmark": "DNK", "djibouti": "DJI", "dominica": "DMA", "dominican republic": "DOM", "ecuador": "ECU", "egypt": "EGY",
|
|
|
+ "el salvador": "SLV", "equatorial guinea": "GNQ", "eritrea": "ERI", "estonia": "EST", "eswatini": "SWZ", "ethiopia": "ETH",
|
|
|
+ "fiji": "FJI", "finland": "FIN", "france": "FRA", "gabon": "GAB", "gambia": "GMB", "georgia": "GEO", "germany": "DEU",
|
|
|
+ "ghana": "GHA", "greece": "GRC", "grenada": "GRD", "guatemala": "GTM", "guinea": "GIN", "guinea-bissau": "GNB", "guyana": "GUY",
|
|
|
+ "haiti": "HTI", "honduras": "HND", "hungary": "HUN", "iceland": "ISL", "india": "IND", "indonesia": "IDN", "iran": "IRN",
|
|
|
+ "iraq": "IRQ", "ireland": "IRL", "israel": "ISR", "italy": "ITA", "jamaica": "JAM", "japan": "JPN", "jordan": "JOR",
|
|
|
+ "kazakhstan": "KAZ", "kenya": "KEN", "kiribati": "KIR", "korea, north": "PRK", "korea, south": "KOR", "kuwait": "KWT",
|
|
|
+ "kyrgyzstan": "KGZ", "laos": "LAO", "latvia": "LVA", "lebanon": "LBN", "lesotho": "LSO", "liberia": "LBR", "libya": "LBY",
|
|
|
+ "liechtenstein": "LIE", "lithuania": "LTU", "luxembourg": "LUX", "madagascar": "MDG", "malawi": "MWI", "malaysia": "MYS",
|
|
|
+ "maldives": "MDV", "mali": "MLI", "malta": "MLT", "marshall islands": "MHL", "mauritania": "MRT", "mauritius": "MUS",
|
|
|
+ "mexico": "MEX", "micronesia": "FSM", "moldova": "MDA", "monaco": "MCO", "mongolia": "MNG", "montenegro": "MNE", "morocco": "MAR",
|
|
|
+ "mozambique": "MOZ", "myanmar": "MMR", "namibia": "NAM", "nauru": "NRU", "nepal": "NPL", "netherlands": "NLD", "new zealand": "NZL",
|
|
|
+ "nicaragua": "NIC", "niger": "NER", "nigeria": "NGA", "north macedonia": "MKD", "norway": "NOR", "oman": "OMN", "pakistan": "PAK",
|
|
|
+ "palau": "PLW", "panama": "PAN", "papua new guinea": "PNG", "paraguay": "PRY", "peru": "PER", "philippines": "PHL", "poland": "POL",
|
|
|
+ "portugal": "PRT", "qatar": "QAT", "romania": "ROU", "russia": "RUS", "rwanda": "RWA", "saudi arabia": "SAU", "senegal": "SEN",
|
|
|
+ "serbia": "SRB", "seychelles": "SYC", "sierra leone": "SLE", "singapore": "SGP", "slovakia": "SVK", "slovenia": "SVN",
|
|
|
+ "solomon islands": "SLB", "somalia": "SOM", "south africa": "ZAF", "spain": "ESP", "sri lanka": "LKA", "sudan": "SDN",
|
|
|
+ "suriname": "SUR", "sweden": "SWE", "switzerland": "CHE", "syria": "SYR", "tajikistan": "TJK", "tanzania": "TZA", "thailand": "THA",
|
|
|
+ "timor-leste": "TLS", "togo": "TGO", "tonga": "TON", "tunisia": "TUN", "turkey": "TUR", "turkmenistan": "TKM", "uganda": "UGA",
|
|
|
+ "ukraine": "UKR", "united arab emirates": "ARE", "united kingdom": "GBR", "united states": "USA", "uruguay": "URY", "uzbekistan": "UZB",
|
|
|
+ "vanuatu": "VUT", "venezuela": "VEN", "vietnam": "VNM", "yemen": "YEM", "zambia": "ZMB", "zimbabwe": "ZWE"
|
|
|
}
|
|
|
|
|
|
def get_country_iso3(name: str) -> str:
|
|
|
@@ -52,7 +81,7 @@ def to_yyyymmdd(data_str: str, date_str_format: str, target_format: str="%Y-%m-%
|
|
|
except:
|
|
|
return data_str
|
|
|
|
|
|
-def create_proxy_auth_extension(ip, port, username, password, plugin_path="./chrome_proxy_auth_plugin"):
|
|
|
+def create_proxy_auth_extension(ip, port, username, password, plugin_path):
|
|
|
"""
|
|
|
创建一个 Chrome 插件来自动处理代理认证
|
|
|
"""
|
|
|
@@ -167,6 +196,19 @@ class VfsPlugin2(IVSPlg):
|
|
|
VFS_PUBLIC_KEY_PEM.encode(),
|
|
|
backend=default_backend()
|
|
|
)
|
|
|
+
|
|
|
+ # --- [核心修改] 并发隔离与资源管理 ---
|
|
|
+ # 生成唯一实例 ID
|
|
|
+ self.instance_id = uuid.uuid4().hex[:8]
|
|
|
+ self.root_workspace = os.path.abspath(os.path.join("temp_browser_data", f"{self.group_id}_{self.instance_id}"))
|
|
|
+ # 定义子目录:代理插件目录 & 浏览器用户数据目录
|
|
|
+ self.proxy_ext_path = os.path.join(self.root_workspace, "proxy_ext")
|
|
|
+ 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.session_create_time: float = 0
|
|
|
|
|
|
def get_group_id(self) -> str:
|
|
|
@@ -210,49 +252,45 @@ class VfsPlugin2(IVSPlg):
|
|
|
使用 DrissionPage 创建会话:
|
|
|
1. 启动浏览器
|
|
|
2. 导航到登录页
|
|
|
- 3. 自动过盾并提取 Token
|
|
|
+ 3. 自动过盾并提取 Token (集成 CloudflareBypasser)
|
|
|
4. JS fetch 登录
|
|
|
"""
|
|
|
- self._log("Initializing Browser Session...")
|
|
|
+ self._log(f"Initializing Session (ID: {self.instance_id})...")
|
|
|
|
|
|
# 0. 配置浏览器
|
|
|
co = ChromiumOptions()
|
|
|
- co.auto_port() # 自动分配端口
|
|
|
+ co.auto_port()
|
|
|
+
|
|
|
+ # --- [关键配置] 设置独立的用户数据目录 ---
|
|
|
+ # 这样每个实例的 Cache, Cookies, LocalStorage 都是完全隔离的
|
|
|
+ # 同时也防止了多进程争抢同一个 Default 文件夹导致的崩溃
|
|
|
+ co.set_user_data_path(self.user_data_path)
|
|
|
|
|
|
+ # 代理配置
|
|
|
if self.config.proxy and self.config.proxy.ip:
|
|
|
p = self.config.proxy
|
|
|
-
|
|
|
- # 情况 A: 有账号密码 -> 使用插件方案
|
|
|
- if p.username and p.password:
|
|
|
- self._log(f"Configuring authenticated proxy: {p.ip}:{p.port}")
|
|
|
- plugin_path = create_proxy_auth_extension(
|
|
|
- ip=p.ip,
|
|
|
- port=p.port,
|
|
|
- username=p.username,
|
|
|
- password=p.password
|
|
|
- )
|
|
|
- co.add_extension(plugin_path)
|
|
|
-
|
|
|
- # 情况 B: 无账号密码 (IP白名单模式) -> 直接设置
|
|
|
- else:
|
|
|
- self._log(f"Configuring standard proxy: {p.ip}:{p.port}")
|
|
|
- co.set_proxy(f"{p.scheme}://{p.ip}:{p.port}")
|
|
|
+ if p.username and p.password:
|
|
|
+ self._log(f"Configuring authenticated proxy: {p.ip}:{p.port}")
|
|
|
+ # [关键调用] 生成该实例独享的插件
|
|
|
+ plugin_path = create_proxy_auth_extension(
|
|
|
+ p.ip, p.port, p.username, p.password,
|
|
|
+ self.proxy_ext_path # 传入唯一路径
|
|
|
+ )
|
|
|
+ co.add_extension(plugin_path)
|
|
|
+ else:
|
|
|
+ self._log(f"Configuring standard proxy: {p.ip}:{p.port}")
|
|
|
+ co.set_proxy(f"{p.scheme}://{p.ip}:{p.port}")
|
|
|
|
|
|
- # 无头模式 (生产环境建议 True, 调试 False)
|
|
|
- # co.headless(True)
|
|
|
- co.headless(False) # 调试时设为 False 方便观察
|
|
|
-
|
|
|
- # 反爬参数
|
|
|
+ co.headless(False)
|
|
|
co.set_argument('--no-sandbox')
|
|
|
co.set_argument('--disable-gpu')
|
|
|
co.set_argument('--window-size=1920,1080')
|
|
|
- # 禁用自动化特征
|
|
|
co.set_argument('--disable-blink-features=AutomationControlled')
|
|
|
|
|
|
try:
|
|
|
self.page = ChromiumPage(co)
|
|
|
|
|
|
- # 1. 导航到登录页面 (建立 Context)
|
|
|
+ # 1. 导航到登录页面
|
|
|
mission = self.free_config.get("mission_code", "")
|
|
|
country = self.free_config.get("country_code", "")
|
|
|
lang = self.free_config.get("language", "en")
|
|
|
@@ -265,37 +303,64 @@ class VfsPlugin2(IVSPlg):
|
|
|
|
|
|
self.page.get(login_page_url)
|
|
|
|
|
|
- # 2. 等待 Cloudflare 验证通过
|
|
|
- # DrissionPage 会自动处理 Turnstile,我们只需要等待结果出现
|
|
|
- # 通常 CF 的 widget 会生成一个 hidden input name="cf-turnstile-response"
|
|
|
- self._log("Waiting for Cloudflare challenge...")
|
|
|
+ # -------------------------------------------------------------
|
|
|
+ # [核心修改] 2. 智能 Cloudflare 过盾逻辑
|
|
|
+ # -------------------------------------------------------------
|
|
|
+ self._log("Handling Cloudflare challenge...")
|
|
|
|
|
|
- # 最多等待 30 秒
|
|
|
+ # 初始化过盾助手
|
|
|
+ cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
|
|
|
cf_token = ""
|
|
|
- for _ in range(10):
|
|
|
- # 间隔 1 秒
|
|
|
+
|
|
|
+ # 循环检测 (40秒超时)
|
|
|
+ for i in range(40):
|
|
|
time.sleep(1)
|
|
|
+
|
|
|
+ # A. 优先处理 Cookie 遮挡 (VFS 必须步骤)
|
|
|
+ # 如果不关掉 cookie banner,验证码可能点不到
|
|
|
self._handle_cookie_banner()
|
|
|
- # 尝试从 DOM 获取 Token
|
|
|
+
|
|
|
+ # B. 尝试从 DOM 获取 Token (无感验证可能自动通过)
|
|
|
try:
|
|
|
- # 检查是否有 cf-turnstile-response 元素且有值
|
|
|
- ele = self.page.ele('xpath://input[@name="cf-turnstile-response"]')
|
|
|
+ ele = self.page.ele('@name=cf-turnstile-response')
|
|
|
if ele and ele.value:
|
|
|
cf_token = ele.value
|
|
|
- self._log("Cloudflare Turnstile token extracted from DOM.")
|
|
|
+ self._log("Cloudflare Turnstile token extracted.")
|
|
|
break
|
|
|
except:
|
|
|
pass
|
|
|
|
|
|
- # 也可以检查是否已经看到了登录框 (id="mat-input-0" 或 form)
|
|
|
- if self.page.ele('xpath://form'):
|
|
|
+ # C. 如果前 3 秒没自动出 Token,开始尝试点击
|
|
|
+ if i > 2:
|
|
|
+ try:
|
|
|
+ # 开启 DFS 深度搜索模式 (防止 Shadow DOM 嵌套太深找不到)
|
|
|
+ # 在第 10 秒后开启深度搜索,前期用快速搜索
|
|
|
+ use_dfs = (i > 10)
|
|
|
+
|
|
|
+ cf_bypasser.click_verification_button(is_dfs=use_dfs)
|
|
|
+ except Exception as e:
|
|
|
+ # 点击错误忽略,继续下一轮
|
|
|
+ pass
|
|
|
+
|
|
|
+ # D. 检查是否已经看到了登录框 (有时候 Token 提取慢了,但页面已经变了)
|
|
|
+ if self.page.ele('tag:form') or self.page.ele('#mat-input-0'):
|
|
|
self._log("Login form detected.")
|
|
|
- # 即使 form 出来了,有时候 token 还在生成,稍微再等一下
|
|
|
+ # 继续尝试提取一次 Token,如果实在没有也不要死循环
|
|
|
+ if i > 5 and not cf_token:
|
|
|
+ self._log("Form visible but token not found yet...")
|
|
|
+
|
|
|
+ # -------------------------------------------------------------
|
|
|
+
|
|
|
+ if not cf_token:
|
|
|
+ # 最后尝试一次强取
|
|
|
+ try:
|
|
|
+ cf_token = self.page.ele('@name=cf-turnstile-response').value
|
|
|
+ except:
|
|
|
+ pass
|
|
|
|
|
|
- # 如果没拿到 token,尝试直接继续,或者报错
|
|
|
- # 注意:有些 VFS 页面可能没有显式的 turnstile,而是隐式的
|
|
|
if not cf_token:
|
|
|
- self._log("[WARN] Could not extract Turnstile token. Trying to proceed anyway...")
|
|
|
+ self._log("[WARN] Could not extract Turnstile token.")
|
|
|
+ raise BizLogicError(f"Could not extract Turnstile token.")
|
|
|
|
|
|
# 3. 准备登录 API 参数
|
|
|
email = self.config.account.username
|
|
|
@@ -309,8 +374,7 @@ class VfsPlugin2(IVSPlg):
|
|
|
headers = self._get_common_headers(with_auth=False)
|
|
|
headers.update({
|
|
|
"clientsource": client_src,
|
|
|
- "orangex": orange_src,
|
|
|
- # DrissionPage fetch 不需要 content-type,json参数会自动加
|
|
|
+ "orangex": orange_src
|
|
|
})
|
|
|
|
|
|
data = {
|
|
|
@@ -320,7 +384,7 @@ class VfsPlugin2(IVSPlg):
|
|
|
"countrycode": country,
|
|
|
"languageCode": "en-US",
|
|
|
"captcha_version": "cloudflare-v1",
|
|
|
- "captcha_api_key": cf_token # 填入提取到的 Token
|
|
|
+ "captcha_api_key": cf_token
|
|
|
}
|
|
|
|
|
|
self._log("Sending Login Request via Browser Fetch...")
|
|
|
@@ -335,6 +399,8 @@ class VfsPlugin2(IVSPlg):
|
|
|
# 分支 2: OTP
|
|
|
elif resp_json.get("enableOTPAuthentication"):
|
|
|
self._log("Login requires OTP.")
|
|
|
+ # 注意:_submit_login_otp 内部也会调用 _refresh_turnstile_token
|
|
|
+ # 所以这里旧的 cf_token 其实用处不大,传过去也没事
|
|
|
otp = self._read_otp_email()
|
|
|
self._submit_login_otp(cf_token, otp)
|
|
|
|
|
|
@@ -342,7 +408,6 @@ class VfsPlugin2(IVSPlg):
|
|
|
raise BizLogicError(f"Login failed: {resp.text[:200]}")
|
|
|
|
|
|
self.session_create_time = time.time()
|
|
|
- # 获取真实IP (用于日志)
|
|
|
try:
|
|
|
self.real_ip = self._get_realnetwork_ip()
|
|
|
except:
|
|
|
@@ -350,9 +415,7 @@ class VfsPlugin2(IVSPlg):
|
|
|
|
|
|
except Exception as e:
|
|
|
self._log(f"Create Session Failed: {e}")
|
|
|
- if self.page:
|
|
|
- self.page.quit()
|
|
|
- self.page = None
|
|
|
+ self.cleanup()
|
|
|
raise e
|
|
|
|
|
|
def query(self) -> VSQueryResult:
|
|
|
@@ -372,7 +435,7 @@ class VfsPlugin2(IVSPlg):
|
|
|
result.availability_status = AvailabilityStatus.NoneAvailable
|
|
|
result.visa_type = apt_config.get("visa_type", "")
|
|
|
result.city = apt_config.get("city", "")
|
|
|
-
|
|
|
+ result.routing_key = apt_config.get("routing_key", "")
|
|
|
if earliest_date:
|
|
|
result.success = True
|
|
|
if "WaitList" in earliest_date:
|
|
|
@@ -757,12 +820,11 @@ class VfsPlugin2(IVSPlg):
|
|
|
|
|
|
def _refresh_turnstile_token(self) -> str:
|
|
|
"""
|
|
|
- 强制刷新 Cloudflare Turnstile 并获取新 Token (增强版)
|
|
|
+ 强制刷新 Cloudflare Turnstile 并获取新 Token (集成 CloudflareBypasser 版)
|
|
|
"""
|
|
|
self._log("Refreshing Cloudflare Turnstile token...")
|
|
|
|
|
|
- # 1. JS 强制重置
|
|
|
- # 加上 try-catch 防止页面没有 turnstile 对象导致崩溃
|
|
|
+ # 1. JS 强制重置 (保持不变)
|
|
|
js_reset = """
|
|
|
try {
|
|
|
var input = document.querySelector('input[name="cf-turnstile-response"]');
|
|
|
@@ -774,32 +836,41 @@ class VfsPlugin2(IVSPlg):
|
|
|
"""
|
|
|
self.page.run_js(js_reset)
|
|
|
|
|
|
- # 2. 轮询等待 (增加到 30 秒)
|
|
|
- # 策略:检测 Token -> 如果没有且有 iframe -> 点击 iframe 触发验证
|
|
|
- for i in range(60): # 60 * 0.5s = 30s
|
|
|
+ # 2. 初始化过盾助手
|
|
|
+ # 假设 CloudflareBypasser 类已在当前文件中定义
|
|
|
+ cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
|
|
|
+
|
|
|
+ # 3. 轮询等待 (30秒)
|
|
|
+ for i in range(60):
|
|
|
time.sleep(0.5)
|
|
|
|
|
|
- # A. 尝试直接获取 Token (使用 JS 获取更稳定)
|
|
|
- token = self.page.run_js('return document.querySelector("input[name=\'cf-turnstile-response\']")?.value')
|
|
|
- if token:
|
|
|
- self._log("Turnstile token refreshed successfully.")
|
|
|
- return token
|
|
|
+ # A. 检查 Token 是否已生成
|
|
|
+ # 使用 DrissionPage 的方式获取 value 比较稳定
|
|
|
+ try:
|
|
|
+ ele = self.page.ele('@name=cf-turnstile-response')
|
|
|
+ if ele and ele.value:
|
|
|
+ self._log("Turnstile token refreshed successfully.")
|
|
|
+ return ele.value
|
|
|
+ except:
|
|
|
+ pass
|
|
|
|
|
|
- # B. 如果等待了 3 秒还没结果,尝试寻找 iframe 并点击
|
|
|
- # Cloudflare 有时需要用户点一下 "Verify you are human"
|
|
|
- if i > 6 and (i % 5 == 0): # 每隔 2.5 秒尝试点一次
|
|
|
+ # B. 尝试点击验证框
|
|
|
+ # 策略:前2秒等待,之后开始尝试点击
|
|
|
+ if i > 4:
|
|
|
+ # [重要] VFS 经常有 Cookie 弹窗遮挡,先尝试清理一下
|
|
|
+ self._handle_cookie_banner()
|
|
|
+
|
|
|
try:
|
|
|
- # 查找包含 turnstile 或 cloudflare 的 iframe
|
|
|
- # VFS 页面通常只有一个
|
|
|
- cf_iframe = self.page.ele('xpath://iframe[contains(@src, "turnstile") or contains(@src, "cloudflare")]')
|
|
|
- if cf_iframe:
|
|
|
- # 尝试点击 iframe 的中心位置
|
|
|
- # self._log("Clicking Cloudflare widget to activate...")
|
|
|
- cf_iframe.click(by_js=True)
|
|
|
- except Exception:
|
|
|
+ # 使用 CloudflareBypasser 的高级点击逻辑
|
|
|
+ # is_dfs=True 表示如果普通搜索找不到,就递归搜索 iframe (更耗时但更强)
|
|
|
+ # 我们在尝试 10 次 (5秒) 后开启 DFS 模式
|
|
|
+ use_dfs = (i > 14)
|
|
|
+
|
|
|
+ cf_bypasser.click_verification_button(is_dfs=use_dfs)
|
|
|
+ except Exception as e:
|
|
|
+ # 点击过程报错不要中断主循环
|
|
|
pass
|
|
|
|
|
|
- # 如果超时,为了调试,打印一下当前页面源码的一部分或截图(可选)
|
|
|
raise BizLogicError("Failed to refresh Cloudflare Turnstile token (Timeout)")
|
|
|
|
|
|
# -------------------------------------------------------------
|
|
|
@@ -1373,4 +1444,40 @@ class VfsPlugin2(IVSPlg):
|
|
|
curr = curr.replace(year=curr.year + 1, month=1)
|
|
|
else:
|
|
|
curr = curr.replace(month=curr.month + 1)
|
|
|
- return months
|
|
|
+ return months
|
|
|
+
|
|
|
+ # --- 资源清理核心方法 ---
|
|
|
+ def cleanup(self):
|
|
|
+ """
|
|
|
+ 销毁浏览器并彻底删除临时文件
|
|
|
+ """
|
|
|
+ # 1. 关闭浏览器
|
|
|
+ if self.page:
|
|
|
+ try:
|
|
|
+ self.page.quit() # 这会关闭 Chrome 进程
|
|
|
+ except Exception:
|
|
|
+ pass # 忽略已关闭的错误
|
|
|
+ self.page = None
|
|
|
+
|
|
|
+ # 2. 删除文件
|
|
|
+ # 注意:Chrome 关闭后可能需要几百毫秒释放文件锁,稍微等待
|
|
|
+ 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:
|
|
|
+ # 如果删除失败(通常是Windows文件占用),重试
|
|
|
+ if self.logger: self.logger(f"Cleanup retry: {e}")
|
|
|
+ time.sleep(0.5)
|
|
|
+
|
|
|
+ # 如果依然存在,打印警告(虽然 ignore_errors=True 会掩盖报错,但可以 check exists)
|
|
|
+ if os.path.exists(self.root_workspace) and self.logger:
|
|
|
+ self.logger(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
|
|
|
+
|
|
|
+ def __del__(self):
|
|
|
+ """
|
|
|
+ 析构函数:当对象被垃圾回收时自动调用
|
|
|
+ """
|
|
|
+ self.cleanup()
|