matrix-for-agents 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. agentmatrix/__init__.py +20 -0
  2. agentmatrix/agents/__init__.py +1 -0
  3. agentmatrix/agents/base.py +572 -0
  4. agentmatrix/agents/claude_coder.py +10 -0
  5. agentmatrix/agents/data_crawler.py +14 -0
  6. agentmatrix/agents/post_office.py +212 -0
  7. agentmatrix/agents/report_writer.py +14 -0
  8. agentmatrix/agents/secretary.py +10 -0
  9. agentmatrix/agents/stateful.py +10 -0
  10. agentmatrix/agents/user_proxy.py +82 -0
  11. agentmatrix/agents/worker.py +30 -0
  12. agentmatrix/backends/__init__.py +1 -0
  13. agentmatrix/backends/llm_client.py +414 -0
  14. agentmatrix/backends/mock_llm.py +35 -0
  15. agentmatrix/cli_runner.py +94 -0
  16. agentmatrix/core/__init__.py +0 -0
  17. agentmatrix/core/action.py +50 -0
  18. agentmatrix/core/browser/bing.py +208 -0
  19. agentmatrix/core/browser/browser_adapter.py +298 -0
  20. agentmatrix/core/browser/browser_common.py +85 -0
  21. agentmatrix/core/browser/drission_page_adapter.py +1296 -0
  22. agentmatrix/core/browser/google.py +230 -0
  23. agentmatrix/core/cerebellum.py +121 -0
  24. agentmatrix/core/events.py +22 -0
  25. agentmatrix/core/loader.py +185 -0
  26. agentmatrix/core/loader_v1.py +146 -0
  27. agentmatrix/core/log_util.py +158 -0
  28. agentmatrix/core/message.py +32 -0
  29. agentmatrix/core/prompt_engine.py +30 -0
  30. agentmatrix/core/runtime.py +211 -0
  31. agentmatrix/core/session.py +20 -0
  32. agentmatrix/db/__init__.py +1 -0
  33. agentmatrix/db/database.py +79 -0
  34. agentmatrix/db/vector_db.py +213 -0
  35. agentmatrix/docs/Design.md +109 -0
  36. agentmatrix/docs/Framework Capbilities.md +105 -0
  37. agentmatrix/docs/Planner Design.md +148 -0
  38. agentmatrix/docs/crawler_flow.md +110 -0
  39. agentmatrix/docs/report_writer.md +83 -0
  40. agentmatrix/docs/review.md +99 -0
  41. agentmatrix/docs/skill_design.md +23 -0
  42. agentmatrix/profiles/claude_coder.yml +40 -0
  43. agentmatrix/profiles/mark.yml +26 -0
  44. agentmatrix/profiles/planner.yml +21 -0
  45. agentmatrix/profiles/prompts/base.txt +88 -0
  46. agentmatrix/profiles/prompts/base_v1.txt +101 -0
  47. agentmatrix/profiles/prompts/base_v2.txt +94 -0
  48. agentmatrix/profiles/tom_the_data_crawler.yml +38 -0
  49. agentmatrix/profiles/user_proxy.yml +17 -0
  50. agentmatrix/skills/__init__.py +1 -0
  51. agentmatrix/skills/crawler_helpers.py +315 -0
  52. agentmatrix/skills/data_crawler.py +777 -0
  53. agentmatrix/skills/filesystem.py +204 -0
  54. agentmatrix/skills/notebook.py +158 -0
  55. agentmatrix/skills/project_management.py +114 -0
  56. agentmatrix/skills/report_writer.py +194 -0
  57. agentmatrix/skills/report_writer_utils.py +379 -0
  58. agentmatrix/skills/search_tool.py +383 -0
  59. agentmatrix/skills/terminal_ctrl.py +122 -0
  60. agentmatrix/skills/utils.py +33 -0
  61. agentmatrix/skills/web_searcher.py +1107 -0
  62. matrix_for_agents-0.1.2.dist-info/METADATA +44 -0
  63. matrix_for_agents-0.1.2.dist-info/RECORD +66 -0
  64. matrix_for_agents-0.1.2.dist-info/WHEEL +5 -0
  65. matrix_for_agents-0.1.2.dist-info/licenses/LICENSE +190 -0
  66. matrix_for_agents-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1296 @@
1
+ """
2
+ 基于 DrissionPage 库的 BrowserAdapter 实现类。
3
+
4
+ 该实现使用 DrissionPage 的 ChromiumPage 来提供浏览器自动化功能。
5
+ 支持使用指定的 Chrome profile 路径启动浏览器。
6
+ """
7
+
8
+ from typing import List, Optional, Any, Union
9
+ import time
10
+ import os
11
+ import hashlib
12
+
13
+ import uuid
14
+ from pathlib import Path
15
+ from urllib.parse import urlparse, unquote
16
+ from .browser_adapter import (
17
+ BrowserAdapter,
18
+ TabHandle,
19
+ PageElement,
20
+ ElementType,
21
+ PageType,
22
+ InteractionReport,
23
+ PageSnapshot,
24
+ KeyAction
25
+ )
26
+ from ...core.log_util import AutoLoggerMixin
27
+ from DrissionPage import ChromiumPage, ChromiumOptions
28
+ import asyncio, random
29
+ import trafilatura
30
+ import logging
31
+
32
+
33
+ class DrissionPageElement(PageElement):
34
+ """
35
+ 基于 DrissionPage 的 ChromiumElement 的 PageElement 实现类。
36
+ """
37
+
38
+ def __init__(self, chromium_element):
39
+ """
40
+ 初始化 DrissionPageElement。
41
+
42
+ Args:
43
+ chromium_element: DrissionPage 的 ChromiumElement 对象
44
+ """
45
+ self._element = chromium_element
46
+
47
+ def get_text(self) -> str:
48
+ """获取元素的可见文本 (用于小脑判断)"""
49
+ return self._element.text
50
+
51
+ def get_tag_name(self) -> str:
52
+ """获取元素的标签名 (a, button, div)"""
53
+ return self._element.tag.lower()
54
+
55
+ def get_element(self) -> Any:
56
+ """获取元素对象 (ChromiumElement)"""
57
+ return self._element
58
+
59
+ def is_visible(self) -> bool:
60
+ """元素是否可见"""
61
+ return self._element.states.is_displayed
62
+
63
+
64
+ class DrissionPageAdapter(BrowserAdapter,AutoLoggerMixin):
65
+ """
66
+ 基于 DrissionPage 库的浏览器适配器实现。
67
+
68
+ 使用 DrissionPage 的 ChromiumPage 来驱动 Chrome 浏览器。
69
+ 支持指定 Chrome profile 路径来实现会话持久化。
70
+ """
71
+ _custom_log_level = logging.DEBUG
72
+ def __init__(self, profile_path: Optional[str] = None, download_path: Optional[str] = None):
73
+ """
74
+ 初始化 DrissionPage 适配器。
75
+
76
+ Args:
77
+ profile_path: Chrome profile 的路径。如果提供,将以该路径为 profile 启动 Chrome。
78
+ download_path: 下载文件的保存路径。如果提供,浏览器下载的文件将保存到此目录。
79
+ """
80
+ self.profile_path = profile_path
81
+ self.download_path = download_path
82
+
83
+ # 确保下载目录存在
84
+ if self.download_path and not os.path.exists(self.download_path):
85
+ os.makedirs(self.download_path, exist_ok=True)
86
+
87
+ self.browser: Optional[Any] = None
88
+
89
+
90
+ async def start(self, headless: bool = False):
91
+ """
92
+ 启动浏览器进程。
93
+
94
+ Args:
95
+ headless: 是否以无头模式启动浏览器
96
+ """
97
+ os.environ["no_proxy"] = "localhost,127.0.0.1"
98
+ co = ChromiumOptions().set_user_data_path(self.profile_path)
99
+
100
+ # 配置下载路径
101
+ if self.download_path:
102
+ co.set_download_path(self.download_path)
103
+
104
+ if headless:
105
+ co.headless()
106
+
107
+ # 在线程池中创建浏览器实例
108
+ self.browser = await asyncio.to_thread(ChromiumPage, addr_or_opts=co)
109
+
110
+ async def close(self):
111
+ """关闭浏览器进程并清理资源"""
112
+ if self.browser:
113
+ try:
114
+ # 在线程池中关闭浏览器
115
+ await asyncio.to_thread(self.browser.quit)
116
+ except Exception:
117
+ # 忽略关闭时的异常
118
+ self.logger.exception("Error closing browser")
119
+ pass
120
+ finally:
121
+ self.browser = None
122
+
123
+
124
+
125
+
126
+ # --- Tab Management (标签页管理) ---
127
+ async def create_tab(self, url: Optional[str] = None) -> TabHandle:
128
+ """打开一个新的标签页,返回句柄"""
129
+ if not self.browser:
130
+ raise RuntimeError("Browser not started. Call start() first.")
131
+
132
+ # TODO: 实现创建标签页的逻辑
133
+ pass
134
+
135
+
136
+
137
+ async def close_tab(self, tab: TabHandle):
138
+ """关闭指定的标签页"""
139
+ # TODO: 实现关闭标签页的逻辑
140
+ pass
141
+
142
+ async def get_tab(self) -> TabHandle:
143
+ """获取当前焦点标签页的句柄"""
144
+ if not self.browser:
145
+ raise RuntimeError("Browser not started. Call start() first.")
146
+
147
+ # 在线程池中获取标签页
148
+ return await asyncio.to_thread(lambda: self.browser.latest_tab)
149
+
150
+ def get_tab_url(self, tab):
151
+ return tab.url
152
+
153
+
154
+
155
+ async def switch_to_tab(self, tab: TabHandle):
156
+ """将浏览器焦点切换到指定标签页 (模拟人类视线)"""
157
+ # TODO: 实现切换标签页的逻辑
158
+ pass
159
+
160
+ # --- Navigation & Content (导航与内容获取) ---
161
+ async def navigate(self, tab: TabHandle, url: str) -> InteractionReport:
162
+ """
163
+ 在指定 Tab 访问 URL。
164
+ 注意:Navigate 也可能触发下载 (如直接访问 pdf 链接),因此返回 InteractionReport。
165
+ """
166
+ if not self.browser:
167
+ raise RuntimeError("Browser not started. Call start() first.")
168
+
169
+ try:
170
+ # 记录导航前的URL
171
+ old_url = tab.url if hasattr(tab, 'url') else ""
172
+
173
+
174
+
175
+ # 在线程池中执行导航
176
+ await asyncio.to_thread(tab.get, url)
177
+
178
+ # 使用异步睡眠
179
+ await asyncio.sleep(2) # 等待页面加载
180
+
181
+ # 检查URL是否改变
182
+ new_url = tab.url if hasattr(tab, 'url') else ""
183
+ is_url_changed = old_url != new_url
184
+
185
+ # 创建交互报告
186
+ report = InteractionReport(
187
+ is_url_changed=is_url_changed,
188
+ is_dom_changed=is_url_changed # URL改变通常意味着DOM也改变了
189
+ )
190
+
191
+ return report
192
+
193
+ except Exception as e:
194
+ self.logger.exception(f"Navigation failed for URL: {url}")
195
+ return InteractionReport(
196
+ error=f"Navigation failed: {str(e)}"
197
+ )
198
+
199
+
200
+
201
+ async def _wait_for_dom_ready(self, tab: TabHandle, timeout: int = 10):
202
+ """
203
+ 等待 DOM 完全加载。
204
+ """
205
+ if not tab:
206
+ tab = await self.get_tab()
207
+
208
+ start_time = time.time()
209
+
210
+ while time.time() - start_time < timeout:
211
+ try:
212
+ # 检查页面加载状态
213
+ if hasattr(tab.states, 'ready_state'):
214
+ if tab.states.ready_state == 'complete':
215
+ self.logger.info("DOM is ready (complete)")
216
+ return True
217
+
218
+ # 备用检查:尝试访问 body 元素
219
+ body = tab.ele('body', timeout=0.5)
220
+ if body:
221
+ self.logger.info("DOM is ready (body found)")
222
+ return True
223
+
224
+ except Exception as e:
225
+ self.logger.debug(f"DOM not ready yet: {e}")
226
+
227
+ await asyncio.sleep(0.5)
228
+
229
+ self.logger.warning(f"DOM ready timeout after {timeout} seconds")
230
+ return False
231
+
232
+ async def stabilize(self, tab: TabHandle):
233
+ """
234
+ [Phase 2] 页面稳定化。
235
+ """
236
+ if not tab:
237
+ return False # 防御
238
+
239
+ self.logger.info(f"⚓ Stabilizing page: {tab.url}")
240
+
241
+ # 1. 基础加载等待
242
+ try:
243
+ # DrissionPage 的 wait.load_start() 有时会卡住,不如直接 wait.doc_loaded()
244
+ # 设置较短超时,因为我们后面有滚动循环
245
+ await asyncio.to_thread(tab.wait.doc_loaded, timeout=35)
246
+ except Exception:
247
+ pass # 超时也继续,有些页面 JS 加载永远不 finish
248
+ try:
249
+ # 2. 暴力抗干扰 (Anti-Obstruction)
250
+ # 在滚动前先尝试清理一波明显的遮挡
251
+ await self._handle_popups(tab)
252
+
253
+ # 3. 智能滚动 (Smart Scroll)
254
+ # 我们不仅要到底,还要确保中间的内容都被触发加载 (Lazy Load)
255
+ start_time = time.time()
256
+ max_duration = 45 # 45秒足够了
257
+
258
+ # 记录上次高度和指纹,双重校验
259
+ last_height = await asyncio.to_thread(tab.run_js, "return document.body.scrollHeight;")
260
+ no_change_count = 0
261
+
262
+ # 分段滚动策略:不像人类那样慢慢滑,直接分段跳跃
263
+ # 每次向下滚动一屏的高度
264
+ viewport_height = await asyncio.to_thread(tab.run_js, "return window.innerHeight;")
265
+ current_scroll_y = 0
266
+
267
+ while time.time() - start_time < max_duration:
268
+ # 向下滚动一屏
269
+ current_scroll_y += viewport_height
270
+ await asyncio.to_thread(tab.scroll, current_scroll_y)
271
+
272
+ # 稍微等待内容渲染
273
+ await asyncio.sleep(0.8)
274
+
275
+ # 检查弹窗 (滚动可能触发新的弹窗)
276
+ await self._handle_popups(tab)
277
+
278
+ # 检查是否到底
279
+ new_height = await asyncio.to_thread(tab.run_js, "return document.body.scrollHeight;")
280
+ current_pos = await asyncio.to_thread(tab.run_js, "return window.scrollY + window.innerHeight;")
281
+
282
+ # 如果当前位置已经接近页面总高度 (允许 50px 误差)
283
+ if current_pos >= new_height - 50:
284
+ # 再次确认高度是否真的不再增长了 (有些无限加载需要等一会)
285
+ if new_height == last_height:
286
+ no_change_count += 1
287
+ if no_change_count >= 2: # 连续两次没变,才算真的到底了
288
+ break
289
+ else:
290
+ no_change_count = 0 # 高度变了,重置计数
291
+ last_height = new_height
292
+
293
+ # 4. 回到顶部
294
+ await asyncio.to_thread(tab.scroll.to_top)
295
+ await asyncio.sleep(0.5)
296
+
297
+ self.logger.info("✅ Page stabilized.")
298
+ return True
299
+ except Exception as e:
300
+ self.logger.exception(f"Page stabilization failed: {e}")
301
+ return True
302
+
303
+ async def _handle_popups(self, tab: TabHandle):
304
+ """
305
+ 智能弹窗处理:基于文本语义和元素属性,而非死板的 CSS 选择器。
306
+ DrissionPage 的优势在于它可以极快地获取元素文本。
307
+ """
308
+ # 1. 定义我们想点击的“关键词”
309
+ # 这些词通常出现在 Consent 弹窗的按钮上
310
+ allow_keywords = ['accept', 'agree', 'allow', 'consent', 'i understand', 'got it', 'cookie', '接受', '同意', '知道']
311
+ # 这些词出现在关闭按钮上
312
+ close_keywords = ['close', 'later', 'no thanks', 'not now', '关闭', '取消']
313
+
314
+ # 2. 查找页面上所有可能是“遮挡层”中的按钮
315
+ # 策略:查找所有 z-index 很高 或者 position fixed 的容器里的按钮
316
+ # 但这太慢。
317
+
318
+ # 简易策略:直接找页面上可见的、包含上述关键词的 BUTTON 或 A 标签
319
+ # 并且只处理那些看起来像是在“浮层”里的 (通过简单的 JS 判断,或者不做判断直接盲点风险较大)
320
+
321
+ # 安全策略:只针对常见的 ID/Class 模式进行精确打击
322
+ # 结合你原来的逻辑,但做简化
323
+
324
+ common_popup_close_selectors = [
325
+ 'button[aria-label="Close"]',
326
+ 'button[class*="close"]',
327
+ '.close-icon',
328
+ '[id*="cookie"] button', # Cookie 栏里的按钮通常都是要点的
329
+ '[class*="consent"] button'
330
+ ]
331
+
332
+ try:
333
+ for selector in common_popup_close_selectors:
334
+ # 在线程池中查找可见的元素
335
+ eles = await asyncio.to_thread(tab.eles, selector, timeout=2)
336
+ for ele in eles:
337
+ # 在线程池中检查是否可见
338
+ is_displayed = await asyncio.to_thread(lambda: ele.states.is_displayed)
339
+ if is_displayed:
340
+ # 在线程池中检查文本是否匹配"拒绝"或"关闭"或"同意"
341
+ txt = await asyncio.to_thread(lambda: ele.text.lower())
342
+ # 如果是 Cookie 区域的按钮,通常点第一个可见的就行(大概率是 Accept)
343
+ if 'cookie' in selector or 'consent' in selector:
344
+ await asyncio.to_thread(ele.click, by_js=True) # 用 JS 点更稳,不会被遮挡
345
+ self.logger.info(f"Clicked cookie consent: {txt}")
346
+ await asyncio.sleep(0.5)
347
+ return True
348
+
349
+ # 如果是关闭按钮
350
+ if any(k in txt for k in close_keywords) or not txt: # 有些关闭按钮没字,只有X
351
+ await asyncio.to_thread(ele.click, by_js=True)
352
+ self.logger.info(f"Clicked popup close: {selector}")
353
+ await asyncio.sleep(0.5)
354
+ return True
355
+
356
+ except Exception:
357
+ pass
358
+
359
+ return False
360
+
361
+ async def get_page_snapshot(self, tab: TabHandle) -> PageSnapshot:
362
+ """
363
+ [Phase 3] 获取页面内容供小脑阅读。
364
+ 智能提取正文,过滤噪音。
365
+ """
366
+ if not tab:
367
+ raise ValueError("Tab handle is None")
368
+
369
+ url = tab.url
370
+ title = tab.title
371
+
372
+ # 1. 判断内容类型 (HTML? Static Asset?)
373
+ content_type = await self.analyze_page_type(tab)
374
+
375
+ # 2. 如果是静态资源,使用专门的静态资源处理
376
+ if content_type == PageType.STATIC_ASSET:
377
+ return await self._get_static_asset_snapshot(tab, url, title, content_type)
378
+
379
+ # 3. HTML 正文提取 (核心逻辑)
380
+ # 在线程池中获取当前渲染后的 HTML (包含 JS 执行后的结果)
381
+ raw_html = await asyncio.to_thread(lambda: tab.html)
382
+
383
+ # A. 尝试使用 Trafilatura 提取高质量 Markdown
384
+ # include_links=True: 保留正文里的链接,这对小脑判断"是否有价值的引用"很有用
385
+ # include_formatting=True: 保留加粗、标题等
386
+ extracted_text = trafilatura.extract(
387
+ raw_html,
388
+ include_links=True,
389
+ include_formatting=True,
390
+ output_format='markdown',
391
+ url=url # 传入 URL 有助于 trafilatura 处理相对路径
392
+ )
393
+
394
+ # B. 备选方案 (Fallback)
395
+ if not extracted_text or len(extracted_text) < 50:
396
+ self.logger.info(f"Trafilatura extraction failed or too short for {url}, falling back to simple cleaning.")
397
+ extracted_text = await self._fallback_text_extraction(tab)
398
+
399
+ # 4. 最终组装
400
+ # 可以在这里加一个 Token 截断,比如保留前 15000 字符,
401
+ # 因为用来做“价值判断”不需要读完几万字的长文。
402
+ final_text = extracted_text[:20000]
403
+
404
+ return PageSnapshot(
405
+ url=url,
406
+ title=title,
407
+ content_type=content_type,
408
+ main_text=final_text,
409
+ raw_html=raw_html[:2000] # 只保留一点点头部 HTML 用于 debug,不需要全存
410
+ )
411
+
412
+ async def analyze_page_type(self, tab: TabHandle) -> PageType:
413
+ """
414
+ 判断页面类型。
415
+ Chrome 浏览器打开 PDF 时,DOM 结构非常特殊。
416
+ """
417
+ try:
418
+
419
+
420
+ # 1. 检查 URL 特征
421
+ url = tab.url.lower()
422
+ if url == "about:blank" or url.startswith("chrome://") or url.startswith("data:"):
423
+ self.logger.warning(f"⚠️ Empty/System URL detected: {url}")
424
+ return PageType.ERRO_PAGE
425
+
426
+ # 2. 检查 Title 特征 (HTTP 错误通常会反映在标题)
427
+ title = tab.title.lower()
428
+ error_keywords = [
429
+ "404 not found", "page not found", "500 internal server error",
430
+ "502 bad gateway", "site can't be reached", "privacy error",
431
+ "无法访问", "找不到页面", "服务器错误", "网站无法连接"
432
+ ]
433
+ if any(k in title for k in error_keywords):
434
+ self.logger.warning(f"⚠️ Error Page Title detected: {title}")
435
+ return PageType.ERRO_PAGE
436
+
437
+
438
+
439
+
440
+ # 1. 在线程池中获取 MIME Type
441
+ # 这里的 timeout 要极短,因为如果页面还在加载,我们不希望卡住,
442
+ # 但通常 contentType 是 header 返回后就有的
443
+ content_type = await asyncio.to_thread(
444
+ lambda: tab.run_js("return document.contentType;", timeout=1)
445
+ )
446
+ content_type = content_type.lower() if content_type else ""
447
+
448
+ # 2. 判定逻辑
449
+ if "text/html" in content_type or "application/xhtml+xml" in content_type:
450
+ # 特殊情况:有时服务器配置错误,把 JSON 当 HTML 发,
451
+ # 或者这是个纯展示代码的 HTML 页。
452
+ # 但一般按 HTML 处理没错,大不了 Scout 不出东西。
453
+ return PageType.NAVIGABLE
454
+
455
+ # 常见的非 HTML 类型
456
+ if any(t in content_type for t in ["application/pdf", "image/", "text/plain", "application/json", "text/xml"]):
457
+ return PageType.STATIC_ASSET
458
+
459
+ # 3. 兜底:如果 JS 失败(比如 XML 有时不能运行 JS),回退到 URL 后缀
460
+ url = tab.url.lower()
461
+ if any(url.endswith(ext) for ext in ['.pdf', '.jpg', '.png', '.json', '.xml', '.txt']):
462
+ return PageType.STATIC_ASSET
463
+
464
+ # 默认视为网页
465
+ return PageType.NAVIGABLE
466
+
467
+ except Exception:
468
+ # 如果出错了(比如页面卡死),保守起见当作网页处理,或者根据 URL 判
469
+ return PageType.NAVIGABLE
470
+
471
+
472
+
473
+ async def _fallback_text_extraction(self, tab: TabHandle) -> str:
474
+ """
475
+ 当智能提取失败时,使用 DrissionPage 暴力提取可见文本。
476
+ 并做简单的清洗。
477
+ """
478
+ # 移除 script, style 等无关标签
479
+ # DrissionPage 的 .text 属性其实已经处理了大部分,但我们可以更彻底一点
480
+ try:
481
+ # 在线程池中获取 body 元素
482
+ body = await asyncio.to_thread(tab.ele, 'tag:body')
483
+
484
+ # 在线程池中获取文本,这里的 text 获取的是 "innerText",即用户可见的文本
485
+ raw_text = await asyncio.to_thread(lambda: body.text)
486
+
487
+ # 简单的后处理:去除连续空行
488
+ lines = [line.strip() for line in raw_text.split('\n') if line.strip()]
489
+ return "\n".join(lines)
490
+ except Exception as e:
491
+ return f"[Error extracting text: {e}]"
492
+
493
+ async def _detect_asset_subtype(self, tab: TabHandle, url: str) -> str:
494
+ """
495
+ 判断静态资源的子类型。
496
+ 优先使用 content_type 判断(参考 analyze_page_type),回退到 URL 后缀。
497
+ """
498
+ url_lower = url.lower()
499
+
500
+ # 0. 特殊情况:Chrome PDF viewer 扩展
501
+ # URL 格式:chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/index.html?...
502
+ if 'chrome-extension://' in url_lower and 'pdf' in url_lower:
503
+ return 'pdf'
504
+
505
+ # 1. 优先从 content_type 判断(更可靠)
506
+ try:
507
+ content_type = await asyncio.to_thread(tab.run_js, "return document.contentType;", timeout=1)
508
+ content_type = content_type.lower() if content_type else ""
509
+
510
+ if "application/pdf" in content_type:
511
+ return 'pdf'
512
+ elif "application/json" in content_type or "text/json" in content_type:
513
+ return 'json'
514
+ elif "text/xml" in content_type or "application/xml" in content_type:
515
+ return 'xml'
516
+ elif "image/" in content_type:
517
+ return 'image'
518
+ except Exception:
519
+ # JS 执行失败,回退到 URL 判断
520
+ pass
521
+
522
+ # 2. 回退到 URL 后缀判断
523
+ if url_lower.endswith('.pdf'):
524
+ return 'pdf'
525
+ elif url_lower.endswith('.json'):
526
+ return 'json'
527
+ elif url_lower.endswith('.xml'):
528
+ return 'xml'
529
+ elif any(url_lower.endswith(ext) for ext in ['.txt', '.log', '.md']):
530
+ return 'text'
531
+ else:
532
+ # 默认为 text
533
+ return 'text'
534
+
535
+ async def _get_static_asset_snapshot(self, tab: TabHandle, url: str, title: str, content_type: PageType) -> PageSnapshot:
536
+ """
537
+ 处理静态资源的 snapshot。
538
+ """
539
+ subtype = await self._detect_asset_subtype(tab, url)
540
+
541
+ self.logger.info(f"Creating snapshot for static asset: {subtype} - {url}")
542
+
543
+ if subtype == 'pdf':
544
+ return await self._snapshot_pdf_browser(tab, url, title, content_type)
545
+ elif subtype == 'json':
546
+ return await self._snapshot_json(tab, url, title, content_type)
547
+ elif subtype == 'xml':
548
+ return await self._snapshot_xml(tab, url, title, content_type)
549
+ else:
550
+ return await self._snapshot_text(tab, url, title, content_type)
551
+
552
+ async def _snapshot_pdf_browser(self, tab: TabHandle, url: str, title: str, content_type: PageType) -> PageSnapshot:
553
+ """
554
+ 使用专业的 PDF 解析工具提取 PDF 内容。
555
+
556
+ 流程:
557
+ 1. 下载 PDF 文件到本地
558
+ 2. 使用 marker 库转换为 Markdown(动态长度策略)
559
+ 3. 清理临时文件
560
+
561
+ 动态长度策略:
562
+ - 先转换前 5 页
563
+ - 如果长度 ≤ 100,000 字符,继续转换剩余页面
564
+ - 当总长度超过 10,000 字符时停止
565
+ """
566
+ pdf_path = None
567
+ try:
568
+ self.logger.info(f"Extracting PDF content from: {url}")
569
+
570
+ # 1. 下载 PDF 文件到本地
571
+ pdf_path = await self.save_static_asset(tab)
572
+
573
+ if not pdf_path:
574
+ self.logger.error("Failed to save PDF file")
575
+ return PageSnapshot(
576
+ url=url,
577
+ title=title,
578
+ content_type=content_type,
579
+ main_text="[PDF Document] (Failed to download)",
580
+ raw_html=""
581
+ )
582
+
583
+ # 2. 导入 pdf_to_markdown
584
+ from skills.report_writer_utils import pdf_to_markdown
585
+ import pymupdf # PyMuPDF,用于获取 PDF 页数
586
+
587
+ # 3. 获取 PDF 总页数
588
+ try:
589
+ self.logger.debug(f"Opening {pdf_path}")
590
+ doc = pymupdf.open(pdf_path)
591
+ total_pages = len(doc)
592
+ doc.close()
593
+ self.logger.info(f"PDF total pages: {total_pages}")
594
+ except Exception as e:
595
+ self.logger.exception(f"Failed to get PDF page count: {e}")
596
+ # 可能是加密或损坏的 PDF,返回空
597
+ return PageSnapshot(
598
+ url=url,
599
+ title=title,
600
+ content_type=content_type,
601
+ main_text="[PDF Document] (Encrypted or corrupted)",
602
+ raw_html=""
603
+ )
604
+
605
+ # 4. 动态长度转换策略
606
+ try:
607
+ # 先转换前 5 页
608
+ initial_end = min(5, total_pages)
609
+ markdown_text = pdf_to_markdown(pdf_path, start_page=1, end_page=initial_end)
610
+ self.logger.info(f"Initial conversion (pages 1-{initial_end}): {len(markdown_text)} chars")
611
+
612
+ # 如果长度 ≤ 100,000 字符,继续转换剩余页面
613
+ if len(markdown_text) <= 100000:
614
+ current_page = initial_end + 1
615
+
616
+ while current_page <= total_pages and len(markdown_text) <= 10000:
617
+ # 逐页转换
618
+ page_text = pdf_to_markdown(pdf_path, start_page=current_page, end_page=current_page)
619
+ markdown_text += "\n\n" + page_text
620
+ self.logger.debug(f"Added page {current_page}: total {len(markdown_text)} chars")
621
+ current_page += 1
622
+
623
+ # 如果在 10,000 字符限制内仍有未转换页面,一次性转换剩余所有
624
+ if current_page <= total_pages and len(markdown_text) <= 10000:
625
+ remaining_text = pdf_to_markdown(pdf_path, start_page=current_page, end_page=total_pages)
626
+ markdown_text += "\n\n" + remaining_text
627
+ self.logger.info(f"Converted remaining pages: final total {len(markdown_text)} chars")
628
+
629
+ self.logger.info(f"PDF converted to Markdown: {len(markdown_text)} chars")
630
+
631
+ return PageSnapshot(
632
+ url=url,
633
+ title=title,
634
+ content_type=content_type,
635
+ main_text=f"[PDF Document]\n\n{markdown_text}",
636
+ raw_html=""
637
+ )
638
+
639
+ except Exception as e:
640
+ self.logger.exception(f"Failed to convert PDF to Markdown: {e}")
641
+ # 转换失败(可能是加密 PDF),返回空内容不报错
642
+ return PageSnapshot(
643
+ url=url,
644
+ title=title,
645
+ content_type=content_type,
646
+ main_text="[PDF Document] (Encrypted or conversion failed)",
647
+ raw_html=""
648
+ )
649
+
650
+ except Exception as e:
651
+ self.logger.exception(f"Failed to extract PDF content: {e}")
652
+ return PageSnapshot(
653
+ url=url,
654
+ title=title,
655
+ content_type=content_type,
656
+ main_text="[PDF Document] (Extraction failed)",
657
+ raw_html=""
658
+ )
659
+
660
+ finally:
661
+ # 4. 清理临时 PDF 文件
662
+ if pdf_path and os.path.exists(pdf_path):
663
+ try:
664
+ os.unlink(pdf_path)
665
+ self.logger.info(f"Cleaned up temporary PDF: {pdf_path}")
666
+ except Exception as e:
667
+ self.logger.warning(f"Failed to cleanup PDF file: {e}")
668
+
669
+ async def _snapshot_json(self, tab: TabHandle, url: str, title: str, content_type: PageType) -> PageSnapshot:
670
+ """
671
+ 提取并格式化 JSON。
672
+ """
673
+ try:
674
+ # 在线程池中获取原始文本
675
+ body = await asyncio.to_thread(tab.ele, 'tag:body')
676
+ text = await asyncio.to_thread(lambda: body.text.strip())
677
+
678
+ # 尝试解析并格式化
679
+ try:
680
+ import json
681
+ data = json.loads(text)
682
+ formatted = json.dumps(data, indent=2, ensure_ascii=False)
683
+
684
+ # 限制在 5K
685
+ max_length = 5000
686
+ if len(formatted) > max_length:
687
+ formatted = formatted[:max_length] + "\n\n... (truncated)"
688
+
689
+ return PageSnapshot(
690
+ url=url,
691
+ title=title,
692
+ content_type=content_type,
693
+ main_text=f"[JSON Data]\n\n```json\n{formatted}\n```",
694
+ raw_html=""
695
+ )
696
+ except json.JSONDecodeError:
697
+ # 不是有效的 JSON,当作普通文本处理
698
+ max_length = 5000
699
+ if len(text) > max_length:
700
+ text = text[:max_length] + "\n\n... (truncated)"
701
+
702
+ return PageSnapshot(
703
+ url=url,
704
+ title=title,
705
+ content_type=content_type,
706
+ main_text=f"[JSON or Text Data]\n\n```\n{text}\n```",
707
+ raw_html=""
708
+ )
709
+
710
+ except Exception as e:
711
+ self.logger.exception(f"Failed to extract JSON: {e}")
712
+ return PageSnapshot(
713
+ url=url,
714
+ title=title,
715
+ content_type=content_type,
716
+ main_text="[JSON/Text Data] (Extraction failed)",
717
+ raw_html=""
718
+ )
719
+
720
+ async def _snapshot_xml(self, tab: TabHandle, url: str, title: str, content_type: PageType) -> PageSnapshot:
721
+ """
722
+ 提取并格式化 XML。
723
+ """
724
+ try:
725
+ # 在线程池中获取原始文本
726
+ body = await asyncio.to_thread(tab.ele, 'tag:body')
727
+ text = await asyncio.to_thread(lambda: body.text.strip())
728
+
729
+ # 限制在 5K
730
+ max_length = 5000
731
+ if len(text) > max_length:
732
+ text = text[:max_length] + "\n\n... (truncated)"
733
+
734
+ return PageSnapshot(
735
+ url=url,
736
+ title=title,
737
+ content_type=content_type,
738
+ main_text=f"[XML Data]\n\n```xml\n{text}\n```",
739
+ raw_html=""
740
+ )
741
+
742
+ except Exception as e:
743
+ self.logger.exception(f"Failed to extract XML: {e}")
744
+ return PageSnapshot(
745
+ url=url,
746
+ title=title,
747
+ content_type=content_type,
748
+ main_text="[XML Data] (Extraction failed)",
749
+ raw_html=""
750
+ )
751
+
752
+ async def _snapshot_text(self, tab: TabHandle, url: str, title: str, content_type: PageType) -> PageSnapshot:
753
+ """
754
+ 处理纯文本文件。
755
+ """
756
+ try:
757
+ # 在线程池中获取文本
758
+ body = await asyncio.to_thread(tab.ele, 'tag:body')
759
+ text = await asyncio.to_thread(lambda: body.text.strip())
760
+
761
+ # 限制在 5K
762
+ max_length = 5000
763
+ if len(text) > max_length:
764
+ text = text[:max_length] + "\n\n... (truncated)"
765
+
766
+ return PageSnapshot(
767
+ url=url,
768
+ title=title,
769
+ content_type=content_type,
770
+ main_text=f"[Text File]\n\n```\n{text}\n```",
771
+ raw_html=""
772
+ )
773
+
774
+ except Exception as e:
775
+ self.logger.exception(f"Failed to extract text: {e}")
776
+ return PageSnapshot(
777
+ url=url,
778
+ title=title,
779
+ content_type=content_type,
780
+ main_text="[Text File] (Extraction failed)",
781
+ raw_html=""
782
+ )
783
+
784
+ async def save_view_as_file(self, tab: TabHandle, save_dir: str) -> Optional[str]:
785
+ """
786
+ 如果当前页面是 PDF 预览或纯文本,将其保存为本地文件。
787
+ """
788
+ # TODO: 实现保存视图为文件的逻辑
789
+ pass
790
+
791
+ async def save_static_asset(self, tab: TabHandle) -> Optional[str]:
792
+ """
793
+ [针对 STATIC_ASSET]
794
+ 保存当前 Tab 显示的内容为文件。
795
+
796
+ 支持的文件类型:
797
+ - PDF 文件
798
+ - 图片文件 (jpg, png, gif, webp, etc.)
799
+ - 文本文件 (json, txt, xml, etc.)
800
+ """
801
+ if not tab:
802
+ self.logger.error("Tab handle is None")
803
+ return None
804
+
805
+ try:
806
+ # 1. 获取当前页面信息
807
+ url = tab.url
808
+ page_type = await self.analyze_page_type(tab)
809
+
810
+ if page_type != PageType.STATIC_ASSET:
811
+ self.logger.warning(f"Page is not a static asset: {url}")
812
+ return None
813
+
814
+ # 2. 确定保存目录
815
+ save_dir = self.download_path if self.download_path else "downloads"
816
+ Path(save_dir).mkdir(parents=True, exist_ok=True)
817
+
818
+ # 3. 解析 URL 获取文件名
819
+ parsed_url = urlparse(url)
820
+ filename = unquote(os.path.basename(parsed_url.path))
821
+
822
+ # 如果 URL 没有清晰的文件名,生成一个
823
+ if not filename or '.' not in filename:
824
+ # 根据 URL 路径或生成 UUID
825
+ ext = self._get_extension_from_url(url)
826
+ filename = f"{uuid.uuid4().hex[:8]}{ext}"
827
+
828
+ # 4. 判断资源类型并保存
829
+ file_path = os.path.join(save_dir, filename)
830
+
831
+ # 判断是否是文本类型
832
+ if self._is_text_content_type(url):
833
+ # 文本类型:直接提取 body 文本
834
+ await self._save_text_asset(tab, file_path)
835
+ else:
836
+ # 在线程池中使用 tab.download 下载二进制类型
837
+ # 注意:浏览器已经在启动时设置了 download_path,这里不应该再传路径参数
838
+ # 否则会导致路径被拼接两次
839
+ res = await asyncio.to_thread(tab.download, url)
840
+ status, file_path = res
841
+ #await self._save_binary_asset(url, file_path)
842
+
843
+ # 转换为绝对路径,确保后续调用能正确找到文件
844
+ file_path = os.path.abspath(file_path)
845
+ self.logger.info(f"Static asset saved to: {file_path}")
846
+ return file_path
847
+
848
+ except Exception as e:
849
+ self.logger.exception(f"Failed to save static asset: {e}")
850
+ return None
851
+
852
+ def _get_extension_from_url(self, url: str) -> str:
853
+ """
854
+ 从 URL 推断文件扩展名。
855
+ """
856
+ url_lower = url.lower()
857
+
858
+ # 常见文件扩展名映射
859
+ extensions = {
860
+ '.pdf': '.pdf',
861
+ '.jpg': '.jpg', '.jpeg': '.jpg',
862
+ '.png': '.png',
863
+ '.gif': '.gif',
864
+ '.webp': '.webp',
865
+ '.svg': '.svg',
866
+ '.json': '.json',
867
+ '.txt': '.txt',
868
+ '.xml': '.xml',
869
+ '.html': '.html', '.htm': '.html'
870
+ }
871
+
872
+ for ext, file_ext in extensions.items():
873
+ if ext in url_lower:
874
+ return file_ext
875
+
876
+ # 默认扩展名
877
+ return '.bin'
878
+
879
+ def _is_text_content_type(self, url: str) -> bool:
880
+ """
881
+ 判断 URL 是否指向文本内容。
882
+ """
883
+ url_lower = url.lower()
884
+ text_extensions = ['.json', '.txt', '.xml', '.html', '.htm', '.svg']
885
+ return any(url_lower.endswith(ext) for ext in text_extensions)
886
+
887
+ async def _save_text_asset(self, tab: TabHandle, file_path: str):
888
+ """
889
+ 保存文本类型的资源。
890
+ """
891
+ try:
892
+ # 在线程池中提取 body 文本
893
+ body = await asyncio.to_thread(tab.ele, 'tag:body')
894
+ if not body:
895
+ raise ValueError("No body element found")
896
+
897
+ text_content = await asyncio.to_thread(lambda: body.text)
898
+
899
+ # 写入文件(保持同步,按照用户要求)
900
+ with open(file_path, 'w', encoding='utf-8') as f:
901
+ f.write(text_content)
902
+
903
+ self.logger.info(f"Text asset saved: {file_path} ({len(text_content)} chars)")
904
+
905
+ except Exception as e:
906
+ self.logger.exception(f"Failed to save text asset: {e}")
907
+ raise
908
+
909
+
910
+
911
+ # --- Scouting & Interaction (侦察与交互) ---
912
+ async def scan_elements(self, tab: TabHandle) :
913
+ """
914
+ [Phase 4] 扫描页面。
915
+ 返回两个列表:
916
+ 1. 第一个列表:所有可点的、指向某个明确 URL 的元素(例如 <a> 标签)
917
+ 2. 第二个列表:所有可点的、没有明确新 URL 的其他元素(按钮等)
918
+ 实现了去重和垃圾过滤。
919
+ """
920
+ # 无价值元素的黑名单(中英文)
921
+ IGNORED_PATTERNS = [
922
+ # 登录/注册
923
+ '登录', 'login', 'signin', 'sign in', 'register', '注册', 'sign up', 'signup',
924
+ # 退出/关闭
925
+ 'exit', '退出', 'logout', 'log out', 'cancel', '取消', 'close', '关闭',
926
+ # 通用导航
927
+ 'home', '首页', 'back', '返回', 'skip', '跳过',
928
+ # 同意/拒绝
929
+ 'accept', '接受', 'agree', '同意', 'decline', '拒绝'
930
+ ]
931
+
932
+ if not tab: return {}, {}
933
+
934
+ # 1. 定义统一选择器 (Method B)
935
+ # 覆盖:链接, 按钮, 图片输入, 提交按钮, 以及伪装成按钮/链接的 div/span
936
+ # 注意:排除 href 为 javascript: mailto: tel: 的链接,这些通常 Agent 处理不了
937
+ selector = (
938
+ '@|tag()=a' # 所有带 href 属性的 a 标签
939
+ '@|tag()=button' # 所有 button 标签
940
+ 'input@type=button||' # type=button 的 input
941
+ 'input@type=submit||' # type=submit 的 input
942
+ 'input@type=image||' # type=image 的 input
943
+ '@role=button||' # role=button 的元素
944
+ '@role=link||' # role=link 的元素
945
+ '@role=menuitem' # role=menuitem 的元素
946
+ )
947
+ selector1 = '@|tag()=a@|tag()=button'
948
+ selector2 = 'css:input[type="button"],input[type="submit"], input[type="image"]'
949
+ selector3 = 'css:[role="button"],[role="link"],[role="menuitem"]'
950
+
951
+ raw_elements = []
952
+ try:
953
+ # 2. 在线程池中批量获取元素 (DrissionPage 的 eles 方法)
954
+ # timeout 设短点,找不到就算了
955
+ for css_selector in [selector1, selector2, selector3]:
956
+ self.logger.debug(f"Checking: {css_selector}")
957
+ elements = await asyncio.to_thread(tab.eles, css_selector, timeout=2)
958
+ self.logger.debug(f"Found {len(elements)} elements")
959
+ raw_elements.extend(elements)
960
+ #raw_elements = list(set(raw_elements)) # 去重
961
+ except Exception as e:
962
+ self.logger.exception(f"Scan elements failed: {e}")
963
+ return {}, {}
964
+
965
+ # 结果容器
966
+
967
+ button_elements = {} # 没有明确新 URL 的元素
968
+
969
+ # 链接去重字典: {normalized_url: (element, text_length)}
970
+ # 我们只保留指向同一个 URL 的链接中,文本最长的那个
971
+ seen_links = {}
972
+
973
+ # 3. 遍历与过滤
974
+ # 为了性能,限制最大处理数量 (比如前 500 个 DOM 里的元素)
975
+ max_scan_count = 500
976
+
977
+ count = 0
978
+ for ele in raw_elements:
979
+ if count > max_scan_count:
980
+ break
981
+
982
+ try:
983
+ # --- 在线程池中快速过滤 (无网络交互) ---
984
+ tag = await asyncio.to_thread(lambda: ele.tag)
985
+ # 获取文本,如果没有可见文本,尝试获取 title 或 aria-label
986
+ # DrissionPage 的 .text 获取的是可见文本,这步其实隐含了可见性检查的一部分,但有些隐藏元素也有 text
987
+ text = await asyncio.to_thread(lambda: ele.text.strip())
988
+
989
+ # 补充文本源 (针对图标按钮)
990
+ if not text:
991
+ aria_label = await asyncio.to_thread(lambda: ele.attr('aria-label'))
992
+ title_attr = await asyncio.to_thread(lambda: ele.attr('title'))
993
+ alt_attr = await asyncio.to_thread(lambda: ele.attr('alt'))
994
+ text = aria_label or title_attr or alt_attr or ""
995
+ text = text.strip()
996
+
997
+ # 如果还是没字,跳过 (除非是 input image)
998
+ if not text and tag != 'input':
999
+ continue
1000
+
1001
+ # --- 黑名单过滤 ---
1002
+ # 检查文本是否匹配无价值模式
1003
+ text_lower = text.lower()
1004
+ should_skip = False
1005
+ for pattern in IGNORED_PATTERNS:
1006
+ # 检查是否包含模式(部分匹配)
1007
+ if pattern in text_lower:
1008
+ self.logger.debug(f"⛔ Filtering ignored element: '{text}' (matched '{pattern}')")
1009
+ should_skip = True
1010
+ break
1011
+ # 检查完全匹配
1012
+ if text_lower.strip() == pattern:
1013
+ self.logger.debug(f"⛔ Filtering ignored element: '{text}' (exact match '{pattern}')")
1014
+ should_skip = True
1015
+ break
1016
+
1017
+ if should_skip:
1018
+ continue
1019
+
1020
+ # --- 在线程池中进行慢速过滤 (网络交互) ---
1021
+ # 检查可见性 (is_displayed 内部会 check visibility, display, opacity)
1022
+ # 还要检查尺寸,防止 1x1 的跟踪点
1023
+ is_displayed = await asyncio.to_thread(lambda: ele.states.is_displayed)
1024
+ if not is_displayed:
1025
+ continue
1026
+
1027
+ rect = await asyncio.to_thread(lambda: ele.rect)
1028
+ if rect.size[0] < 5 or rect.size[1] < 5: # 忽略极小元素
1029
+ continue
1030
+
1031
+ # --- 分类处理 ---
1032
+
1033
+ # A. 链接 (Links) -> 需去重
1034
+ role = await asyncio.to_thread(lambda: ele.attr('role'))
1035
+ if tag == 'a' or role == 'link':
1036
+ href = await asyncio.to_thread(lambda: ele.attr('href'))
1037
+
1038
+ if not href or len(href) < 2 or href.startswith('#'):
1039
+ continue
1040
+
1041
+ # 绝对路径化 (DrissionPage 拿到的 href 通常已经是绝对路径,或者是 property)
1042
+ # 如果不是,可以在这里做 urljoin,但 DrissionPage 的 .link 属性通常是好的
1043
+ full_url = await asyncio.to_thread(lambda: ele.link)
1044
+ if not full_url: continue
1045
+
1046
+ # 去重逻辑:保留描述最长的
1047
+ if full_url in seen_links:
1048
+ existing_link_text = seen_links[full_url]
1049
+ if len(text) > existing_link_text:
1050
+ seen_links[full_url] = text # 更新为更长文本的
1051
+ else:
1052
+ seen_links[full_url] = text
1053
+
1054
+ # B. 按钮 (Buttons) -> 直接添加到 button_elements
1055
+ else:
1056
+ # 按钮不需要 URL 去重,因为不同的按钮可能有不同的副作用
1057
+ # 构造返回对象
1058
+ button_elements[text] = DrissionPageElement(ele)
1059
+
1060
+ count += 1
1061
+
1062
+ except Exception:
1063
+ # 遍历过程中元素可能会失效 (StaleElement),直接忽略
1064
+ continue
1065
+
1066
+
1067
+
1068
+ self.logger.info(f"🔍 Scanned {len(seen_links) + len(button_elements)} elements ({len(seen_links)} links, {len(button_elements)} buttons)")
1069
+ return seen_links, button_elements
1070
+
1071
+ async def get_target_element(self,tab, element: Union[str, PageElement] ):
1072
+ # 根据 element 类型获取 ChromiumElement
1073
+ if isinstance(element, str):
1074
+ # 如果是字符串选择器,使用 find_element 查找
1075
+ target_element = (await self.find_element(tab, element)).get_element()
1076
+ else:
1077
+ # 如果是 PageElement 对象,直接获取底层元素
1078
+ target_element = element.get_element()
1079
+
1080
+ return target_element
1081
+
1082
+
1083
+ async def click_and_observe(self, tab: TabHandle, element: Union[str, PageElement]) -> InteractionReport:
1084
+ """
1085
+ [Phase 5] 核心交互函数。
1086
+ 点击元素,并智能等待,捕捉所有可能的后果 (新Tab、下载、页面变动)。
1087
+ 必须能够处理 SPA (单页应用) 的 DOM 变动检测。
1088
+
1089
+ Args:
1090
+ tab: 标签页句柄
1091
+ element: 要点击的元素,可以是选择器字符串或 PageElement 对象
1092
+
1093
+ Returns:
1094
+ InteractionReport: 点击后的后果报告单
1095
+ """
1096
+ # 记录点击前的状态
1097
+ old_url = tab.url
1098
+ old_tab_count = self.browser.tabs_count
1099
+ # 快速计算指纹 (IO开销微乎其微)
1100
+ # 加上 title,防止 body 为空的情况
1101
+ try:
1102
+ body_text = await asyncio.to_thread(lambda: tab.ele('body', timeout=0.1).text)
1103
+ raw_text = f"{tab.title}|{body_text}"
1104
+ old_fingerprint = hashlib.md5(raw_text.encode('utf-8')).hexdigest()
1105
+ except:
1106
+ old_fingerprint = ""
1107
+
1108
+ target_element = await self.get_target_element(tab, element)
1109
+
1110
+ # 点击元素
1111
+ try:
1112
+ await asyncio.to_thread(target_element.click)
1113
+ except Exception as e:
1114
+ self.logger.exception(f"Click failed for element: {element}")
1115
+ #但不管有啥错,我们继续
1116
+ # 点击后有多种可能,一种是有新tab出现,一种是没有,当前tab重新打开别的地方,也可能是当前tab内部dom 变化
1117
+ # 有新tab一般几乎立刻就出现了,我们等1秒看看有没有就知道了
1118
+ await asyncio.sleep(1)
1119
+ new_tabs = []
1120
+ is_url_changed = False
1121
+ is_dom_changed = False
1122
+ try:
1123
+
1124
+ new_tab_count = self.browser.tabs_count
1125
+ has_new_tab = new_tab_count > old_tab_count
1126
+
1127
+
1128
+
1129
+
1130
+ # 检查是否有新标签页出现
1131
+ if has_new_tab:
1132
+ # 获取新出现的标签页
1133
+ all_tabs = await asyncio.to_thread(self.browser.get_tabs)
1134
+ new_tabs = all_tabs[old_tab_count:]
1135
+
1136
+ # B. 检查当前页面变化
1137
+
1138
+
1139
+ # 等待页面加载完成,最多60秒
1140
+ start_time = time.time()
1141
+ timeout = 60 # 60秒超时
1142
+
1143
+ new_url = tab.url
1144
+ if old_url != new_url:
1145
+ is_url_changed = True
1146
+ is_dom_changed = True
1147
+ # url变化了,直接范围,肯定都变了
1148
+ return InteractionReport(
1149
+ new_tabs=new_tabs,
1150
+ is_url_changed=is_url_changed,
1151
+ is_dom_changed=is_dom_changed
1152
+ )
1153
+ #url 没变化,那等到DOM Ready
1154
+ waited_time = time.time()-start_time
1155
+ while tab.states.ready_state != 'complete' and waited_time < timeout:
1156
+ await asyncio.sleep(0.2)
1157
+
1158
+
1159
+ # 这时候,可能是dom ready,也可能是超时了,不要紧,直接比较text指纹
1160
+
1161
+ try:
1162
+ new_body_text = await asyncio.to_thread(lambda: tab.ele('body', timeout=0.1).text)
1163
+ new_text = f"{tab.title}|{new_body_text}"
1164
+ new_fingerprint = hashlib.md5(new_text.encode('utf-8')).hexdigest()
1165
+ if old_fingerprint != new_fingerprint:
1166
+ is_dom_changed = True
1167
+ except:
1168
+ pass # 获取失败视作没变
1169
+
1170
+
1171
+ # 创建交互报告
1172
+ report = InteractionReport(
1173
+ new_tabs=new_tabs,
1174
+ is_url_changed=is_url_changed,
1175
+ is_dom_changed=is_dom_changed
1176
+ )
1177
+
1178
+ return report
1179
+ except Exception as e:
1180
+ self.logger.exception(f"Click and observe failed for element: {element}")
1181
+ return InteractionReport(
1182
+ new_tabs=new_tabs,
1183
+ is_url_changed=is_url_changed,
1184
+ is_dom_changed=is_dom_changed
1185
+ )
1186
+
1187
+ # ==========================================
1188
+ # Input & Control (精确输入与控制)
1189
+ # 用于 Phase 0 (搜索) 或特定表单交互
1190
+ # ==========================================
1191
+
1192
+ async def type_text(self, tab: TabHandle, selector: str, text: str, clear_existing: bool = True) -> bool:
1193
+ """
1194
+ 在指定元素中输入文本。
1195
+
1196
+ Args:
1197
+ selector: 定位符 (CSS/XPath/DrissionPage语法)。例如: 'input[name="q"]'
1198
+ text: 要输入的文本。
1199
+ clear_existing: 输入前是否清空原有内容。
1200
+
1201
+ Returns:
1202
+ bool: 操作是否成功 (元素找到且输入完成)。
1203
+ """
1204
+ if not tab:
1205
+ tab = await self.get_tab()
1206
+ ele = await asyncio.to_thread(tab.ele, selector)
1207
+ if ele:
1208
+ await asyncio.to_thread(ele.click)
1209
+ await asyncio.sleep(random.uniform(0.1,0.3) )
1210
+ await asyncio.to_thread(ele.input, vals=text, clear=clear_existing)
1211
+ return True
1212
+ return False
1213
+
1214
+ async def press_key(self, tab: TabHandle, key: Union[KeyAction, str]) -> InteractionReport:
1215
+ """
1216
+ 在当前页面模拟按键。
1217
+ 通常用于输入搜索词后按回车。
1218
+
1219
+ Returns:
1220
+ InteractionReport: 按键可能会导致页面刷新或跳转 (如按回车提交表单),
1221
+ 所以必须返回后果报告,供逻辑层判断是否需要 Soft Restart。
1222
+ """
1223
+ # TODO: 实现按键逻辑
1224
+ pass
1225
+
1226
+ async def click_by_selector(self, tab: TabHandle, selector: str) -> InteractionReport:
1227
+ """
1228
+ [精确点击] 通过选择器点击特定元素。
1229
+ 区别于 click_and_observe (那个是基于侦察出的 PageElement 对象),
1230
+ 这个方法用于已知页面结构的场景 (如点击搜索按钮)。
1231
+ """
1232
+ # TODO: 实现选择器点击逻辑
1233
+ pass
1234
+
1235
+ async def scroll(self, tab: TabHandle, direction: str = "bottom", distance: int = 0):
1236
+ """
1237
+ 手动控制滚动。
1238
+ Args:
1239
+ direction: 'bottom', 'top', 'down', 'up'
1240
+ distance: 像素值 (如果 direction 是 down/up)
1241
+ """
1242
+ if not tab:
1243
+ tab = await self.get_tab()
1244
+
1245
+ try:
1246
+ if direction == "bottom":
1247
+ # 在线程池中滚动到页面底部
1248
+ await asyncio.to_thread(tab.scroll.to_bottom)
1249
+ elif direction == "top":
1250
+ # 在线程池中滚动到页面顶部
1251
+ await asyncio.to_thread(tab.scroll.to_top)
1252
+ elif direction == "down":
1253
+ # 向下滚动指定像素
1254
+ if distance <= 0:
1255
+ distance = 500 # 默认向下滚动500像素
1256
+ await asyncio.to_thread(tab.scroll.down, distance)
1257
+ elif direction == "up":
1258
+ # 向上滚动指定像素
1259
+ if distance <= 0:
1260
+ distance = 500 # 默认向上滚动500像素
1261
+ await asyncio.to_thread(tab.scroll.up, distance)
1262
+ else:
1263
+ self.logger.warning(f"Unsupported scroll direction: {direction}")
1264
+ return False
1265
+
1266
+ # 短暂等待滚动完成
1267
+ await asyncio.sleep(0.5)
1268
+ return True
1269
+
1270
+ except Exception as e:
1271
+ self.logger.warning(f"Scroll failed: {e}")
1272
+ return False
1273
+
1274
+ async def find_element(self, tab: TabHandle, selector: str) -> PageElement:
1275
+ """
1276
+ 根据选择器查找元素。
1277
+ 用于验证页面是否加载正确 (例如:检查是否存在 'input[name="q"]' 来确认是否在 Google 首页)。
1278
+
1279
+ Args:
1280
+ tab: 标签页句柄
1281
+ selector: CSS选择器或XPath等定位符
1282
+
1283
+ Returns:
1284
+ PageElement: 找到的元素对象
1285
+
1286
+ Raises:
1287
+ Exception: 如果元素未找到或查找过程中出现错误
1288
+ """
1289
+ if not tab:
1290
+ tab = await self.get_tab()
1291
+
1292
+ # 在线程池中使用 DrissionPage 的 ele 方法查找元素
1293
+ chromium_element = await asyncio.to_thread(tab.ele, selector)
1294
+
1295
+ # 如果找不到元素,DrissionPage 会抛出异常,这里我们让它自然抛出
1296
+ return DrissionPageElement(chromium_element)