hkfires commited on
Commit
615e7b3
·
verified ·
1 Parent(s): d280cfc

feat(browser): add automatic restart with exponential backoff for KeepAliveError

Browse files
Files changed (2) hide show
  1. browser/instance.py +176 -145
  2. browser/navigation.py +4 -1
browser/instance.py CHANGED
@@ -1,9 +1,10 @@
1
  import os
2
  import signal
 
3
  from playwright.sync_api import TimeoutError, Error as PlaywrightError
4
  from utils.logger import setup_logging
5
  from utils.cookie_manager import CookieManager
6
- from browser.navigation import handle_successful_navigation
7
  from browser.cookie_validator import CookieValidator
8
  from camoufox.sync_api import Camoufox
9
  from utils.paths import logs_dir
@@ -69,164 +70,194 @@ def run_browser_instance(config, shutdown_event=None):
69
  screenshot_dir = logs_dir()
70
  ensure_dir(screenshot_dir)
71
 
72
- try:
73
- with Camoufox(**launch_options) as browser:
74
- context = browser.new_context()
75
- context.add_cookies(cookies)
76
- page = context.new_page()
77
 
78
- # 创建Cookie验证器
79
- cookie_validator = CookieValidator(page, context, logger)
 
 
 
80
 
81
- # ####################################################################
82
- # ############ 增强的 page.goto() 错误处理和日志记录 ###############
83
- # ####################################################################
84
-
85
- response = None
86
- try:
87
- logger.info(f"正在导航到: {mask_url_for_logging(expected_url)} (超时设置为 90 秒)")
88
- # page.goto() 会返回一个 response 对象,我们可以用它来获取状态码等信息
89
- response = page.goto(expected_url, wait_until='domcontentloaded', timeout=90000)
 
 
 
90
 
91
- # 检查HTTP响应状态码
92
- if response:
93
- logger.info(f"导航初步成功,服务器响应状态码: {response.status} {response.status_text}")
94
- if not response.ok: # response.ok 检查状态码是否在 200-299 范围内
95
- logger.warning(f"警告:页面加载成功,但HTTP状态码表示错误: {response.status}")
96
- # 即使状态码错误,也保存快照以供分析
97
- page.screenshot(path=os.path.join(screenshot_dir, f"WARN_http_status_{response.status}_{diagnostic_tag}.png"))
98
- else:
99
- # 对于非http/https的导航(如 about:blank),response可能为None
100
- logger.warning("page.goto 未返回响应对象,可能是一个非HTTP导航")
101
-
102
- except TimeoutError:
103
- # 这是最常见的错误:超时
104
- logger.error(f"导航到 {mask_url_for_logging(expected_url)} 超时 (超过90秒)")
105
- logger.error("可能原因:网络连接缓慢、目标网站服务器无响应、代理问题、或页面资源被阻塞")
106
- # 尝试保存诊断信息
107
  try:
108
- # 截图对于看到页面卡在什么状态非常有帮助(例如,空白页、加载中、Chrome错误页)
109
- screenshot_path = os.path.join(screenshot_dir, f"FAIL_timeout_{diagnostic_tag}.png")
110
- page.screenshot(path=screenshot_path, full_page=True)
111
- logger.info(f"已截取超时时的屏幕快照: {screenshot_path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
- # 保存HTML可以帮助分析DOM结构,即使在无头模式下也很有用
114
- html_path = os.path.join(screenshot_dir, f"FAIL_timeout_{diagnostic_tag}.html")
115
- with open(html_path, 'w', encoding='utf-8') as f:
116
- f.write(page.content())
117
- logger.info(f"已保存超时时的页面HTML: {html_path}")
118
- except Exception as diag_e:
119
- logger.error(f"在尝试进行超时诊断(截图/保存HTML)时发生额外错误: {diag_e}")
120
- return # 超时后,后续操作无意义,直接终止
121
-
122
- except PlaywrightError as e:
123
- # 捕获其他Playwright相关的网络错误,例如DNS解析失败、连接被拒绝等
124
- error_message = str(e)
125
- logger.error(f"导航到 {mask_url_for_logging(expected_url)} 时发生 Playwright 网络错误")
126
- logger.error(f"错误详情: {error_message}")
127
 
128
- # Playwright的错误信息通常很具体,例如 "net::ERR_CONNECTION_REFUSED"
129
- if "net::ERR_NAME_NOT_RESOLVED" in error_message:
130
- logger.error("排查建议:检查DNS设置或域名是否正确")
131
- elif "net::ERR_CONNECTION_REFUSED" in error_message:
132
- logger.error("排查建议:目标服务��可能已关闭,或代理/防火墙阻止了连接")
133
- elif "net::ERR_INTERNET_DISCONNECTED" in error_message:
134
- logger.error("排查建议:检查本机的网络连接")
135
 
136
- # 同样,尝试截图,尽管此时页面可能完全无法访问
137
- try:
138
- screenshot_path = os.path.join(screenshot_dir, f"FAIL_network_error_{diagnostic_tag}.png")
139
- page.screenshot(path=screenshot_path)
140
- logger.info(f"已截取网络错误时的屏幕快照: {screenshot_path}")
141
- except Exception as diag_e:
142
- logger.error(f"在尝试进行网络错误诊断(截图)时发生额外错误: {diag_e}")
143
- return # 网络错误,终止
144
-
145
- # --- 如果导航没有抛出异常,继续执行后续逻辑 ---
146
-
147
- logger.info("页面初步加载完成,正在检查并处理初始弹窗...")
148
- page.wait_for_timeout(2000)
149
-
150
- final_url = page.url
151
- logger.info(f"导航完成。最终URL为: {mask_url_for_logging(final_url)}")
152
 
153
- # ... 你原有的URL检查逻辑保持不变 ...
154
- if "accounts.google.com/v3/signin/identifier" in final_url:
155
- logger.error("检测到Google登录页面(需要输入邮箱)。Cookie已完全失效")
156
- page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_identifier_page_{diagnostic_tag}.png"))
157
- return
158
 
159
- # 提取路径部分进行匹配(允许域名重定向)
160
- expected_path = extract_url_path(expected_url).split('?')[0]
161
- final_path = extract_url_path(final_url)
162
 
163
- if expected_path and expected_path in final_path:
164
- logger.info(f"URL验证通过。预期路径: {mask_path_for_logging(expected_path)}")
165
 
166
- # --- 新的健壮策略:等待加载指示器消失 ---
167
- # 这是解决竞态条件的关键。错误消息或内容只在初始加载完成后才会出现。
168
- spinner_locator = page.locator('mat-spinner')
169
- try:
170
- logger.info("正在等待加载指示器 (spinner) 消失... (最长等待30秒)")
171
- # 我们等待spinner变为'隐藏'状态或从DOM中消失。
172
- spinner_locator.wait_for(state='hidden', timeout=30000)
173
- logger.info("加载指示器已消失。页面已完成异步加载")
174
- except TimeoutError:
175
- logger.error("页面加载指示器在30秒内未消失。页面可能已卡住")
176
- page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_spinner_stuck_{diagnostic_tag}.png"))
177
- return # 如果页面加载卡住则退出
178
-
179
- # --- 现在我们可以安全地检查错误消息 ---
180
- # 我们使用最具体的文本以避免误判。
181
- auth_error_text = "authentication error"
182
- auth_error_locator = page.get_by_text(auth_error_text, exact=False)
183
-
184
- # 这里我们只需要很短的超时时间,因为页面应该是稳定的。
185
- if auth_error_locator.is_visible(timeout=2000):
186
- logger.error(f"检测到认证失败的错误横幅: '{auth_error_text}'. Cookie已过期或无效")
187
- screenshot_path = os.path.join(screenshot_dir, f"FAIL_auth_error_banner_{diagnostic_tag}.png")
188
- page.screenshot(path=screenshot_path)
 
 
 
 
 
 
 
 
 
 
 
189
 
190
- # html_path = os.path.join(screenshot_dir, f"FAIL_auth_error_banner_{diagnostic_tag}.html")
191
- # with open(html_path, 'w', encoding='utf-8') as f:
192
- # f.write(page.content())
193
- # logger.info(f"已保存包含错误信息的页面HTML: {html_path}")
194
- return # 明确的失败,因此我们退出。
195
-
196
- # --- 如果没有错误,进行最终确认(作为后备方案) ---
197
- logger.info("未检测到认证错误横幅。进行最终确认")
198
- login_button_cn = page.get_by_role('button', name='登录')
199
- login_button_en = page.get_by_role('button', name='Login')
200
-
201
- if login_button_cn.is_visible(timeout=1000) or login_button_en.is_visible(timeout=1000):
202
- logger.error("页面上仍显示'登录'按钮。Cookie无效")
203
- page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_login_button_visible_{diagnostic_tag}.png"))
 
 
 
 
 
204
  return
205
 
206
- # --- 如果所有检查都通过,我们假设成功 ---
207
- logger.info("所有验证通过,确认已成功登录")
 
 
208
 
209
- handle_successful_navigation(page, logger, diagnostic_tag, shutdown_event, cookie_validator)
210
- elif "accounts.google.com/v3/signin/accountchooser" in final_url:
211
- logger.warning("检测到Google账户选择页面。登录失败或Cookie已过期")
212
- page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_chooser_click_failed_{diagnostic_tag}.png"))
213
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  else:
215
- logger.error(f"导航到了意外的URL")
216
- logger.error(f" 预期路径: {mask_path_for_logging(expected_path)}")
217
- logger.error(f" 最终路径: {mask_path_for_logging(final_path)}")
218
- logger.error(f" 最终URL: {mask_url_for_logging(final_url)}")
219
- page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_unexpected_url_{diagnostic_tag}.png"))
220
- return
221
-
222
- except KeyboardInterrupt:
223
- logger.info(f"用户中断,正在关闭...")
224
- except SystemExit as e:
225
- # 捕获Cookie验证失败时的系统退出
226
- if e.code == 1:
227
- logger.error("Cookie验证失败,关闭进程实例")
228
- else:
229
- logger.info(f"实例正常退出,退出码: {e.code}")
230
- except Exception as e:
231
- # 这是一个最终的捕获,用于捕获所有未预料到的错误
232
- logger.exception(f"运行 Camoufox 实例时发生未预料的严重错误: {e}")
 
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 utils.paths import logs_dir
 
70
  screenshot_dir = logs_dir()
71
  ensure_dir(screenshot_dir)
72
 
73
+ # 重启控制变量
74
+ max_retries = int(os.getenv("MAX_RESTART_RETRIES", "5"))
75
+ retry_count = 0
76
+ base_delay = 3
 
77
 
78
+ while True:
79
+ # 检查是否收到全局关闭信号
80
+ if shutdown_event and shutdown_event.is_set():
81
+ logger.info("检测到全局关闭事件,浏览器实例不再启动,准备退出")
82
+ return
83
 
84
+ try:
85
+ with Camoufox(**launch_options) as browser:
86
+ context = browser.new_context()
87
+ context.add_cookies(cookies)
88
+ page = context.new_page()
89
+
90
+ # 创建Cookie验证器
91
+ cookie_validator = CookieValidator(page, context, logger)
92
+
93
+ # ####################################################################
94
+ # ############ 增强的 page.goto() 错误处理和日志记录 ###############
95
+ # ####################################################################
96
 
97
+ response = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  try:
99
+ logger.info(f"正在导航到: {mask_url_for_logging(expected_url)} (超时设置为 90 秒)")
100
+ # page.goto() 会返回一个 response 对象,我们可以用它来获取状态码等信息
101
+ response = page.goto(expected_url, wait_until='domcontentloaded', timeout=90000)
102
+
103
+ # 检查HTTP响应状态码
104
+ if response:
105
+ logger.info(f"导航初步成功,服务器响应状态码: {response.status} {response.status_text}")
106
+ if not response.ok: # response.ok 检查状态码是否在 200-299 范围内
107
+ logger.warning(f"警告:页面加载成功,但HTTP状态码表示错误: {response.status}")
108
+ # 即使状态码错误,也保存快照以供分析
109
+ page.screenshot(path=os.path.join(screenshot_dir, f"WARN_http_status_{response.status}_{diagnostic_tag}.png"))
110
+ else:
111
+ # 对于非http/https的导航(如 about:blank),response可能为None
112
+ logger.warning("page.goto 未返回响应对象,可能是一个非HTTP导航")
113
+
114
+ except TimeoutError:
115
+ # 这是最常见的错误:超时
116
+ logger.error(f"导航到 {mask_url_for_logging(expected_url)} 超时 (超过90秒)")
117
+ logger.error("可能原因:网络连接缓慢、目标网站服务器无响应、代理问题、或页面资源被阻塞")
118
+ # 尝试保存诊断信息
119
+ try:
120
+ # 截图对于看到页面卡在什么状态非常有帮助(例如,空白页、加载中、Chrome错误页)
121
+ screenshot_path = os.path.join(screenshot_dir, f"FAIL_timeout_{diagnostic_tag}.png")
122
+ page.screenshot(path=screenshot_path, full_page=True)
123
+ logger.info(f"已截取超时时的屏幕快照: {screenshot_path}")
124
+
125
+ # 保存HTML可以帮助分析DOM结构,即使在无头模式下也很有用
126
+ html_path = os.path.join(screenshot_dir, f"FAIL_timeout_{diagnostic_tag}.html")
127
+ with open(html_path, 'w', encoding='utf-8') as f:
128
+ f.write(page.content())
129
+ logger.info(f"已保存超时时的页面HTML: {html_path}")
130
+ except Exception as diag_e:
131
+ logger.error(f"在尝试进行超时诊断(截图/保存HTML)时发生额外错误: {diag_e}")
132
+ return # 超时后,后续操作无意义,直接终止
133
+
134
+ except PlaywrightError as e:
135
+ # 捕获其他Playwright相关的网络错误,例如DNS解析失败、连接被拒绝等
136
+ error_message = str(e)
137
+ logger.error(f"导航到 {mask_url_for_logging(expected_url)} 时发生 Playwright 网络错误")
138
+ logger.error(f"错误详情: {error_message}")
139
+
140
+ # Playwright的错误信息通常很具体,例如 "net::ERR_CONNECTION_REFUSED"
141
+ if "net::ERR_NAME_NOT_RESOLVED" in error_message:
142
+ logger.error("排查建议:检查DNS设置或域名是否正确")
143
+ elif "net::ERR_CONNECTION_REFUSED" in error_message:
144
+ logger.error("排查建议:目标服务器可能已关闭,或代理/防火墙阻止了连接")
145
+ elif "net::ERR_INTERNET_DISCONNECTED" in error_message:
146
+ logger.error("排查建议:检查本机的网络连接")
147
 
148
+ # 同样,尝试截图,尽管此时页面可能完全无法访问
149
+ try:
150
+ screenshot_path = os.path.join(screenshot_dir, f"FAIL_network_error_{diagnostic_tag}.png")
151
+ page.screenshot(path=screenshot_path)
152
+ logger.info(f"已截取网络错误时的屏幕快照: {screenshot_path}")
153
+ except Exception as diag_e:
154
+ logger.error(f"在尝试进行网络错误诊断(截图)时发生额外错误: {diag_e}")
155
+ return # 网络错误,终止
156
+
157
+ # --- 如果导航没有抛出异常,继续执行后续逻辑 ---
 
 
 
 
158
 
159
+ logger.info("页面初步加载完成,正在检查并处理初始弹窗...")
160
+ page.wait_for_timeout(2000)
 
 
 
 
 
161
 
162
+ final_url = page.url
163
+ logger.info(f"导航完成。最终URL为: {mask_url_for_logging(final_url)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
 
165
+ # ... 你原有的URL检查逻辑保持不变 ...
166
+ if "accounts.google.com/v3/signin/identifier" in final_url:
167
+ logger.error("检测到Google登录页面(需要输入邮箱)。Cookie已完全失效")
168
+ page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_identifier_page_{diagnostic_tag}.png"))
169
+ return
170
 
171
+ # 提取路径部分进行匹配(允许域名重定向)
172
+ expected_path = extract_url_path(expected_url).split('?')[0]
173
+ final_path = extract_url_path(final_url)
174
 
175
+ if expected_path and expected_path in final_path:
176
+ logger.info(f"URL验证通过。预期路径: {mask_path_for_logging(expected_path)}")
177
 
178
+ # --- 新的健壮策略:等待加载指示器消失 ---
179
+ # 这是解决竞态条件的关键。错误消息或内容只在初始加载完成后才会出现。
180
+ spinner_locator = page.locator('mat-spinner')
181
+ try:
182
+ logger.info("正在等待加载指示器 (spinner) 消失... (最长等待30秒)")
183
+ # 我们等待spinner变为'隐藏'状态或从DOM中消失。
184
+ spinner_locator.wait_for(state='hidden', timeout=30000)
185
+ logger.info("加载指示器已消失。页面已完成异步加载")
186
+ except TimeoutError:
187
+ logger.error("页面加载指示器在30秒内未消失。页面可能已卡住")
188
+ page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_spinner_stuck_{diagnostic_tag}.png"))
189
+ raise KeepAliveError("页面加载指示器超时")
190
+
191
+ # --- 现在我们可以安全地检查错误消息 ---
192
+ # 我们使用最具体的文本以避免误判。
193
+ auth_error_text = "authentication error"
194
+ auth_error_locator = page.get_by_text(auth_error_text, exact=False)
195
+
196
+ # 这里我们只需要很短的超时时间,因为页面应该是稳定的。
197
+ if auth_error_locator.is_visible(timeout=2000):
198
+ logger.error(f"检测到认证失败的错误横幅: '{auth_error_text}'. Cookie已过期或无效")
199
+ screenshot_path = os.path.join(screenshot_dir, f"FAIL_auth_error_banner_{diagnostic_tag}.png")
200
+ page.screenshot(path=screenshot_path)
201
+
202
+ # html_path = os.path.join(screenshot_dir, f"FAIL_auth_error_banner_{diagnostic_tag}.html")
203
+ # with open(html_path, 'w', encoding='utf-8') as f:
204
+ # f.write(page.content())
205
+ # logger.info(f"已保存包含错误信息的页面HTML: {html_path}")
206
+ return # 明确的失败,因此我们退出。
207
+
208
+ # --- 如果没有错误,进行最终确认(作为后备方案) ---
209
+ logger.info("未检测到认证错误横幅。进行最终确认")
210
+ login_button_cn = page.get_by_role('button', name='登录')
211
+ login_button_en = page.get_by_role('button', name='Login')
212
 
213
+ if login_button_cn.is_visible(timeout=1000) or login_button_en.is_visible(timeout=1000):
214
+ logger.error("页面上仍显示'登录'按钮。Cookie无效")
215
+ page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_login_button_visible_{diagnostic_tag}.png"))
216
+ return
217
+
218
+ # --- 如果所有检查都通过,我们假设成功 ---
219
+ logger.info("所有验证通过,确认已成功登录")
220
+
221
+ handle_successful_navigation(page, logger, diagnostic_tag, shutdown_event, cookie_validator)
222
+ elif "accounts.google.com/v3/signin/accountchooser" in final_url:
223
+ logger.warning("检测到Google账户选择页面。登录失败或Cookie已过期")
224
+ page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_chooser_click_failed_{diagnostic_tag}.png"))
225
+ return
226
+ else:
227
+ logger.error(f"导航到了意外的URL")
228
+ logger.error(f" 预期路径: {mask_path_for_logging(expected_path)}")
229
+ logger.error(f" 最终路径: {mask_path_for_logging(final_path)}")
230
+ logger.error(f" 最终URL: {mask_url_for_logging(final_url)}")
231
+ page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_unexpected_url_{diagnostic_tag}.png"))
232
  return
233
 
234
+ # 如果运行到这里且没有异常,表示实例正常结束(例如收到关闭信号)
235
+ # 正常结束时重置重试计数器
236
+ retry_count = 0
237
+ return
238
 
239
+ except KeepAliveError as e:
240
+ retry_count += 1
241
+ if retry_count > max_retries:
242
+ logger.error(f"重试次数已达上限 ({max_retries}),实例不再重启,退出")
243
  return
244
+
245
+ # 指数退避:3秒、6秒、12秒、24秒...最长60秒
246
+ delay = min(base_delay * (2 ** (retry_count - 1)), 60)
247
+ logger.error(f"浏览器实例出现错误 (重试 {retry_count}/{max_retries}),将在 {delay} 秒后重启浏览器实例: {e}")
248
+ time.sleep(delay)
249
+ continue
250
+ except KeyboardInterrupt:
251
+ logger.info(f"用户中断,正在关闭...")
252
+ return
253
+ except SystemExit as e:
254
+ # 捕获Cookie验证失败时的系统退出
255
+ if e.code == 1:
256
+ logger.error("Cookie验证失败,关闭进程实例")
257
  else:
258
+ logger.info(f"实例正常退出,退出码: {e.code}")
259
+ return
260
+ except Exception as e:
261
+ # 这是一个最终的捕获,用于捕获所有未预料到的错误
262
+ logger.exception(f"运行 Camoufox 实例时发生未预料的严重错误: {e}")
263
+ return
 
 
 
 
 
 
 
 
 
 
 
 
browser/navigation.py CHANGED
@@ -4,6 +4,9 @@ from playwright.sync_api import Page, expect
4
  from utils.paths import logs_dir
5
  from utils.common import ensure_dir
6
 
 
 
 
7
  def handle_untrusted_dialog(page: Page, logger=None):
8
  """
9
  检查并处理 "Last modified by..." 的弹窗。
@@ -83,4 +86,4 @@ def handle_successful_navigation(page: Page, logger, cookie_file_config, shutdow
83
  logger.info(f"已在保持活动循环出错时截屏: {screenshot_filename}")
84
  except Exception as screenshot_e:
85
  logger.error(f"在保持活动循环出错时截屏失败: {screenshot_e}")
86
- break # 如果页面关闭或出错,则退出循环
 
4
  from utils.paths import logs_dir
5
  from utils.common import ensure_dir
6
 
7
+ class KeepAliveError(Exception):
8
+ pass
9
+
10
  def handle_untrusted_dialog(page: Page, logger=None):
11
  """
12
  检查并处理 "Last modified by..." 的弹窗。
 
86
  logger.info(f"已在保持活动循环出错时截屏: {screenshot_filename}")
87
  except Exception as screenshot_e:
88
  logger.error(f"在保持活动循环出错时截屏失败: {screenshot_e}")
89
+ raise KeepAliveError(f"在保持活动循环时出错: {e}")