cutenix commited on
Commit
f526a32
·
1 Parent(s): 4cc3b81

duplicate from hkfires

Browse files
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 使用一个轻量的 Python 官方镜像作为基础
2
+ FROM python:3.11-slim-bookworm
3
+
4
+ # 设置工作目录,后续的命令都在这个目录下执行
5
+ WORKDIR /app
6
+
7
+ # 安装运行 Playwright 所需的最小系统依赖集
8
+ # 在同一层中清理 apt 缓存以减小镜像体积
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ libatk1.0-0 libatk-bridge2.0-0 libcups2 libdbus-1-3 libdrm2 libgbm1 libgtk-3-0 \
11
+ libnspr4 libnss3 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 \
12
+ libxext6 libxfixes3 libxrandr2 libxrender1 libxtst6 ca-certificates \
13
+ fonts-liberation libasound2 libpangocairo-1.0-0 libpango-1.0-0 libu2f-udev xvfb \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ # 拷贝并安装 Python 依赖
17
+ COPY requirements.txt .
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ # 下载 camoufox
21
+ RUN camoufox fetch
22
+
23
+ # 将项目中的所有文件拷贝到工作目录
24
+ COPY . .
25
+
26
+ # 暴露 Hugging Face Spaces 期望的端口(仅在服务器模式下使用)
27
+ EXPOSE 7860
28
+
29
+
30
+ # 设置容器启动时要执行的命令
31
+ CMD ["python", "main.py"]
README.md CHANGED
@@ -1,12 +1,9 @@
1
  ---
2
  title: AIStudioBuildWS
3
- emoji: 🐢
4
- colorFrom: blue
5
- colorTo: pink
6
- sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
- app_file: app.py
10
  pinned: false
11
  ---
12
 
 
1
  ---
2
  title: AIStudioBuildWS
3
+ emoji: 🏆
4
+ colorFrom: pink
5
+ colorTo: red
6
+ sdk: docker
 
 
 
7
  pinned: false
8
  ---
9
 
browser/cookie_validator.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import sys
3
+ from playwright.sync_api import TimeoutError, Error as PlaywrightError
4
+
5
+
6
+ class CookieValidator:
7
+ """Cookie验证器,负责定期验证Cookie的有效性。"""
8
+
9
+ def __init__(self, page, context, logger):
10
+ """
11
+ 初始化Cookie验证器
12
+
13
+ Args:
14
+ page: 主页面实例
15
+ context: 浏览器上下文
16
+ logger: 日志记录器
17
+ """
18
+ self.page = page
19
+ self.context = context
20
+ self.logger = logger
21
+
22
+
23
+ def validate_cookies_in_main_thread(self):
24
+ """
25
+ 在主线程中执行Cookie验证(由主线程调用)
26
+
27
+ Returns:
28
+ bool: Cookie是否有效
29
+ """
30
+ validation_page = None
31
+ try:
32
+ # 创建新标签页(在主线程中执行)
33
+ self.logger.info("开始Cookie验证...")
34
+ validation_page = self.context.new_page()
35
+
36
+ # 访问验证URL
37
+ validation_url = "https://aistudio.google.com/apps"
38
+ validation_page.goto(validation_url, wait_until='domcontentloaded', timeout=30000)
39
+
40
+ # 等待页面加载
41
+ validation_page.wait_for_timeout(2000)
42
+
43
+ # 获取最终URL
44
+ final_url = validation_page.url
45
+
46
+ # 检查是否被重定向到登录页面
47
+ if "accounts.google.com/v3/signin/identifier" in final_url:
48
+ self.logger.error("Cookie验证失败: 被重定向到登录页面")
49
+ return False
50
+
51
+ if "accounts.google.com/v3/signin/accountchooser" in final_url:
52
+ self.logger.error("Cookie验证失败: 被重定向到账户选择页面")
53
+ return False
54
+
55
+ # 如果没有跳转到登录页面,就算成功
56
+ self.logger.info("Cookie验证成功")
57
+ return True
58
+
59
+ except TimeoutError:
60
+ self.logger.error("Cookie验证失败: 页面加载超时")
61
+ return False
62
+
63
+ except PlaywrightError as e:
64
+ self.logger.error(f"Cookie验证失败: {e}")
65
+ return False
66
+
67
+ except Exception as e:
68
+ self.logger.error(f"Cookie验证失败: {e}")
69
+ return False
70
+
71
+ finally:
72
+ # 关闭验证标签页
73
+ if validation_page:
74
+ try:
75
+ validation_page.close()
76
+ except Exception:
77
+ pass # 忽略关闭错误
78
+
79
+ def shutdown_instance_on_cookie_failure(self):
80
+ """
81
+ 因Cookie失效而关闭实例
82
+ """
83
+ self.logger.error("Cookie失效,关闭实例")
84
+ time.sleep(1)
85
+ sys.exit(1)
browser/instance.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import signal
3
+ import time
4
+ from playwright.sync_api import TimeoutError, Error as PlaywrightError
5
+ from utils.logger import setup_logging
6
+ from utils.cookie_manager import CookieManager
7
+ from browser.navigation import handle_successful_navigation, KeepAliveError
8
+ from browser.cookie_validator import CookieValidator
9
+ from camoufox.sync_api import Camoufox
10
+ from camoufox.exceptions import InvalidIP, InvalidProxy
11
+ from utils.paths import logs_dir
12
+ from utils.common import parse_headless_mode, ensure_dir, parse_proxy_config
13
+ from utils.url_helper import extract_url_path, mask_url_for_logging, mask_path_for_logging
14
+
15
+
16
+ def run_browser_instance(config, shutdown_event=None):
17
+ """
18
+ 根据最终合并的配置,启动并管理一个单独的 Camoufox 浏览器实例。
19
+ 使用CookieManager统一管理Cookie加载,避免重复的扫描逻辑。
20
+ """
21
+ # 重置信号处理器,确保子进程能响应 SIGTERM
22
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
23
+ # 忽略 SIGINT (Ctrl+C),让主进程统一处理
24
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
25
+
26
+ cookie_source = config.get('cookie_source')
27
+ if not cookie_source:
28
+ # 使用默认logger进行错误报告
29
+ logger = setup_logging(os.path.join(logs_dir(), 'app.log'))
30
+ logger.error("错误: 配置中缺少cookie_source对象")
31
+ return
32
+
33
+ instance_label = cookie_source.display_name
34
+ logger = setup_logging(
35
+ os.path.join(logs_dir(), 'app.log'), prefix=instance_label
36
+ )
37
+ diagnostic_tag = instance_label.replace(os.sep, "_")
38
+
39
+ expected_url = config.get('url')
40
+ proxy = config.get('proxy')
41
+ headless_setting = config.get('headless', 'virtual')
42
+
43
+ # 使用CookieManager加载Cookie
44
+ cookie_manager = CookieManager(logger)
45
+ all_cookies = []
46
+
47
+ try:
48
+ # 直接使用CookieSource对象加载Cookie
49
+ cookies = cookie_manager.load_cookies(cookie_source)
50
+ all_cookies.extend(cookies)
51
+
52
+ except Exception as e:
53
+ logger.error(f"从Cookie来源加载时出错: {e}")
54
+ return
55
+
56
+ # 3. 检查是否有任何Cookie可用
57
+ if not all_cookies:
58
+ logger.error("错误: 没有可用的Cookie(既没有有效的JSON文件,也没有环境变量)")
59
+ return
60
+
61
+ cookies = all_cookies
62
+
63
+ headless_mode = parse_headless_mode(headless_setting)
64
+ launch_options = {"headless": headless_mode}
65
+ # launch_options["block_images"] = True # 禁用图片加载
66
+
67
+ if proxy:
68
+ proxy_config = parse_proxy_config(proxy)
69
+ if not proxy_config:
70
+ logger.error("错误: 代理配置无效,无法启动浏览器实例")
71
+ return
72
+ logger.info(f"使用代理: {proxy_config.get('server', proxy)} 访问")
73
+ launch_options["proxy"] = proxy_config
74
+ launch_options["geoip"] = True
75
+
76
+ screenshot_dir = logs_dir()
77
+ ensure_dir(screenshot_dir)
78
+
79
+ # 重启控制变量
80
+ max_retries = int(os.getenv("MAX_RESTART_RETRIES", "5"))
81
+ retry_count = 0
82
+ base_delay = 3
83
+
84
+ while True:
85
+ # 检查是否收到全局关闭信号
86
+ if shutdown_event and shutdown_event.is_set():
87
+ logger.info("检测到全局关闭事件,浏览器实例不再启动,准备退出")
88
+ return
89
+
90
+ try:
91
+ with Camoufox(**launch_options) as browser:
92
+ context = browser.new_context()
93
+ context.add_cookies(cookies)
94
+ page = context.new_page()
95
+
96
+ # 创建Cookie验证器
97
+ cookie_validator = CookieValidator(page, context, logger)
98
+
99
+ # ####################################################################
100
+ # ############ 增强的 page.goto() 错误处理和日志记录 ###############
101
+ # ####################################################################
102
+
103
+ response = None
104
+ try:
105
+ logger.info(f"正在导航到: {mask_url_for_logging(expected_url)} (超时设置为 90 秒)")
106
+ # page.goto() 会返回一个 response 对象,我们可以用它来获取状态码等信息
107
+ response = page.goto(expected_url, wait_until='domcontentloaded', timeout=90000)
108
+
109
+ # 检查HTTP响应状态码
110
+ if response:
111
+ logger.info(f"导航初步成功,服务器响应状态码: {response.status} {response.status_text}")
112
+ if not response.ok: # response.ok 检查状态码是否在 200-299 范围内
113
+ logger.warning(f"警告:页面加载成功,但HTTP状态码表示错误: {response.status}")
114
+ # 即使状态码错误,也保存快照以供分析
115
+ page.screenshot(path=os.path.join(screenshot_dir, f"WARN_http_status_{response.status}_{diagnostic_tag}.png"))
116
+ else:
117
+ # 对于非http/https的导航(如 about:blank),response可能为None
118
+ logger.warning("page.goto 未返回响应对象,可能是一个非HTTP导航")
119
+
120
+ except TimeoutError:
121
+ # 这是最常见的错误:超时
122
+ logger.error(f"导航到 {mask_url_for_logging(expected_url)} 超时 (超过90秒)")
123
+ logger.error("可能原因:网络连接缓慢、目标网站服务器无响应、代理问题、或页面资源被阻塞")
124
+ # 尝试保存诊断信息
125
+ try:
126
+ # 截图对于看到页面卡在什么状态非常有帮助(例如,空白页、加载中、Chrome错误页)
127
+ screenshot_path = os.path.join(screenshot_dir, f"FAIL_timeout_{diagnostic_tag}.png")
128
+ page.screenshot(path=screenshot_path, full_page=True)
129
+ logger.info(f"已截取超时时的屏幕快照: {screenshot_path}")
130
+
131
+ # 保存HTML可以帮助分析DOM结构,即使在无头模式下也很有用
132
+ html_path = os.path.join(screenshot_dir, f"FAIL_timeout_{diagnostic_tag}.html")
133
+ with open(html_path, 'w', encoding='utf-8') as f:
134
+ f.write(page.content())
135
+ logger.info(f"已保存超时时的页面HTML: {html_path}")
136
+ except Exception as diag_e:
137
+ logger.error(f"在尝试进行超时诊断(截图/保存HTML)时发生额外错误: {diag_e}")
138
+ return # 超时后,后续操作无意义,直接终止
139
+
140
+ except PlaywrightError as e:
141
+ # 捕获其他Playwright相关的网络错误,例如DNS解析失败、连接被拒绝等
142
+ error_message = str(e)
143
+ logger.error(f"导航到 {mask_url_for_logging(expected_url)} 时发生 Playwright 网络错误")
144
+ logger.error(f"错误详情: {error_message}")
145
+
146
+ # Playwright的错误信息通常很具体,例如 "net::ERR_CONNECTION_REFUSED"
147
+ if "net::ERR_NAME_NOT_RESOLVED" in error_message:
148
+ logger.error("排查建议:检查DNS设置或域名是否正确")
149
+ elif "net::ERR_CONNECTION_REFUSED" in error_message:
150
+ logger.error("排查建议:目标服务器可能已关闭,或代理/防火墙阻止了连接")
151
+ elif "net::ERR_INTERNET_DISCONNECTED" in error_message:
152
+ logger.error("排查建议:检查本机的网络连接")
153
+
154
+ # 同样,尝试截图,尽管此时页面可能完全无法访问
155
+ try:
156
+ screenshot_path = os.path.join(screenshot_dir, f"FAIL_network_error_{diagnostic_tag}.png")
157
+ page.screenshot(path=screenshot_path)
158
+ logger.info(f"已截取网络错误时的屏幕快照: {screenshot_path}")
159
+ except Exception as diag_e:
160
+ logger.error(f"在尝试进行网络错误诊断(截图)时发生额外错误: {diag_e}")
161
+
162
+ error_message_lower = error_message.lower()
163
+ if "proxy" in error_message_lower or "ns_error_proxy" in error_message_lower or "err_proxy" in error_message_lower:
164
+ raise KeepAliveError(f"代理连接错误: {error_message}")
165
+ return # 网络错误,终止
166
+
167
+ # --- 如果导航没有抛出异常,继续执行后续逻辑 ---
168
+
169
+ logger.info("页面初步加载完成,正在检查并处理初始弹窗...")
170
+ page.wait_for_timeout(2000)
171
+
172
+ final_url = page.url
173
+ logger.info(f"导航完成。最终URL为: {mask_url_for_logging(final_url)}")
174
+
175
+ # ... 你原有的URL检查逻辑保持不变 ...
176
+ if "accounts.google.com/v3/signin/identifier" in final_url:
177
+ logger.error("检测到Google登录页面(需要输入邮箱)。Cookie已完全失效")
178
+ page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_identifier_page_{diagnostic_tag}.png"))
179
+ return
180
+
181
+ # 提取路径部分进行匹配(允许域名重定向)
182
+ expected_path = extract_url_path(expected_url).split('?')[0]
183
+ final_path = extract_url_path(final_url)
184
+
185
+ if expected_path and expected_path in final_path:
186
+ logger.info(f"URL验证通过。预期路径: {mask_path_for_logging(expected_path)}")
187
+
188
+ # --- 新的健壮策略:等待加载指示器消失 ---
189
+ # 这是解决竞态条件的关键。错误消息或内容只在初始加载完成后才会出现。
190
+ spinner_locator = page.locator('mat-spinner')
191
+ try:
192
+ logger.info("正在等待加载指示器 (spinner) 消失... (最长等待30秒)")
193
+ # 我们等待spinner变为'隐藏'状态或从DOM中消失。
194
+ spinner_locator.wait_for(state='hidden', timeout=30000)
195
+ logger.info("加载指示器已消失。页面已完成异步加载")
196
+ except TimeoutError:
197
+ logger.error("页面加载指示器在30秒内未消失。页面可能已卡住")
198
+ page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_spinner_stuck_{diagnostic_tag}.png"))
199
+ raise KeepAliveError("页面加载指示器超时")
200
+
201
+ # --- 现在我们可以安全地检查错误消息 ---
202
+ # 我们使用最具体的文本以避免误判。
203
+ auth_error_text = "authentication error"
204
+ auth_error_locator = page.get_by_text(auth_error_text, exact=False)
205
+
206
+ # 这里我们只需要很短的超时时间,因为页面应该是稳定的。
207
+ if auth_error_locator.is_visible(timeout=2000):
208
+ logger.error(f"检测到认证失败的错误横幅: '{auth_error_text}'. Cookie已过期或无效")
209
+ screenshot_path = os.path.join(screenshot_dir, f"FAIL_auth_error_banner_{diagnostic_tag}.png")
210
+ page.screenshot(path=screenshot_path)
211
+
212
+ # html_path = os.path.join(screenshot_dir, f"FAIL_auth_error_banner_{diagnostic_tag}.html")
213
+ # with open(html_path, 'w', encoding='utf-8') as f:
214
+ # f.write(page.content())
215
+ # logger.info(f"已保存包含错误信息的页面HTML: {html_path}")
216
+ return # 明确的失败,因此我们退出。
217
+
218
+ # --- 如果没有错误,进行最终确认(作为后备方案) ---
219
+ logger.info("未检测到认证错误横幅。进行最终确认")
220
+ login_button_cn = page.get_by_role('button', name='登录')
221
+ login_button_en = page.get_by_role('button', name='Login')
222
+
223
+ if login_button_cn.is_visible(timeout=1000) or login_button_en.is_visible(timeout=1000):
224
+ logger.error("页面上仍显示'登录'按钮。Cookie无效")
225
+ page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_login_button_visible_{diagnostic_tag}.png"))
226
+ return
227
+
228
+ # --- 如果所有检查都通过,我们假设成功 ---
229
+ logger.info("所有验证通过,确认已成功登录")
230
+
231
+ handle_successful_navigation(page, logger, diagnostic_tag, shutdown_event, cookie_validator)
232
+ elif "accounts.google.com/v3/signin/accountchooser" in final_url:
233
+ logger.warning("检测到Google账户选择页面。登录失败或Cookie已过期")
234
+ page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_chooser_click_failed_{diagnostic_tag}.png"))
235
+ return
236
+ else:
237
+ logger.error(f"导航到了意外的URL")
238
+ logger.error(f" 预期路径: {mask_path_for_logging(expected_path)}")
239
+ logger.error(f" 最终路径: {mask_path_for_logging(final_path)}")
240
+ logger.error(f" 最终URL: {mask_url_for_logging(final_url)}")
241
+ page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_unexpected_url_{diagnostic_tag}.png"))
242
+ return
243
+
244
+ # 如果运行到这里且没有异常,表示实例正常结束(例如收到关闭信号)
245
+ # 正常结束时重置重试计数器
246
+ retry_count = 0
247
+ return
248
+
249
+ except (InvalidProxy, InvalidIP, KeepAliveError) as e:
250
+ if isinstance(e, (InvalidProxy, InvalidIP)):
251
+ retry_reason = f"代理/GeoIP 处理失败: {e}"
252
+ logger.error(retry_reason)
253
+ else:
254
+ retry_reason = str(e)
255
+ retry_count += 1
256
+ if retry_count > max_retries:
257
+ logger.error(f"重试次数已达上限 ({max_retries}),实例不再重启,退出")
258
+ return
259
+
260
+ # 指数退避:3秒、6秒、12秒、24秒...最长60秒
261
+ delay = min(base_delay * (2 ** (retry_count - 1)), 60)
262
+ logger.error(f"浏览器实例出现错误 (重试 {retry_count}/{max_retries}),将在 {delay} 秒后重启浏览器实例: {retry_reason}")
263
+ time.sleep(delay)
264
+ continue
265
+ except KeyboardInterrupt:
266
+ logger.info(f"用户中断,正在关闭...")
267
+ return
268
+ except SystemExit as e:
269
+ # 捕获Cookie验证失败时��系统退出
270
+ if e.code == 1:
271
+ logger.error("Cookie验证失败,关闭进程实例")
272
+ else:
273
+ logger.info(f"实例正常退出,退出码: {e.code}")
274
+ return
275
+ except Exception as e:
276
+ # 这是一个最终的捕获,用于捕获所有未预料到的错误
277
+ logger.exception(f"运行 Camoufox 实例时发生未预料的严重错误: {e}")
278
+ return
browser/navigation.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import os
3
+ import random
4
+ from playwright.sync_api import Page, expect
5
+ from utils.paths import logs_dir
6
+ from utils.common import ensure_dir
7
+ from browser.ws_helper import reconnect_ws, get_ws_status, dismiss_interaction_modal, click_in_iframe, PageLocators
8
+
9
+ class KeepAliveError(Exception):
10
+ pass
11
+
12
+ def handle_popup_dialog(page: Page, logger=None):
13
+ """
14
+ 检查并处理弹窗。
15
+ 交替点击 Got it 和 Continue to the app 按钮直到没有弹窗。
16
+ """
17
+ logger.info("开始处理弹窗...")
18
+
19
+ # 定义需要查找的按钮列表
20
+ button_names = ["Got it", "Continue to the app"]
21
+ max_iterations = 10 # 最多尝试10轮,防止死循环
22
+ total_clicks = 0
23
+
24
+ try:
25
+ for iteration in range(max_iterations):
26
+ clicked_in_round = False
27
+
28
+ # 等待页面稳定
29
+ time.sleep(1)
30
+
31
+ # 每轮交替尝试点击所有按钮
32
+ for button_name in button_names:
33
+ try:
34
+ button_locator = page.locator(f'button:visible:has-text("{button_name}")')
35
+ if button_locator.count() > 0 and button_locator.first.is_visible():
36
+ # logger.info(f"检测到弹窗: 点击 '{button_name}'")
37
+ button_locator.first.click(force=True, timeout=2000)
38
+ total_clicks += 1
39
+ clicked_in_round = True
40
+ time.sleep(1)
41
+ except:
42
+ pass
43
+
44
+ if not clicked_in_round:
45
+ break
46
+
47
+ if total_clicks > 0:
48
+ logger.info(f"弹窗处理完成, 共点击 {total_clicks} 次")
49
+ else:
50
+ logger.info("未检测到弹窗")
51
+ except Exception as e:
52
+ logger.info(f"检查弹窗时发生意外:{e},将继续执行...")
53
+
54
+ def handle_successful_navigation(page: Page, logger, cookie_file_config, shutdown_event=None, cookie_validator=None):
55
+ """
56
+ 在成功导航到目标页面后,执行后续操作(处理弹窗、保持运行)。
57
+ """
58
+ logger.info("已成功到达目标页面")
59
+ page.click('body') # 给予页面焦点
60
+
61
+ # 检查并处理弹窗
62
+ handle_popup_dialog(page, logger=logger)
63
+
64
+ # 保存登录成功截图
65
+ try:
66
+ from datetime import datetime
67
+ screenshot_dir = logs_dir()
68
+ ensure_dir(screenshot_dir)
69
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
70
+ screenshot_path = os.path.join(screenshot_dir, f"SUCCESS_{cookie_file_config}_{timestamp}.png")
71
+ page.screenshot(path=screenshot_path)
72
+ logger.info(f"已保存登录成功截图: {screenshot_path}")
73
+ except Exception as e:
74
+ logger.warning(f"保存截图失败: {e}")
75
+
76
+ if cookie_validator:
77
+ logger.info("Cookie验证器已创建,将定期验证Cookie有效性")
78
+
79
+ logger.info("实例将保持运行状态。每8-15秒随机点击一次页面以保持活动")
80
+
81
+ # 等待页面加载和渲染
82
+ time.sleep(15)
83
+
84
+ # 创建 PageLocators 缓存对象,复用 locator 避免内存泄漏
85
+ locators = PageLocators(page)
86
+
87
+ # 记录初始WS状态
88
+ last_ws_status = get_ws_status(page, logger, locators)
89
+ logger.info(f"初始WS状态: {last_ws_status}")
90
+
91
+ # 添加Cookie验证计数器
92
+ click_counter = 0
93
+
94
+ while True:
95
+ # 检查是否收到关闭信号
96
+ if shutdown_event and shutdown_event.is_set():
97
+ logger.info("收到关闭信号,正在优雅退出保持活动循环...")
98
+ break
99
+
100
+ try:
101
+ # 检测并关闭interaction-modal遮罩层(如果出现)
102
+ dismiss_interaction_modal(page, logger, locators)
103
+
104
+ # 在iframe内随机移动并点击保活
105
+ click_in_iframe(page, logger, locators)
106
+ click_counter += 1
107
+
108
+ # 检查WS状态是否发生变化
109
+ current_ws_status = get_ws_status(page, logger, locators)
110
+ if current_ws_status != last_ws_status:
111
+ logger.warning(f"WS状态变更: {last_ws_status} -> {current_ws_status}")
112
+
113
+ # 如果状态变成IDLE,尝试重连
114
+ if current_ws_status == "IDLE":
115
+ logger.info("WS状态为IDLE,尝试重连...")
116
+ reconnect_ws(page, logger, locators)
117
+ current_ws_status = get_ws_status(page, logger, locators)
118
+
119
+ last_ws_status = current_ws_status
120
+
121
+ # 每720次点击(约2小时)执行一次完整的Cookie验证
122
+ if cookie_validator and click_counter >= 720: # 720 * ~10秒 ≈ 7200秒 ≈ 2小时
123
+ is_valid = cookie_validator.validate_cookies_in_main_thread()
124
+
125
+ if not is_valid:
126
+ cookie_validator.shutdown_instance_on_cookie_failure()
127
+ return
128
+
129
+ click_counter = 0 # 重置计数器
130
+
131
+ # 使用可中��的随机睡眠(8-15秒),每秒检查一次关闭信号
132
+ sleep_duration = random.randint(8, 15)
133
+ for _ in range(sleep_duration):
134
+ if shutdown_event and shutdown_event.is_set():
135
+ logger.info("收到关闭信号,正在优雅退出保持活动循环...")
136
+ return
137
+ time.sleep(1)
138
+
139
+ except Exception as e:
140
+ logger.error(f"在保持活动循环中出错: {e}")
141
+ # 在保持活动循环中出错时截屏
142
+ try:
143
+ screenshot_dir = logs_dir()
144
+ ensure_dir(screenshot_dir)
145
+ screenshot_filename = os.path.join(screenshot_dir, f"FAIL_keep_alive_error_{cookie_file_config}.png")
146
+ page.screenshot(path=screenshot_filename, full_page=True)
147
+ logger.info(f"已在保持活动循环出错时截屏: {screenshot_filename}")
148
+ except Exception as screenshot_e:
149
+ logger.error(f"在保持活动循环出错时截屏失败: {screenshot_e}")
150
+ raise KeepAliveError(f"在保持活动循环时出错: {e}")
browser/ws_helper.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import random
3
+ from playwright.sync_api import Page, FrameLocator
4
+
5
+
6
+ class PageLocators:
7
+ """
8
+ 缓存常用的 Locator 对象,避免每次保活循环都创建新对象导致内存泄漏。
9
+ Playwright 的 Locator 是惰性的,可以安全复用。
10
+ """
11
+
12
+ def __init__(self, page: Page):
13
+ self.page = page
14
+ # 缓存常用的 locator
15
+ self._modal = None
16
+ self._iframe = None
17
+ self._frame = None
18
+ self._ws_status = None
19
+ self._disconnect_btn = None
20
+ self._connect_btn = None
21
+
22
+ @property
23
+ def modal(self):
24
+ """interaction-modal 遮罩层"""
25
+ if self._modal is None:
26
+ self._modal = self.page.locator('div.interaction-modal')
27
+ return self._modal
28
+
29
+ @property
30
+ def iframe(self):
31
+ """Preview iframe 元素"""
32
+ if self._iframe is None:
33
+ self._iframe = self.page.locator('iframe[title="Preview"]')
34
+ return self._iframe
35
+
36
+ @property
37
+ def frame(self):
38
+ """Preview iframe 的 FrameLocator"""
39
+ if self._frame is None:
40
+ self._frame = self.page.frame_locator('iframe[title="Preview"]')
41
+ return self._frame
42
+
43
+ @property
44
+ def ws_status(self):
45
+ """WS 状态文本元素"""
46
+ if self._ws_status is None:
47
+ self._ws_status = self.frame.locator('text=/WS:\\s*(CONNECTED|IDLE|CONNECTING|RECONNECTING)/i').first
48
+ return self._ws_status
49
+
50
+ @property
51
+ def disconnect_btn(self):
52
+ """Disconnect 按钮"""
53
+ if self._disconnect_btn is None:
54
+ self._disconnect_btn = self.frame.locator('button:has-text("Disconnect")')
55
+ return self._disconnect_btn
56
+
57
+ @property
58
+ def connect_btn(self):
59
+ """Connect 按钮"""
60
+ if self._connect_btn is None:
61
+ self._connect_btn = self.frame.locator('button:has-text("Connect")')
62
+ return self._connect_btn
63
+
64
+
65
+ def get_preview_frame(page: Page, logger=None) -> FrameLocator:
66
+ """
67
+ 获取预览iframe的FrameLocator。
68
+ """
69
+ try:
70
+ # 查找title为"Preview"的iframe
71
+ frame = page.frame_locator('iframe[title="Preview"]')
72
+ return frame
73
+ except Exception as e:
74
+ if logger:
75
+ logger.warning(f"获取Preview iframe失败: {e}")
76
+ return None
77
+
78
+
79
+ def get_ws_status(page: Page, logger=None, locators: PageLocators = None) -> str:
80
+ """
81
+ 获取页面中WS连接状态(在iframe内部)。
82
+ 返回: CONNECTED, IDLE, CONNECTING, RECONNECTING 或 UNKNOWN
83
+
84
+ Args:
85
+ page: Playwright Page 对象
86
+ logger: 日志记录器
87
+ locators: 可选的 PageLocators 缓存对象,传入可避免重复创建 locator
88
+ """
89
+ try:
90
+ if locators:
91
+ status_element = locators.ws_status
92
+ else:
93
+ frame = get_preview_frame(page, logger)
94
+ if not frame:
95
+ return "UNKNOWN"
96
+ status_element = frame.locator('text=/WS:\\s*(CONNECTED|IDLE|CONNECTING|RECONNECTING)/i').first
97
+
98
+ if status_element.is_visible(timeout=3000):
99
+ text = status_element.text_content()
100
+ if text:
101
+ if "RECONNECTING" in text.upper():
102
+ return "RECONNECTING"
103
+ elif "CONNECTED" in text.upper():
104
+ return "CONNECTED"
105
+ elif "IDLE" in text.upper():
106
+ return "IDLE"
107
+ elif "CONNECTING" in text.upper():
108
+ return "CONNECTING"
109
+ return "UNKNOWN"
110
+ except Exception as e:
111
+ if logger:
112
+ logger.warning(f"获取WS状态时出错: {e}")
113
+ return "UNKNOWN"
114
+
115
+
116
+ def click_disconnect(page: Page, logger=None, locators: PageLocators = None) -> bool:
117
+ """
118
+ 点击Disconnect按钮断开WS连接(在iframe内部)。
119
+
120
+ Args:
121
+ page: Playwright Page 对象
122
+ logger: 日志记录器
123
+ locators: 可选的 PageLocators 缓存对象
124
+ """
125
+ try:
126
+ if locators:
127
+ disconnect_btn = locators.disconnect_btn
128
+ else:
129
+ frame = get_preview_frame(page, logger)
130
+ if not frame:
131
+ return False
132
+ disconnect_btn = frame.locator('button:has-text("Disconnect")')
133
+
134
+ if disconnect_btn.count() > 0 and disconnect_btn.first.is_visible(timeout=3000):
135
+ disconnect_btn.first.click(timeout=5000)
136
+ if logger:
137
+ logger.info("已点击 Disconnect 按钮")
138
+ time.sleep(1)
139
+ return True
140
+ if logger:
141
+ logger.warning("未找到可见的 Disconnect 按钮")
142
+ return False
143
+ except Exception as e:
144
+ if logger:
145
+ logger.warning(f"点击 Disconnect 按钮失败: {e}")
146
+ return False
147
+
148
+
149
+ def click_connect(page: Page, logger=None, locators: PageLocators = None) -> bool:
150
+ """
151
+ 点击Connect按钮建立WS连接(在iframe内部)。
152
+
153
+ Args:
154
+ page: Playwright Page 对象
155
+ logger: 日志记录器
156
+ locators: 可选的 PageLocators 缓存对象
157
+ """
158
+ try:
159
+ if locators:
160
+ connect_btn = locators.connect_btn
161
+ else:
162
+ frame = get_preview_frame(page, logger)
163
+ if not frame:
164
+ return False
165
+ connect_btn = frame.locator('button:has-text("Connect")')
166
+
167
+ if connect_btn.count() > 0 and connect_btn.first.is_visible(timeout=3000):
168
+ connect_btn.first.click(timeout=5000)
169
+ if logger:
170
+ logger.info("已点击 Connect 按钮")
171
+ time.sleep(1)
172
+ return True
173
+ if logger:
174
+ logger.warning("未找到可见的 Connect 按钮")
175
+ return False
176
+ except Exception as e:
177
+ if logger:
178
+ logger.warning(f"点击 Connect 按钮失败: {e}")
179
+ return False
180
+
181
+
182
+ def wait_for_ws_connected(page: Page, logger=None, timeout: int = 30, locators: PageLocators = None) -> bool:
183
+ """
184
+ 等待WS状态变为CONNECTED。
185
+ """
186
+ start_time = time.time()
187
+ while time.time() - start_time < timeout:
188
+ status = get_ws_status(page, logger, locators)
189
+ if status == "CONNECTED":
190
+ return True
191
+ time.sleep(1)
192
+ return False
193
+
194
+
195
+ def reconnect_ws(page: Page, logger=None, locators: PageLocators = None) -> str:
196
+ """
197
+ 执行断开再连接的流程,并返回最终WS状态。
198
+ 流程:关闭遮罩 -> Disconnect -> 等待IDLE -> Connect -> 等待CONNECTED -> 获取状态
199
+
200
+ Args:
201
+ page: Playwright Page 对象
202
+ logger: 日志记录器
203
+ locators: 可选的 PageLocators 缓存对象
204
+ """
205
+ if logger:
206
+ logger.info("开始执行WS重连流程: Disconnect -> Connect")
207
+
208
+ # 先关闭 interaction-modal 遮罩层(如果存在)
209
+ dismiss_interaction_modal(page, logger, locators)
210
+
211
+ # 先断开连接
212
+ click_disconnect(page, logger, locators)
213
+ time.sleep(2)
214
+
215
+ # 检查是否变为IDLE
216
+ status = get_ws_status(page, logger, locators)
217
+ if logger:
218
+ logger.info(f"断开后WS状态: {status}")
219
+
220
+ # 再连接
221
+ click_connect(page, logger, locators)
222
+ time.sleep(2)
223
+
224
+ # 等待连接成功
225
+ if wait_for_ws_connected(page, logger, timeout=15, locators=locators):
226
+ status = get_ws_status(page, logger, locators)
227
+ if logger:
228
+ logger.info(f"重连后WS状态: {status}")
229
+ return status
230
+ else:
231
+ status = get_ws_status(page, logger, locators)
232
+ if logger:
233
+ logger.warning(f"WS重连超时,当前状态: {status}")
234
+ return status
235
+
236
+
237
+ def dismiss_interaction_modal(page: Page, logger=None, locators: PageLocators = None) -> bool:
238
+ """
239
+ 检测并关闭 interaction-modal 遮罩层。
240
+ 通过在 iframe 区域内模拟鼠标移动来触发遮罩层关闭。
241
+
242
+ Args:
243
+ page: Playwright Page 对象
244
+ logger: 日志记录器
245
+ locators: 可选的 PageLocators 缓存对象
246
+
247
+ 返回: True 如果成功关闭遮罩,False 如果未找到遮罩或关闭失败
248
+ """
249
+ try:
250
+ if locators:
251
+ modal = locators.modal
252
+ iframe = locators.iframe
253
+ else:
254
+ modal = page.locator('div.interaction-modal')
255
+ iframe = page.locator('iframe[title="Preview"]')
256
+
257
+ if modal.count() == 0 or not modal.first.is_visible(timeout=500):
258
+ return False
259
+
260
+ if logger:
261
+ logger.info("检测到 interaction-modal 遮罩层,尝试关闭...")
262
+
263
+ if iframe.count() > 0:
264
+ iframe_box = iframe.first.bounding_box()
265
+ if iframe_box:
266
+ # 随机起点
267
+ curr_x = iframe_box['x'] + random.randint(50, int(iframe_box['width']) - 50)
268
+ curr_y = iframe_box['y'] + random.randint(50, int(iframe_box['height']) - 50)
269
+
270
+ # 持续连续移动直到遮罩关闭,最多尝试30次
271
+ for i in range(30):
272
+ # 从当前位置随机移动一段距离
273
+ delta_x = random.randint(-30, 30)
274
+ delta_y = random.randint(-20, 20)
275
+ curr_x = max(iframe_box['x'] + 20, min(iframe_box['x'] + iframe_box['width'] - 20, curr_x + delta_x))
276
+ curr_y = max(iframe_box['y'] + 20, min(iframe_box['y'] + iframe_box['height'] - 20, curr_y + delta_y))
277
+
278
+ page.mouse.move(curr_x, curr_y)
279
+ time.sleep(0.05)
280
+
281
+ # 每次移动后检查遮罩是否关闭
282
+ if modal.count() == 0 or not modal.first.is_visible(timeout=100):
283
+ if logger:
284
+ logger.info("已成功关闭 interaction-modal 遮罩层")
285
+ return True
286
+
287
+ return False
288
+ except Exception as e:
289
+ if logger:
290
+ logger.debug(f"关闭 interaction-modal 时出错: {e}")
291
+ return False
292
+
293
+
294
+ def click_in_iframe(page: Page, logger=None, locators: PageLocators = None) -> bool:
295
+ """
296
+ 在 iframe 内随机移动鼠标并点击一次,用于保活。
297
+ 避开顶部(状态栏和按钮区域)和右侧区域。
298
+
299
+ Args:
300
+ page: Playwright Page 对象
301
+ logger: 日志记录器
302
+ locators: 可选的 PageLocators 缓存对象
303
+
304
+ 返回: True 如果成功点击,False 如果失败
305
+ """
306
+ try:
307
+ if locators:
308
+ iframe = locators.iframe
309
+ else:
310
+ iframe = page.locator('iframe[title="Preview"]')
311
+
312
+ if iframe.count() == 0:
313
+ return False
314
+
315
+ iframe_box = iframe.first.bounding_box()
316
+ if not iframe_box:
317
+ return False
318
+
319
+ # 安全区域:避开顶部80像素(状态栏+按钮)和右侧200像素(按钮区域)
320
+ safe_left = iframe_box['x'] + 50
321
+ safe_right = iframe_box['x'] + iframe_box['width'] - 200
322
+ safe_top = iframe_box['y'] + 80
323
+ safe_bottom = iframe_box['y'] + iframe_box['height'] - 50
324
+
325
+ # 确保安全区域有效
326
+ if safe_right <= safe_left or safe_bottom <= safe_top:
327
+ return False
328
+
329
+ # 随机起点(在安全区域内)
330
+ curr_x = random.randint(int(safe_left), int(safe_right))
331
+ curr_y = random.randint(int(safe_top), int(safe_bottom))
332
+
333
+ # 随机移动几步(保持在安全区域内)
334
+ for _ in range(random.randint(3, 6)):
335
+ delta_x = random.randint(-30, 30)
336
+ delta_y = random.randint(-20, 20)
337
+ curr_x = max(int(safe_left), min(int(safe_right), curr_x + delta_x))
338
+ curr_y = max(int(safe_top), min(int(safe_bottom), curr_y + delta_y))
339
+ page.mouse.move(curr_x, curr_y)
340
+ time.sleep(0.05)
341
+
342
+ # 点击当前位置
343
+ page.mouse.click(curr_x, curr_y)
344
+ return True
345
+ except Exception as e:
346
+ if logger:
347
+ logger.debug(f"在 iframe 内点击失败: {e}")
348
+ return False
main.py ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import threading
3
+ import multiprocessing
4
+ import signal
5
+ import sys
6
+ import time
7
+
8
+ # 加载 .env 文件(仅在非 Docker 环境且文件存在时)
9
+ def load_env_file():
10
+ """加载 .env 文件,不影响已存在的环境变量"""
11
+ if os.environ.get("DOCKER_ENV") or os.path.exists("/.dockerenv"):
12
+ return # Docker 环境,跳过加载
13
+ try:
14
+ from dotenv import load_dotenv
15
+ env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
16
+ if os.path.exists(env_path):
17
+ load_dotenv(env_path, override=False) # override=False 不覆盖已有环境变量
18
+ except ImportError:
19
+ pass # python-dotenv 未安装,跳过
20
+
21
+ load_env_file()
22
+
23
+ from browser.instance import run_browser_instance
24
+ from utils.logger import setup_logging
25
+ from utils.paths import cookies_dir, logs_dir
26
+ from utils.cookie_manager import CookieManager
27
+ from utils.common import clean_env_value, ensure_dir
28
+
29
+ # 全局变量
30
+ app_running = False
31
+ flask_app = None
32
+ # 使用 multiprocessing.Event 实现跨进程通信
33
+ shutdown_event = multiprocessing.Event()
34
+
35
+
36
+ class ProcessManager:
37
+ """进程管理器,负责跟踪和管理浏览器进程"""
38
+
39
+ def __init__(self):
40
+ self.processes = {} # {process_id: process_info}
41
+ self.lock = threading.RLock()
42
+ ensure_dir(logs_dir())
43
+ self.logger = setup_logging(str(logs_dir() / 'app.log'), prefix="manager")
44
+
45
+ def add_process(self, process, config=None):
46
+ """添加进程到管理器"""
47
+ with self.lock:
48
+ pid = process.pid if process and hasattr(process, 'pid') else None
49
+
50
+ # 允许添加PID为None的进程(可能还在启动中),但会记录这个情况
51
+ if pid is None:
52
+ # 使用临时ID作为key,等获得真实PID后再更新
53
+ temp_id = f"temp_{len(self.processes)}"
54
+ self.logger.warning(f"进程PID暂时为None,使用临时ID {temp_id}")
55
+ else:
56
+ temp_id = pid
57
+
58
+ process_info = {
59
+ 'process': process,
60
+ 'config': config,
61
+ 'pid': pid,
62
+ 'is_alive': True,
63
+ 'start_time': time.time()
64
+ }
65
+ self.processes[temp_id] = process_info
66
+
67
+ def update_temp_pids(self):
68
+ """更新临时PID为真实PID"""
69
+ with self.lock:
70
+ temp_ids = [k for k in self.processes.keys() if isinstance(k, str) and k.startswith("temp_")]
71
+ for temp_id in temp_ids:
72
+ process_info = self.processes[temp_id]
73
+ process = process_info['process']
74
+
75
+ if process and hasattr(process, 'pid') and process.pid is not None:
76
+ # 更新为真实PID
77
+ self.processes[process.pid] = process_info
78
+ del self.processes[temp_id]
79
+ process_info['pid'] = process.pid
80
+
81
+ def remove_process(self, pid):
82
+ """从管理器中移除进程"""
83
+ with self.lock:
84
+ if pid in self.processes:
85
+ del self.processes[pid]
86
+
87
+ def get_alive_processes(self):
88
+ """获取所有存活进程"""
89
+ with self.lock:
90
+ # 首先尝试更新临时PID
91
+ self.update_temp_pids()
92
+
93
+ alive = []
94
+ dead_pids = []
95
+
96
+ for pid, info in self.processes.items():
97
+ process = info['process']
98
+ try:
99
+ # 检查进程是否真实存在且是子进程
100
+ if process and hasattr(process, 'is_alive') and process.is_alive():
101
+ alive.append(process)
102
+ else:
103
+ dead_pids.append(pid)
104
+ except (ValueError, ProcessLookupError) as e:
105
+ # 进程已经不存在
106
+ dead_pids.append(pid)
107
+ self.logger.warning(f"进程 {pid} 检查时出错: {e}")
108
+
109
+ # 清理死进程记录
110
+ for pid in dead_pids:
111
+ self.remove_process(pid)
112
+
113
+ return alive
114
+
115
+ def terminate_all(self, timeout=10):
116
+ """优雅地终止所有进程"""
117
+ with self.lock:
118
+ # logger = setup_logging(str(logs_dir() / 'app.log'), prefix="signal")
119
+ # 直接使用 self.logger,避免重复 setup_logging
120
+
121
+ # 首先更新临时PID
122
+ self.update_temp_pids()
123
+
124
+ if not self.processes:
125
+ self.logger.info("没有活跃的进程需要关闭")
126
+ return
127
+
128
+ self.logger.info(f"开始关闭 {len(self.processes)} 个进程...")
129
+
130
+ # 第一阶段:发送SIGTERM信号
131
+ active_pids = []
132
+ for pid, info in list(self.processes.items()):
133
+ process = info['process']
134
+ try:
135
+ # 检查进程对象是否有效且进程存活
136
+ if process and hasattr(process, 'is_alive') and process.is_alive() and pid is not None:
137
+ self.logger.info(f"发送SIGTERM给进程 {pid} (运行时长: {time.time() - info['start_time']:.1f}秒)")
138
+ process.terminate()
139
+ active_pids.append(pid)
140
+ else:
141
+ self.logger.info(f"进程 {pid if pid is not None else 'None'} 已经停止或无效")
142
+ except (ValueError, ProcessLookupError, AttributeError) as e:
143
+ self.logger.warning(f"进程 {pid if pid is not None else 'None'} 访问出错: {e}")
144
+
145
+ if not active_pids:
146
+ self.logger.info("所有进程已经停止")
147
+ return
148
+
149
+ # 第二阶段:等待进程退出
150
+ self.logger.info(f"等待 {len(active_pids)} 个进程优雅退出...")
151
+ start_wait = time.time()
152
+ while time.time() - start_wait < 5: # 最多等待5秒
153
+ still_alive = []
154
+ for pid in active_pids:
155
+ if pid in self.processes:
156
+ process = self.processes[pid]['process']
157
+ try:
158
+ if process and hasattr(process, 'is_alive') and process.is_alive():
159
+ still_alive.append(pid)
160
+ except (ValueError, ProcessLookupError, AttributeError):
161
+ pass
162
+ if not still_alive:
163
+ self.logger.info("所有进程已优雅退出")
164
+ return
165
+ time.sleep(0.5)
166
+
167
+ self.logger.info(f"仍有 {len(still_alive)} 个进程在运行,准备强制关闭...")
168
+
169
+ # 第三阶段:强制杀死仍在运行的进程
170
+ for pid in active_pids:
171
+ if pid in self.processes and pid is not None:
172
+ process = self.processes[pid]['process']
173
+ try:
174
+ if process and hasattr(process, 'is_alive') and process.is_alive():
175
+ self.logger.warning(f"进程 {pid} 未响应SIGTERM,强制终止")
176
+ process.kill()
177
+ except (ValueError, ProcessLookupError, AttributeError) as e:
178
+ self.logger.info(f"进程 {pid} 已终止: {e}")
179
+
180
+ self.logger.info("所有进程关闭完成")
181
+
182
+ def get_count(self):
183
+ """获取管理的进程总数"""
184
+ with self.lock:
185
+ return len(self.processes)
186
+
187
+ def get_alive_count(self):
188
+ """获取存活进程数"""
189
+ return len(self.get_alive_processes())
190
+
191
+
192
+ # 全局进程管理器
193
+ process_manager = ProcessManager()
194
+
195
+
196
+ def load_instance_configurations(logger):
197
+ """
198
+ 使用CookieManager解析环境变量和Cookies目录,为每个Cookie来源创建独立的浏览器实例配置。
199
+ """
200
+ # 1. 读取所有实例共享的URL
201
+ shared_url = clean_env_value(os.getenv("CAMOUFOX_INSTANCE_URL"))
202
+ if not shared_url:
203
+ logger.error("错误: 缺少环境变量 CAMOUFOX_INSTANCE_URL。所有实例需要一个共享的目标URL")
204
+ return None, None
205
+
206
+ # 2. 读取全局设置
207
+ global_settings = {
208
+ "headless": clean_env_value(os.getenv("CAMOUFOX_HEADLESS")) or "virtual",
209
+ "url": shared_url # 所有实例都使用这个URL
210
+ }
211
+
212
+ proxy_value = clean_env_value(os.getenv("CAMOUFOX_PROXY"))
213
+ if proxy_value:
214
+ global_settings["proxy"] = proxy_value
215
+
216
+ # 3. 使用CookieManager检测所有Cookie来源
217
+ cookie_manager = CookieManager(logger)
218
+ sources = cookie_manager.detect_all_sources()
219
+
220
+ # 检查是否有任何Cookie来源
221
+ if not sources:
222
+ logger.error("错误: 未找到任何Cookie来源(既没有JSON文件,也没有环境变量Cookie)")
223
+ return None, None
224
+
225
+ # 4. 为每个Cookie来源创建实例配置
226
+ instances = []
227
+ for source in sources:
228
+ if source.type == "file":
229
+ instances.append({
230
+ "cookie_file": source.identifier,
231
+ "cookie_source": source
232
+ })
233
+ elif source.type == "env_var":
234
+ # 从环境变量名中提取索引,如 "USER_COOKIE_1" -> 1
235
+ env_index = source.identifier.split("_")[-1]
236
+ try:
237
+ env_cookie_index = int(env_index)
238
+ except ValueError:
239
+ logger.warning(f"警告: 无法解析 Cookie 环境变量索引,跳过来源: {source.identifier}")
240
+ continue
241
+
242
+ instance_config = {
243
+ "cookie_file": None,
244
+ "env_cookie_index": env_cookie_index,
245
+ "cookie_source": source
246
+ }
247
+
248
+ # 支持每实例独立代理:CAMOUFOX_PROXY_N 对应 USER_COOKIE_N
249
+ instance_proxy_var = f"CAMOUFOX_PROXY_{env_cookie_index}"
250
+ instance_proxy_value = clean_env_value(os.getenv(instance_proxy_var))
251
+ if instance_proxy_value:
252
+ instance_config["proxy"] = instance_proxy_value
253
+ logger.info(f"检测到 {source.display_name} 的独立代理配置: {instance_proxy_var}")
254
+
255
+ instances.append(instance_config)
256
+
257
+ logger.info(f"将启动 {len(instances)} 个浏览器实例")
258
+
259
+ return global_settings, instances
260
+
261
+ def start_browser_instances(run_mode="standalone"):
262
+ """启动浏览器实例的核心逻辑"""
263
+ global app_running, process_manager, shutdown_event
264
+
265
+ log_dir = logs_dir()
266
+ logger = setup_logging(str(log_dir / 'app.log'))
267
+ logger.info("---------------------Camoufox 实例管理器开始启动---------------------")
268
+ start_delay = int(os.getenv("INSTANCE_START_DELAY", "30"))
269
+ logger.info(f"运行模式: {run_mode}; 实例启动间隔: {start_delay} 秒")
270
+
271
+ global_settings, instance_profiles = load_instance_configurations(logger)
272
+ if not instance_profiles:
273
+ logger.error("错误: 环境变量中未找到任何实例配置")
274
+ return
275
+
276
+ for i, profile in enumerate(instance_profiles, 1):
277
+ if not app_running:
278
+ break
279
+
280
+ final_config = global_settings.copy()
281
+ final_config.update(profile)
282
+
283
+ if 'url' not in final_config:
284
+ logger.warning(f"警告: 跳过一个无效的配置项 (缺少 url): {profile}")
285
+ continue
286
+
287
+ cookie_source = final_config.get('cookie_source')
288
+
289
+ if cookie_source:
290
+ if cookie_source.type == "file":
291
+ logger.info(
292
+ f"正在启动第 {i}/{len(instance_profiles)} 个浏览器实例 (file: {cookie_source.display_name})..."
293
+ )
294
+ elif cookie_source.type == "env_var":
295
+ logger.info(
296
+ f"正在启动第 {i}/{len(instance_profiles)} 个浏览器实例 (env: {cookie_source.display_name})..."
297
+ )
298
+ else:
299
+ logger.error(f"错误: 配置中缺少cookie_source对象")
300
+ continue
301
+
302
+ # 传递 shutdown_event 给子进程
303
+ process = multiprocessing.Process(target=run_browser_instance, args=(final_config, shutdown_event))
304
+ process.start()
305
+ # 等待一小段时间让进程获得PID,然后再添加到管理器
306
+ time.sleep(0.1)
307
+ process_manager.add_process(process, final_config)
308
+
309
+ # 等待配置的时间,避免并发启动导致的高CPU占用
310
+ # 即使是最后一个实例,也等待一段时间让其初始化,然后再进入主循环
311
+ time.sleep(start_delay)
312
+
313
+ # 等待所有进程
314
+ previous_count = None
315
+ last_log_time = 0
316
+ try:
317
+ while app_running:
318
+ alive_processes = process_manager.get_alive_processes()
319
+ current_count = len(alive_processes)
320
+
321
+ # 仅在数量变化或间隔一段时间后再记录,避免过于频繁的日志
322
+ now = time.time()
323
+ if current_count != previous_count or now - last_log_time >= 600:
324
+ logger.info(f"当前运行的浏览器实例数: {current_count}")
325
+ previous_count = current_count
326
+ last_log_time = now
327
+
328
+ if not alive_processes:
329
+ logger.info("所有浏览器进程已结束,主进程即将退出")
330
+ break
331
+
332
+ # 等待进程并清理死进程
333
+ for process in alive_processes:
334
+ try:
335
+ process.join(timeout=1)
336
+ except:
337
+ pass
338
+
339
+ time.sleep(1)
340
+ except KeyboardInterrupt:
341
+ logger.info("捕获到键盘中断信号,等待信号处理器完成关闭...")
342
+ # 不在这里关闭进程,让信号处理器统一处理
343
+ pass
344
+
345
+ # 确保在所有进程结束后退出
346
+ logger.info("浏览器实例管理器运行结束")
347
+
348
+ def run_standalone_mode():
349
+ """独立模式"""
350
+ global app_running
351
+ app_running = True
352
+
353
+ start_browser_instances(run_mode="standalone")
354
+
355
+ def run_server_mode():
356
+ """服务器模式"""
357
+ global app_running, flask_app
358
+
359
+ log_dir = logs_dir()
360
+ server_logger = setup_logging(str(log_dir / 'app.log'), prefix="server")
361
+
362
+ # 动态导入 Flask(只在需要时)
363
+ try:
364
+ from flask import Flask, jsonify
365
+ flask_app = Flask(__name__)
366
+ except ImportError:
367
+ server_logger.error("错误: 服务器模式需要 Flask,请安装: pip install flask")
368
+ return
369
+
370
+ app_running = True
371
+
372
+ # 在后台线程中启动浏览器实例
373
+ browser_thread = threading.Thread(target=lambda: start_browser_instances(run_mode="server"), daemon=True)
374
+ browser_thread.start()
375
+
376
+ # 定义路由
377
+ @flask_app.route('/health')
378
+ def health_check():
379
+ """健康检查端点"""
380
+ global process_manager
381
+ running_count = process_manager.get_alive_count()
382
+ total_count = process_manager.get_count()
383
+ return jsonify({
384
+ 'status': 'healthy',
385
+ 'browser_instances': total_count,
386
+ 'running_instances': running_count,
387
+ 'message': f'Application is running with {running_count} active browser instances'
388
+ })
389
+
390
+ @flask_app.route('/')
391
+ def index():
392
+ """主页端点"""
393
+ global process_manager
394
+ running_count = process_manager.get_alive_count()
395
+ total_count = process_manager.get_count()
396
+ return jsonify({
397
+ 'status': 'running',
398
+ 'browser_instances': total_count,
399
+ 'running_instances': running_count,
400
+ 'run_mode': 'server',
401
+ 'message': 'Camoufox Browser Automation is running in server mode'
402
+ })
403
+
404
+ # 禁用 Flask 的默认日志
405
+ import logging
406
+ log = logging.getLogger('werkzeug')
407
+ log.setLevel(logging.ERROR)
408
+
409
+ # 启动 Flask 服务器
410
+ try:
411
+ flask_app.run(host='0.0.0.0', port=7860, debug=False)
412
+ except KeyboardInterrupt:
413
+ server_logger.info("服务器正在关闭...")
414
+
415
+ def signal_handler(signum, frame):
416
+ """统一的信号处理器 - 只有主进程应该执行这个逻辑"""
417
+ global app_running, process_manager, shutdown_event
418
+
419
+ # 立即设置日志,确保能看到后续信息
420
+ logger = setup_logging(str(logs_dir() / 'app.log'), prefix="signal")
421
+ logger.info(f"接收到信号 {signum},开始处理...")
422
+
423
+ # 检查是否是主进程,防止子进程执行关闭逻辑
424
+ current_pid = os.getpid()
425
+
426
+ # 使用一个简单的方法来判断:如果是子进程,通常没有全局变量 process_manager 的控制权
427
+ # 或者通过判断 multiprocessing.current_process().name
428
+ if multiprocessing.current_process().name != 'MainProcess':
429
+ # 子进程接收到信号,通常应该由主进程来管理,或者子进程会因为主进程发送的SIGTERM而终止
430
+ # 这里我们选择忽略,让主进程通过terminate来管理,或者子进程通过shutdown_event来退出
431
+ logger.info(f"子进程 {current_pid} 接收到信号 {signum},忽略主进程信号处理逻辑")
432
+ return
433
+
434
+ logger.info(f"主进程 {current_pid} 接收到信号 {signum},正在关闭应用...")
435
+
436
+ # 1. 立即设置全局标志,阻止新的进程创建
437
+ app_running = False
438
+
439
+ # 2. 设置跨进程关闭事件,通知所有子进程优雅退出
440
+ try:
441
+ shutdown_event.set()
442
+ logger.info("已设置全局关闭事件 (shutdown_event)")
443
+ except Exception as e:
444
+ logger.error(f"设置关闭事件时发生错误: {e}")
445
+
446
+ # 3. 调用进程管理器的优雅终止方法
447
+ try:
448
+ process_manager.terminate_all(timeout=10)
449
+ except Exception as e:
450
+ logger.error(f"调用 terminate_all 时发生错误: {e}")
451
+
452
+ logger.info("应用关闭流程结束,主进程退出")
453
+ sys.exit(0)
454
+
455
+ def main():
456
+ """主入口函数"""
457
+ # 初始化必要的目录
458
+ ensure_dir(logs_dir())
459
+ ensure_dir(cookies_dir())
460
+
461
+ # 注册信号处理器 - 添加更多信号的捕获
462
+ signal.signal(signal.SIGTERM, signal_handler)
463
+ signal.signal(signal.SIGINT, signal_handler)
464
+ # 在某些环境中可能还有其他信号
465
+ try:
466
+ signal.signal(signal.SIGQUIT, signal_handler)
467
+ except (ValueError, AttributeError):
468
+ pass
469
+ try:
470
+ signal.signal(signal.SIGHUP, signal_handler)
471
+ except (ValueError, AttributeError):
472
+ pass
473
+
474
+ # 检查运行模式环境变量
475
+ hg_mode = os.getenv('HG', '').lower()
476
+
477
+ if hg_mode == 'true':
478
+ run_server_mode()
479
+ else:
480
+ run_standalone_mode()
481
+
482
+ if __name__ == "__main__":
483
+ multiprocessing.freeze_support()
484
+ main()
requirements.txt ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ camoufox[geoip]==0.4.11
2
+ aiohappyeyeballs==2.6.1
3
+ aiohttp==3.12.13
4
+ aiosignal==1.3.2
5
+ attrs==25.3.0
6
+ browserforge==1.2.3
7
+ certifi==2025.6.15
8
+ charset-normalizer==3.4.2
9
+ click==8.2.1
10
+ frozenlist==1.7.0
11
+ flask==3.0.0
12
+ geoip2==5.1.0
13
+ greenlet==3.2.3
14
+ idna==3.10
15
+ language-tags==1.2.0
16
+ lxml==5.4.0
17
+ maxminddb==2.7.0
18
+ multidict==6.5.0
19
+ numpy==2.3.0
20
+ orjson==3.10.18
21
+ platformdirs==4.3.8
22
+ playwright==1.52.0
23
+ propcache==0.3.2
24
+ pyee==13.0.0
25
+ PySocks==1.7.1
26
+ requests==2.32.4
27
+ screeninfo==0.8.1
28
+ tqdm==4.67.1
29
+ typing_extensions==4.14.0
30
+ ua-parser==1.0.1
31
+ ua-parser-builtins==0.18.0.post1
32
+ urllib3==2.4.0
33
+ yarl==1.20.1
34
+ python-dotenv>=1.0.0
utils/common.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 通用工具函数
3
+ 提供项目中常用的基础功能
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from urllib.parse import urlsplit, unquote
9
+
10
+ def clean_env_value(value):
11
+ """
12
+ 清理环境变量值,去除首尾空白字符
13
+
14
+ Args:
15
+ value: 环境变量的原始值
16
+
17
+ Returns:
18
+ str or None: 清理后的值,如果为空或None则返回None
19
+ """
20
+ if value is None:
21
+ return None
22
+ stripped = value.strip()
23
+ return stripped or None
24
+
25
+
26
+ def parse_headless_mode(headless_setting):
27
+ """
28
+ 解析headless模式配置
29
+
30
+ Args:
31
+ headless_setting: headless配置值
32
+
33
+ Returns:
34
+ bool or str: True表示headless,False表示有界面,'virtual'表示虚拟模式
35
+ """
36
+ if str(headless_setting).lower() == 'true':
37
+ return True
38
+ elif str(headless_setting).lower() == 'false':
39
+ return False
40
+ else:
41
+ return 'virtual'
42
+
43
+
44
+ def ensure_dir(path):
45
+ """
46
+ 确保目录存在,如果不存在则创建
47
+
48
+ Args:
49
+ path: 目录路径(可以是字符串或Path对象)
50
+ """
51
+ if isinstance(path, str):
52
+ path = Path(path)
53
+ os.makedirs(path, exist_ok=True)
54
+
55
+
56
+ def parse_proxy_config(proxy_value):
57
+ """
58
+ 解析代理配置字符串为 Playwright/Camoufox 需要的 dict 结构
59
+
60
+ 支持格式:
61
+ - scheme://user:pass@host:port
62
+ - scheme://host:port
63
+ - host:port
64
+ - user:pass@host:port (默认 http)
65
+
66
+ Returns:
67
+ dict or None: {"server": "...", "username": "...", "password": "..."} 或 None
68
+ """
69
+ if not proxy_value:
70
+ return None
71
+
72
+ proxy_value = proxy_value.strip()
73
+ if not proxy_value:
74
+ return None
75
+
76
+ if "://" in proxy_value:
77
+ parsed = urlsplit(proxy_value)
78
+ if not parsed.hostname:
79
+ return {"server": proxy_value}
80
+ scheme = parsed.scheme
81
+ else:
82
+ parsed = urlsplit(f"//{proxy_value}")
83
+ if not parsed.hostname:
84
+ return {"server": proxy_value}
85
+ scheme = "http"
86
+
87
+ host = parsed.hostname
88
+ if host and ":" in host and not host.startswith("["):
89
+ host = f"[{host}]"
90
+ server = f"{scheme}://{host}"
91
+ if parsed.port:
92
+ server += f":{parsed.port}"
93
+
94
+ result = {"server": server}
95
+ if parsed.username:
96
+ result["username"] = unquote(parsed.username)
97
+ if parsed.password:
98
+ result["password"] = unquote(parsed.password)
99
+ return result
utils/cookie_handler.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def convert_cookie_editor_to_playwright(cookies_from_editor, logger=None):
2
+ """
3
+ 将从 Cookie-Editor 插件导出的 Cookie 列表转换为 Playwright 兼容的格式。
4
+ """
5
+ playwright_cookies = []
6
+
7
+ for cookie in cookies_from_editor:
8
+ pw_cookie = {}
9
+ for key in ['name', 'value', 'domain', 'path', 'httpOnly', 'secure']:
10
+ if key in cookie:
11
+ pw_cookie[key] = cookie[key]
12
+ if cookie.get('session', False):
13
+ pw_cookie['expires'] = -1
14
+ elif 'expirationDate' in cookie:
15
+ if cookie['expirationDate'] is not None:
16
+ pw_cookie['expires'] = int(cookie['expirationDate'])
17
+ else:
18
+ pw_cookie['expires'] = -1
19
+
20
+ if 'sameSite' in cookie:
21
+ same_site_value = str(cookie['sameSite']).lower()
22
+ if same_site_value == 'no_restriction':
23
+ pw_cookie['sameSite'] = 'None'
24
+ elif same_site_value in ['lax', 'strict']:
25
+ pw_cookie['sameSite'] = same_site_value.capitalize()
26
+ elif same_site_value == 'unspecified':
27
+ pw_cookie['sameSite'] = 'Lax'
28
+
29
+ if all(key in pw_cookie for key in ['name', 'value', 'domain', 'path']):
30
+ playwright_cookies.append(pw_cookie)
31
+ else:
32
+ if logger:
33
+ logger.warning(f"跳过一个格式不完整的 Cookie: {cookie}")
34
+
35
+ return playwright_cookies
36
+
37
+
38
+ def convert_kv_to_playwright(kv_string, default_domain=".google.com", logger=None):
39
+ """
40
+ 将键值对格式的 Cookie 字符串转换为 Playwright 兼容的格式。
41
+
42
+ Args:
43
+ kv_string (str): 包含 Cookie 的键值对字符串,格式为 "name1=value1; name2=value2; ..."
44
+ default_domain (str): 默认域名,默认为".google.com"
45
+ logger: 日志记录器
46
+
47
+ Returns:
48
+ list: Playwright 兼容的 Cookie 列表
49
+ """
50
+ playwright_cookies = []
51
+
52
+ # 按分号分割 Cookie
53
+ cookie_pairs = kv_string.split(';')
54
+
55
+ for pair in cookie_pairs:
56
+ pair = pair.strip() # 去除首尾空白字符
57
+
58
+ if not pair: # 跳过空字符串
59
+ continue
60
+
61
+ # 跳过无效的 Cookie(不包含等号)
62
+ if '=' not in pair:
63
+ if logger:
64
+ logger.warning(f"跳过无效的 Cookie 格式: '{pair}'")
65
+ continue
66
+
67
+ # 分割name和value
68
+ name, value = pair.split('=', 1) # 只分割第一个等号
69
+ name = name.strip()
70
+ value = value.strip()
71
+
72
+ if not name: # 跳过空名称
73
+ if logger:
74
+ logger.warning(f"跳过空名称的 Cookie: '{pair}'")
75
+ continue
76
+
77
+ # 构造 Playwright 格式的 Cookie
78
+ pw_cookie = {
79
+ 'name': name,
80
+ 'value': value,
81
+ 'domain': default_domain,
82
+ 'path': '/',
83
+ 'expires': -1, # 默认为会话 Cookie
84
+ 'httpOnly': False, # KV 格式无法确定 httpOnly 状态,默认为 False
85
+ 'secure': True, # 假设为安全 Cookie
86
+ 'sameSite': 'Lax' # 默认 SameSite 策略
87
+ }
88
+
89
+ playwright_cookies.append(pw_cookie)
90
+
91
+ if logger:
92
+ logger.debug(f"成功转换 Cookie: {name} -> domain={default_domain}")
93
+
94
+ return playwright_cookies
95
+
96
+
97
+ def auto_convert_to_playwright(cookie_data, default_domain=".google.com", logger=None):
98
+ """
99
+ 自动识别 Cookie 数据格式并转换为 Playwright 兼容格式。
100
+ 支持两种输入格式:
101
+ 1. JSON 数组 (Cookie-Editor 导出格式)
102
+ 2. KV 字符串 (键值对格式: "name1=value1; name2=value2; ...")
103
+
104
+ Args:
105
+ cookie_data: Cookie 数据,可以是 list (JSON格式) 或 str (KV格式)
106
+ default_domain (str): KV格式使用的默认域名,默认为".google.com"
107
+ logger: 日志记录器
108
+
109
+ Returns:
110
+ list: Playwright 兼容的 Cookie 列表
111
+
112
+ Raises:
113
+ ValueError: 当格式无法识别时抛出异常
114
+ """
115
+ # 格式1: JSON 数组格式 (Cookie-Editor 导出格式)
116
+ if isinstance(cookie_data, list):
117
+ if logger:
118
+ logger.debug(f"检测到 JSON 数组格式的 Cookie 数据,共 {len(cookie_data)} 个条目")
119
+ return convert_cookie_editor_to_playwright(cookie_data, logger=logger)
120
+
121
+ # 格式2: KV 字符串格式
122
+ if isinstance(cookie_data, str):
123
+ # 去除首尾空白字符
124
+ cookie_str = cookie_data.strip()
125
+
126
+ if not cookie_str:
127
+ if logger:
128
+ logger.warning("收到空的 Cookie 字符串")
129
+ return []
130
+
131
+ if logger:
132
+ logger.debug(f"检测到 KV 字符串格式的 Cookie 数据")
133
+
134
+ return convert_kv_to_playwright(
135
+ cookie_str,
136
+ default_domain=default_domain,
137
+ logger=logger
138
+ )
139
+
140
+ # 无法识别的格式
141
+ error_msg = f"无法识别的 Cookie 数据格式: {type(cookie_data).__name__}"
142
+ if logger:
143
+ logger.error(error_msg)
144
+ raise ValueError(error_msg)
utils/cookie_manager.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 统一的Cookie管理器
3
+ 整合JSON文件和环境变量Cookie的检测、加载和管理功能
4
+ """
5
+
6
+ import os
7
+ import json
8
+ from dataclasses import dataclass
9
+ from typing import List, Dict, Optional
10
+ from utils.paths import cookies_dir
11
+ from utils.cookie_handler import auto_convert_to_playwright
12
+ from utils.common import clean_env_value
13
+
14
+ @dataclass
15
+ class CookieSource:
16
+ """Cookie来源的统一表示"""
17
+ type: str # "file" | "env_var"
18
+ identifier: str # filename or "USER_COOKIE_1"
19
+ display_name: str # 显示名称
20
+
21
+ def __str__(self):
22
+ return f"{self.type}:{self.identifier}"
23
+
24
+
25
+ class CookieManager:
26
+ """
27
+ 统一的Cookie管理器
28
+ 负责检测、加载和缓存所有来源的Cookie数据
29
+ """
30
+
31
+ def __init__(self, logger=None):
32
+ self.logger = logger
33
+ self._detected_sources: Optional[List[CookieSource]] = None
34
+ self._cookie_cache: Dict[str, List[Dict]] = {}
35
+
36
+ def detect_all_sources(self) -> List[CookieSource]:
37
+ """
38
+ 检测所有可用的Cookie来源(JSON文件 + 环境变量)
39
+ 结果会被缓存,避免重复扫描
40
+ """
41
+ if self._detected_sources is not None:
42
+ return self._detected_sources
43
+
44
+ sources = []
45
+
46
+ # 1. 扫描Cookies目录中的JSON文件
47
+ try:
48
+ cookie_path = cookies_dir()
49
+ if os.path.isdir(cookie_path):
50
+ cookie_files = [f for f in os.listdir(cookie_path) if f.lower().endswith('.json')]
51
+
52
+ for cookie_file in cookie_files:
53
+ source = CookieSource(
54
+ type="file",
55
+ identifier=cookie_file,
56
+ display_name=cookie_file
57
+ )
58
+ sources.append(source)
59
+
60
+ if cookie_files and self.logger:
61
+ self.logger.info(f"发现 {len(cookie_files)} 个 Cookie 文件")
62
+ elif self.logger:
63
+ self.logger.info(f"在 {cookie_path} 目录下未找到任何格式的 Cookie 文件")
64
+ else:
65
+ if self.logger:
66
+ self.logger.error(f"Cookie 目录不存在: {cookie_path}")
67
+
68
+ except Exception as e:
69
+ if self.logger:
70
+ self.logger.error(f"扫描 Cookie 目录时出错: {e}")
71
+
72
+ # 2. 扫描USER_COOKIE环境变量
73
+ cookie_index = 1
74
+ env_cookie_count = 0
75
+
76
+ while True:
77
+ env_var_name = f"USER_COOKIE_{cookie_index}"
78
+ env_value = clean_env_value(os.getenv(env_var_name))
79
+
80
+ if not env_value:
81
+ if cookie_index == 1 and self.logger:
82
+ self.logger.info(f"未检测到任何 USER_COOKIE 环境变量")
83
+ break
84
+
85
+ source = CookieSource(
86
+ type="env_var",
87
+ identifier=env_var_name,
88
+ display_name=env_var_name
89
+ )
90
+ sources.append(source)
91
+
92
+ env_cookie_count += 1
93
+ cookie_index += 1
94
+
95
+ if env_cookie_count > 0 and self.logger:
96
+ self.logger.info(f"发现 {env_cookie_count} 个 Cookie 环境变量")
97
+
98
+ # 缓存结果
99
+ self._detected_sources = sources
100
+ return sources
101
+
102
+ def load_cookies(self, source: CookieSource) -> List[Dict]:
103
+ """
104
+ 从指定来源加载Cookie数据
105
+
106
+ Args:
107
+ source: Cookie来源对象
108
+
109
+ Returns:
110
+ Playwright兼容的cookie列表
111
+ """
112
+ cache_key = str(source)
113
+
114
+ # 检查缓存
115
+ if cache_key in self._cookie_cache:
116
+ if self.logger:
117
+ self.logger.debug(f"从缓存加载 Cookie: {source.display_name}")
118
+ return self._cookie_cache[cache_key]
119
+
120
+ cookies = []
121
+
122
+ try:
123
+ if source.type == "file":
124
+ cookies = self._load_from_file(source.identifier)
125
+ elif source.type == "env_var":
126
+ cookies = self._load_from_env(source.identifier)
127
+ else:
128
+ if self.logger:
129
+ self.logger.error(f"未知的 Cookie 来源类型: {source.type}")
130
+ return []
131
+
132
+ # 缓存结果
133
+ self._cookie_cache[cache_key] = cookies
134
+
135
+ if self.logger:
136
+ self.logger.info(f"从 {source.display_name} 加载了 {len(cookies)} 个 Cookie 数据")
137
+
138
+ except Exception as e:
139
+ if self.logger:
140
+ self.logger.error(f"从 {source.display_name} 加载 Cookie 时出错: {e}")
141
+ return []
142
+
143
+ return cookies
144
+
145
+ def _load_from_file(self, filename: str) -> List[Dict]:
146
+ """从文件加载 Cookie,自动识别 JSON 或 KV 格式"""
147
+ cookie_path = cookies_dir() / filename
148
+
149
+ if not os.path.exists(cookie_path):
150
+ raise FileNotFoundError(f"Cookie 文件不存在: {cookie_path}")
151
+
152
+ with open(cookie_path, 'r', encoding='utf-8') as f:
153
+ file_content = f.read().strip()
154
+
155
+ # 尝试解析为 JSON
156
+ try:
157
+ cookies_from_file = json.loads(file_content)
158
+ # JSON 解析成功,使用自动转换函数
159
+ return auto_convert_to_playwright(
160
+ cookies_from_file,
161
+ default_domain=".google.com",
162
+ logger=self.logger
163
+ )
164
+ except json.JSONDecodeError:
165
+ # JSON 解析失败,当作 KV 格式处理
166
+ if self.logger:
167
+ self.logger.info(f"文件 {filename} 不是有效的 JSON 格式,尝试作为 KV 格式解析")
168
+ return auto_convert_to_playwright(
169
+ file_content,
170
+ default_domain=".google.com",
171
+ logger=self.logger
172
+ )
173
+
174
+ def _load_from_env(self, env_var_name: str) -> List[Dict]:
175
+ """从环境变量加载 Cookie,自动识别 JSON 或 KV 格式"""
176
+ env_value = clean_env_value(os.getenv(env_var_name))
177
+
178
+ if not env_value:
179
+ raise ValueError(f"环境变量 {env_var_name} 不存在或为空")
180
+
181
+ # 尝试解析为 JSON
182
+ try:
183
+ cookies_from_env = json.loads(env_value)
184
+ # JSON 解析成功,使用自动转换函数
185
+ return auto_convert_to_playwright(
186
+ cookies_from_env,
187
+ default_domain=".google.com",
188
+ logger=self.logger
189
+ )
190
+ except json.JSONDecodeError:
191
+ # JSON 解析失败,当作 KV 格式处理
192
+ if self.logger:
193
+ self.logger.debug(f"环境变量 {env_var_name} 不是有效的 JSON 格式,作为 KV 格式解析")
194
+ return auto_convert_to_playwright(
195
+ env_value,
196
+ default_domain=".google.com",
197
+ logger=self.logger
198
+ )
utils/logger.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import datetime
3
+ import os
4
+
5
+ def custom_timezone_converter(timestamp):
6
+ """
7
+ 将时间戳转换为指定时区 (默认 UTC+8/Asia/Shanghai) 的 struct_time。
8
+ 时区通过环境变量 TZ_OFFSET (小时数) 配置。
9
+ """
10
+
11
+ # 尝试从环境变量获取偏移量,默认为 8 (北京时间)
12
+ try:
13
+ offset_hours = float(os.getenv('TZ_OFFSET', 8))
14
+ except (ValueError, TypeError):
15
+ offset_hours = 8
16
+
17
+ # 创建时区对象
18
+ target_timezone = datetime.timezone(datetime.timedelta(hours=offset_hours))
19
+
20
+ # 转换时间
21
+ dt_time = datetime.datetime.fromtimestamp(timestamp, target_timezone)
22
+ return dt_time.timetuple()
23
+
24
+ def setup_logging(log_file, prefix=None, level=logging.INFO):
25
+ """
26
+ 配置日志记录器,使其输出到文件和控制台。
27
+ 支持一个可选的前缀,用于标识日志来源。
28
+
29
+ 每个 (进程ID, prefix) 组合对应一个独立的 logger,首次调用时初始化,
30
+ 后续调用直接返回已有 logger,避免重复创建 handler 导致内存泄漏。
31
+
32
+ 时间显示默认为 UTC+8 (北京时间),可通过环境变量 TZ_OFFSET 修改。
33
+
34
+ :param log_file: 日志文件的路径(仅首次调用生效)。
35
+ :param prefix: (可选) 要添加到每条日志消息开头的字符串前缀。
36
+ :param level: 日志级别(仅首次调用生效)。
37
+ """
38
+ # 使用进程ID + 前缀作为 logger 名称,避免不同进程/实例的 logger 互相干扰
39
+ logger_name = f'camoufox.{os.getpid()}'
40
+ if prefix:
41
+ logger_name += f'.{prefix}'
42
+
43
+ logger = logging.getLogger(logger_name)
44
+ logger.setLevel(level)
45
+
46
+ # 如果该 logger 已有 handlers,说明已初始化过,直接返回
47
+ if logger.hasHandlers():
48
+ return logger
49
+
50
+ base_format = '%(asctime)s - %(process)d - %(levelname)s - %(message)s'
51
+
52
+ if prefix:
53
+ log_format = f'%(asctime)s - %(process)d - %(levelname)s - {prefix} - %(message)s'
54
+ else:
55
+ log_format = base_format
56
+
57
+ fh = logging.FileHandler(log_file)
58
+ fh.setLevel(level)
59
+
60
+ ch = logging.StreamHandler()
61
+ ch.setLevel(level)
62
+
63
+ formatter = logging.Formatter(log_format)
64
+ # 设置自定义的时间转换器
65
+ formatter.converter = custom_timezone_converter
66
+
67
+ fh.setFormatter(formatter)
68
+ ch.setFormatter(formatter)
69
+
70
+ logger.addHandler(fh)
71
+ logger.addHandler(ch)
72
+
73
+ logger.propagate = False
74
+
75
+ return logger
utils/paths.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from functools import lru_cache
3
+ from pathlib import Path
4
+
5
+
6
+ @lru_cache(maxsize=1)
7
+ def project_root() -> Path:
8
+ """
9
+ 返回代码仓库根目录,使调用者能够构建不依赖当前工作目录的绝对路径。
10
+ """
11
+ env_root = os.getenv("CAMOUFOX_PROJECT_ROOT")
12
+ if env_root:
13
+ return Path(env_root).expanduser().resolve()
14
+
15
+ current = Path(__file__).resolve()
16
+ for parent in current.parents:
17
+ if (parent / "cookies").exists():
18
+ return parent
19
+
20
+ # 如果标记目录缺失,则回退到原始行为
21
+ return current.parents[min(2, len(current.parents) - 1)]
22
+
23
+
24
+ def logs_dir() -> Path:
25
+ """存储日志文件和截图的根级目录。"""
26
+ return project_root() / "logs"
27
+
28
+
29
+ def cookies_dir() -> Path:
30
+ """存储持久化Cookie JSON文件的根级目录。"""
31
+ return project_root() / "cookies"
utils/url_helper.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ URL处理辅助函数
3
+
4
+ 提供URL解析和路径提取功能,用于导航验证中的域名无关匹配。
5
+ """
6
+
7
+ from urllib.parse import urlparse
8
+
9
+
10
+ def extract_url_path(url: str) -> str:
11
+ """
12
+ 提取URL的路径和查询参数部分,忽略协议和域名差异
13
+
14
+ 用于验证导航是否到达正确页面,允许域名重定向。
15
+
16
+ Args:
17
+ url: 完整URL字符串
18
+
19
+ Returns:
20
+ 路径+查询参数+片段(例如:"/apps/drive/123?param=value#section")
21
+ 如果URL为空或无效,返回空字符串
22
+
23
+ Examples:
24
+ >>> extract_url_path("https://ai.studio/apps/drive/123?param=value")
25
+ '/apps/drive/123?param=value'
26
+
27
+ >>> extract_url_path("https://aistudio.google.com/apps/drive/123")
28
+ '/apps/drive/123'
29
+
30
+ >>> extract_url_path("https://example.com/path")
31
+ '/path'
32
+ """
33
+ if not url:
34
+ return ""
35
+
36
+ try:
37
+ parsed = urlparse(url)
38
+ result = parsed.path
39
+ if parsed.query:
40
+ result += '?' + parsed.query
41
+ if parsed.fragment:
42
+ result += '#' + parsed.fragment
43
+ return result
44
+ except Exception:
45
+ # 如果URL格式无效,返回空字符串
46
+ return ""
47
+
48
+
49
+ def mask_path_for_logging(path: str) -> str:
50
+ """
51
+ 对路径进行脱敏处理,用于日志输出
52
+
53
+ 脱敏规则:
54
+ 1. 对于 /apps/drive/XXXXXXXXXX 路径,保留头4位和尾4位,中间用***代替
55
+ 2. 如果不是 /apps/drive/XXXXXXXXXX 路径,返回完整路径
56
+
57
+ Args:
58
+ path: URL路径字符串
59
+
60
+ Returns:
61
+ 脱敏后的路径字符串
62
+
63
+ Examples:
64
+ >>> mask_path_for_logging("/apps/drive/abcdef123456")
65
+ '/apps/drive/abcd***3456'
66
+
67
+ >>> mask_path_for_logging("/apps/drive/xyz789")
68
+ '/apps/drive/xyz789'
69
+
70
+ >>> mask_path_for_logging("/other/path")
71
+ '/other/path'
72
+ """
73
+ if not path:
74
+ return ""
75
+
76
+ # 检查是否为 /apps/drive/ 路径
77
+ if path.startswith('/apps/drive/'):
78
+ # 提取路径中的ID部分
79
+ path_parts = path.split('/')
80
+ if len(path_parts) >= 4: # ['', 'apps', 'drive', 'ID']
81
+ drive_id = path_parts[3]
82
+
83
+ # 如果ID长度大于8,则进行脱敏处理
84
+ if len(drive_id) > 8:
85
+ # 使用与URL脱敏相同的格式
86
+ masked_id = f"{drive_id[:4]}***{drive_id[-4:]}"
87
+ # 重新构建路径
88
+ masked_parts = path_parts[:3] + [masked_id] + path_parts[4:]
89
+ return '/'.join(masked_parts)
90
+
91
+ # 如果不符合脱敏条件,返回原始路径
92
+ return path
93
+
94
+
95
+ def mask_url_for_logging(url: str) -> str:
96
+ """
97
+ 对URL进行脱敏处理,用于日志输出
98
+
99
+ 脱敏规则:
100
+ 1. 对于 /apps/drive/XXXXXXXXXX 路径,保留头4位和尾4位,中间用***代替
101
+ 2. 如果不是 /apps/drive/XXXXXXXXXX 路径,返回完整URL
102
+
103
+ Args:
104
+ url: 完整URL字符串
105
+
106
+ Returns:
107
+ 脱敏后的URL字符串
108
+
109
+ Examples:
110
+ >>> mask_url_for_logging("https://ai.studio/apps/drive/abcdef123456")
111
+ 'https://ai.studio/apps/drive/abcd***3456'
112
+
113
+ >>> mask_url_for_logging("https://aistudio.google.com/apps/drive/xyz789")
114
+ 'https://aistudio.google.com/apps/drive/xyz789'
115
+
116
+ >>> mask_url_for_logging("https://example.com/other/path")
117
+ 'https://example.com/other/path'
118
+ """
119
+ if not url:
120
+ return ""
121
+
122
+ try:
123
+ parsed = urlparse(url)
124
+
125
+ # 检查是否为 /apps/drive/ 路径
126
+ if parsed.path.startswith('/apps/drive/'):
127
+ # 提取路径中的ID部分
128
+ path_parts = parsed.path.split('/')
129
+ if len(path_parts) >= 4: # ['', 'apps', 'drive', 'ID']
130
+ drive_id = path_parts[3]
131
+
132
+ # 如果ID长度大于8,则进行脱敏处理
133
+ if len(drive_id) > 8:
134
+ masked_id = f"{drive_id[:4]}***{drive_id[-4:]}"
135
+ # 重新构建路径
136
+ masked_parts = path_parts[:3] + [masked_id] + path_parts[4:]
137
+ masked_path = '/'.join(masked_parts)
138
+
139
+ # 重新构建URL
140
+ result = f"{parsed.scheme}://{parsed.netloc}{masked_path}"
141
+ if parsed.query:
142
+ result += '?' + parsed.query
143
+ if parsed.fragment:
144
+ result += '#' + parsed.fragment
145
+ return result
146
+
147
+ # 如果不符合脱敏条件,返回原始URL
148
+ return url
149
+
150
+ except Exception:
151
+ # 如果URL解析失败,返回原始URL
152
+ return url