veddata 0.1.0__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.
- veddata/__init__.py +15 -0
- veddata/browser.py +364 -0
- veddata/chromium.py +321 -0
- veddata/dom.py +298 -0
- veddata/downloads.py +193 -0
- veddata/export.py +221 -0
- veddata/gate.py +173 -0
- veddata/limits.py +188 -0
- veddata/naming.py +111 -0
- veddata/network_monitor.py +565 -0
- veddata/observation.py +181 -0
- veddata/paths.py +131 -0
- veddata/requester.py +129 -0
- veddata/scripts.py +167 -0
- veddata/server.py +113 -0
- veddata/state.py +76 -0
- veddata/tools/__init__.py +0 -0
- veddata/tools/act.py +207 -0
- veddata/tools/chain.py +372 -0
- veddata/tools/chain_tool.py +80 -0
- veddata/tools/discover.py +936 -0
- veddata/tools/navigate.py +389 -0
- veddata/tools/observe.py +402 -0
- veddata/tools/scan.py +172 -0
- veddata/watch_engine.py +359 -0
- veddata/watch_policy.py +106 -0
- veddata-0.1.0.dist-info/METADATA +198 -0
- veddata-0.1.0.dist-info/RECORD +32 -0
- veddata-0.1.0.dist-info/WHEEL +5 -0
- veddata-0.1.0.dist-info/entry_points.txt +2 -0
- veddata-0.1.0.dist-info/licenses/LICENSE +21 -0
- veddata-0.1.0.dist-info/top_level.txt +1 -0
veddata/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""veddata(斥候)— 给 AI 用的网页数据源发现的 MCP 服务。
|
|
2
|
+
|
|
3
|
+
中文名「斥候」取自古代军事侦察兵;英文名 veddata = ved(vedette,军事术语
|
|
4
|
+
"骑马斥候")+ data,表示"派出去把数据来源摸清楚"。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
# 单一真源:安装元数据(来自 pyproject.toml 的 version)
|
|
11
|
+
__version__ = version("veddata")
|
|
12
|
+
except PackageNotFoundError: # 未安装时(直接从源码运行)
|
|
13
|
+
__version__ = "1.0.0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
veddata/browser.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Browser module — Chromium lifecycle, tab management, text extraction.
|
|
2
|
+
|
|
3
|
+
Each tab is identified by a CDP targetId (full UUID). Display uses the
|
|
4
|
+
first 8 chars as a short ID. All tab lookups use prefix matching so
|
|
5
|
+
AI can pass the short ID and still locate the full tab.
|
|
6
|
+
|
|
7
|
+
Playwright async API: one persistent BrowserContext = one session,
|
|
8
|
+
its pages are the tabs. All methods that touch Playwright are async.
|
|
9
|
+
|
|
10
|
+
浏览器怎么起(三种模式,按优先级)
|
|
11
|
+
----------------------------------
|
|
12
|
+
1. ``BROWSER_ADDRESS`` 有值 → ``connect_over_cdp``(接管已有浏览器,不动它)
|
|
13
|
+
2. 否则 → **我们自己 Popen 一个普通 Chrome**(``--remote-debugging-port`` +
|
|
14
|
+
持久 profile),再 ``connect_over_cdp``。见 ``chromium.py`` 里关于指纹的解释:
|
|
15
|
+
这条路上 ``navigator.webdriver=false``、没有 Playwright 的注入痕迹与启动开关。
|
|
16
|
+
3. 找不到 Chrome/Edge → 回退到 Playwright 启动(``launch_persistent_context``),
|
|
17
|
+
功能一致但会带上自动化指纹,往 stderr 说明一次。
|
|
18
|
+
|
|
19
|
+
状态观测:页面自己跳转 / 标签页增删都会 ``observation.mark_dirty()`` ——
|
|
20
|
+
那是"工具之外发生的变化",AI 必须重新建档才能继续操作。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import uuid
|
|
27
|
+
|
|
28
|
+
from playwright.async_api import BrowserContext, Page, Playwright
|
|
29
|
+
|
|
30
|
+
from veddata import chromium, observation, paths
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BrowserSession:
|
|
34
|
+
"""Manages a single Chromium instance with multiple tabs."""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._playwright: Playwright | None = None
|
|
38
|
+
self._context: BrowserContext | None = None # persistent context
|
|
39
|
+
self._pages: dict[str, Page] = {} # tab_id (CDP targetId) → Page
|
|
40
|
+
self._page_to_id: dict[int, str] = {} # id(page) → tab_id
|
|
41
|
+
self._urls: dict[int, str] = {} # id(page) → last seen URL
|
|
42
|
+
self._current_tab: str | None = None # active tab_id
|
|
43
|
+
self._auto_page_hook = False
|
|
44
|
+
self._launcher: chromium.Launcher | None = None
|
|
45
|
+
self._mode = "" # attach / own / fallback
|
|
46
|
+
self._note = ""
|
|
47
|
+
|
|
48
|
+
# ---- 启动 / 接管 ----
|
|
49
|
+
|
|
50
|
+
async def _ensure_browser(self) -> BrowserContext:
|
|
51
|
+
if self._context is not None:
|
|
52
|
+
return self._context
|
|
53
|
+
|
|
54
|
+
from playwright.async_api import async_playwright
|
|
55
|
+
|
|
56
|
+
self._playwright = await async_playwright().start()
|
|
57
|
+
|
|
58
|
+
headless = os.environ.get("HEADLESS", "false") == "true"
|
|
59
|
+
address = os.environ.get("BROWSER_ADDRESS", "").strip()
|
|
60
|
+
|
|
61
|
+
if address:
|
|
62
|
+
self._mode = "attach"
|
|
63
|
+
self._note = f"attached to {address}"
|
|
64
|
+
else:
|
|
65
|
+
try:
|
|
66
|
+
self._launcher = chromium.Launcher()
|
|
67
|
+
address = self._launcher.ensure(paths.profile_dir(), headless)
|
|
68
|
+
self._mode = "own"
|
|
69
|
+
self._note = f"launched {Path_label(self._launcher.path)} on {address}"
|
|
70
|
+
except chromium.ChromiumError as exc:
|
|
71
|
+
# 没有可用的 Chrome/Edge → 回退到 Playwright(会带自动化指纹)
|
|
72
|
+
print(f"[veddata] {exc};回退到 Playwright 启动(该路径带自动化指纹,"
|
|
73
|
+
f"部分站点会拦截)", file=sys.stderr)
|
|
74
|
+
self._mode = "fallback"
|
|
75
|
+
self._note = "playwright launch (fallback)"
|
|
76
|
+
self._context = await self._playwright.chromium.launch_persistent_context(
|
|
77
|
+
user_data_dir=str(paths.profile_dir()),
|
|
78
|
+
headless=headless,
|
|
79
|
+
)
|
|
80
|
+
self._after_attach()
|
|
81
|
+
return self._context
|
|
82
|
+
|
|
83
|
+
# no_defaults=True:不要 Playwright 的默认覆盖(focus emulation、
|
|
84
|
+
# colorScheme/reducedMotion/forcedColors/contrast 媒体模拟、acceptDownloads)。
|
|
85
|
+
# DrissionPage 走的是裸 CDP,什么都不套 —— 我们对齐它,别给页面留下可观测的差异。
|
|
86
|
+
browser = await self._playwright.chromium.connect_over_cdp(address, no_defaults=True)
|
|
87
|
+
contexts = browser.contexts
|
|
88
|
+
self._context = contexts[0] if contexts else await browser.new_context()
|
|
89
|
+
# 下载事实:订阅 browser 级下载事件(只记录,不接管落盘)
|
|
90
|
+
from veddata import downloads
|
|
91
|
+
try:
|
|
92
|
+
self._downloads_cdp = await browser.new_browser_cdp_session()
|
|
93
|
+
await downloads.attach(self._downloads_cdp, paths.profile_dir())
|
|
94
|
+
except Exception:
|
|
95
|
+
self._downloads_cdp = None
|
|
96
|
+
self._after_attach()
|
|
97
|
+
return self._context
|
|
98
|
+
|
|
99
|
+
def _after_attach(self) -> None:
|
|
100
|
+
if self._auto_page_hook or self._context is None:
|
|
101
|
+
return
|
|
102
|
+
self._auto_page_hook = True
|
|
103
|
+
# 页面自己开的新标签页(target=_blank / window.open)→ 自动登记 + 状态置脏
|
|
104
|
+
self._context.on("page", self._on_new_page)
|
|
105
|
+
|
|
106
|
+
# ---- 自动注册(页面自身打开的新标签页) ----
|
|
107
|
+
|
|
108
|
+
def _on_new_page(self, page: Page) -> None:
|
|
109
|
+
"""sync 回调:派发到事件循环执行异步注册,不抢占当前 tab。"""
|
|
110
|
+
observation.mark_dirty("新标签页被打开(页面自己或用户)")
|
|
111
|
+
asyncio.create_task(self._auto_register(page))
|
|
112
|
+
|
|
113
|
+
async def _auto_register(self, page: Page) -> None:
|
|
114
|
+
await self._register_tab(page, set_current=False)
|
|
115
|
+
|
|
116
|
+
async def _register_tab(self, page: Page, set_current: bool = True) -> None:
|
|
117
|
+
"""Register a page as a tab, keyed by CDP targetId (fallback uuid4)."""
|
|
118
|
+
tid = ""
|
|
119
|
+
try:
|
|
120
|
+
session = await page.context.new_cdp_session(page)
|
|
121
|
+
info = await session.send("Target.getTargetInfo")
|
|
122
|
+
tid = info.get("targetInfo", {}).get("targetId", "")
|
|
123
|
+
except Exception:
|
|
124
|
+
tid = ""
|
|
125
|
+
if not tid:
|
|
126
|
+
tid = uuid.uuid4().hex
|
|
127
|
+
self._pages[tid] = page
|
|
128
|
+
self._page_to_id[id(page)] = tid
|
|
129
|
+
try:
|
|
130
|
+
self._urls[id(page)] = page.url or ""
|
|
131
|
+
except Exception:
|
|
132
|
+
self._urls[id(page)] = ""
|
|
133
|
+
|
|
134
|
+
# 页面关闭 → 账本失效
|
|
135
|
+
page.on("close", lambda _p=page: self._on_page_closed(_p))
|
|
136
|
+
# 主框架跳转 → 账本失效(工具自己发起的跳转会在工具返回时重新建档)
|
|
137
|
+
page.on("framenavigated", lambda frame, _p=page: self._on_frame_navigated(_p, frame))
|
|
138
|
+
|
|
139
|
+
if set_current:
|
|
140
|
+
self._current_tab = tid
|
|
141
|
+
# 事件监听统一挂接点(monitor / script registry / console)
|
|
142
|
+
from veddata import state
|
|
143
|
+
await state.attach_page(page)
|
|
144
|
+
|
|
145
|
+
def _on_page_closed(self, page: Page) -> None:
|
|
146
|
+
observation.mark_dirty("标签页被关闭(页面自己或用户)")
|
|
147
|
+
self._unregister_tab(page)
|
|
148
|
+
|
|
149
|
+
def _on_frame_navigated(self, page: Page, frame) -> None:
|
|
150
|
+
"""只看主框架:URL 真变了才置脏(hash 变化/iframe 不算)。"""
|
|
151
|
+
try:
|
|
152
|
+
if frame is not page.main_frame:
|
|
153
|
+
return
|
|
154
|
+
url = frame.url or ""
|
|
155
|
+
except Exception:
|
|
156
|
+
return
|
|
157
|
+
previous = self._urls.get(id(page))
|
|
158
|
+
if previous is None:
|
|
159
|
+
self._urls[id(page)] = url
|
|
160
|
+
return
|
|
161
|
+
if url != previous and url not in ("about:blank", "") :
|
|
162
|
+
self._urls[id(page)] = url
|
|
163
|
+
observation.mark_dirty(f"页面跳转 → {url[:80]}")
|
|
164
|
+
|
|
165
|
+
def _unregister_tab(self, page: Page) -> None:
|
|
166
|
+
"""页面关闭时清理其所有追踪状态。"""
|
|
167
|
+
tid = self._page_to_id.pop(id(page), None)
|
|
168
|
+
self._urls.pop(id(page), None)
|
|
169
|
+
if tid is None:
|
|
170
|
+
return
|
|
171
|
+
self._pages.pop(tid, None)
|
|
172
|
+
from veddata import state
|
|
173
|
+
state._dom_trees.pop(tid, None)
|
|
174
|
+
state._script_registries.pop(tid, None)
|
|
175
|
+
state._watches.pop(tid, None)
|
|
176
|
+
pool = state.get_pool()
|
|
177
|
+
if pool:
|
|
178
|
+
pool.prune(tid)
|
|
179
|
+
if self._current_tab == tid:
|
|
180
|
+
remaining = list(self._pages)
|
|
181
|
+
self._current_tab = remaining[0] if remaining else None
|
|
182
|
+
|
|
183
|
+
async def open(self, url: str) -> dict:
|
|
184
|
+
"""Open a URL in a tab. Reuses blank tab or creates new one."""
|
|
185
|
+
context = await self._ensure_browser()
|
|
186
|
+
page = None
|
|
187
|
+
for p in context.pages:
|
|
188
|
+
if p.url in ("about:blank", "", "chrome://newtab/"):
|
|
189
|
+
page = p
|
|
190
|
+
break
|
|
191
|
+
if page is None:
|
|
192
|
+
page = await context.new_page()
|
|
193
|
+
await self._register_tab(page)
|
|
194
|
+
try:
|
|
195
|
+
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
return {"tab_id": self.current_tab_id(), "title": "", "text": f"Failed to navigate: {e}"}
|
|
198
|
+
return await self._extract_page_info(page)
|
|
199
|
+
|
|
200
|
+
def current_tab_id(self) -> str:
|
|
201
|
+
return self._current_tab or ""
|
|
202
|
+
|
|
203
|
+
def get_tab_url(self, tab_id_str: str) -> str:
|
|
204
|
+
tid = self.resolve_tab_id(tab_id_str)
|
|
205
|
+
page = self._pages.get(tid) if tid else None
|
|
206
|
+
if page is None:
|
|
207
|
+
return ""
|
|
208
|
+
return (page.url or "")[:60]
|
|
209
|
+
|
|
210
|
+
def resolve_tab_id(self, short_id: str) -> str | None:
|
|
211
|
+
"""Prefix-match a short ID to a full tab_id."""
|
|
212
|
+
if not short_id:
|
|
213
|
+
return None
|
|
214
|
+
for tid in self._pages:
|
|
215
|
+
if tid.startswith(short_id):
|
|
216
|
+
return tid
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
async def get_current_page(self) -> Page | None:
|
|
220
|
+
context = await self._ensure_browser()
|
|
221
|
+
if self._current_tab and self._current_tab in self._pages:
|
|
222
|
+
return self._pages[self._current_tab]
|
|
223
|
+
pages = context.pages
|
|
224
|
+
if pages:
|
|
225
|
+
self._current_tab = self._page_to_id.get(id(pages[-1]))
|
|
226
|
+
return pages[-1]
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
async def get_page_by_id(self, tab_id_str: str) -> Page | None:
|
|
230
|
+
"""Get a Page by CDP ID or short ID (prefix matched)."""
|
|
231
|
+
context = await self._ensure_browser()
|
|
232
|
+
tid = self.resolve_tab_id(tab_id_str)
|
|
233
|
+
if tid and tid in self._pages:
|
|
234
|
+
self._current_tab = tid
|
|
235
|
+
return self._pages[tid]
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
async def switch_tab(self, tab_id_str: str) -> str:
|
|
239
|
+
page = await self.get_page_by_id(tab_id_str)
|
|
240
|
+
if page:
|
|
241
|
+
short = tab_id_str[:8] if len(tab_id_str) >= 8 else tab_id_str
|
|
242
|
+
return f"Switched to tab {short}"
|
|
243
|
+
short = tab_id_str[:8] if len(tab_id_str) >= 8 else tab_id_str
|
|
244
|
+
return f"Tab {short} not found"
|
|
245
|
+
|
|
246
|
+
async def list_tabs(self) -> str:
|
|
247
|
+
"""标签页清单:短 ID + URL + 标题(AI 靠它知道"每个页都在哪")。"""
|
|
248
|
+
context = await self._ensure_browser()
|
|
249
|
+
lines = [f"Open tabs ({len(self._pages)}):"]
|
|
250
|
+
for tid, page in self._pages.items():
|
|
251
|
+
try:
|
|
252
|
+
title = (await page.title() or "")[:60]
|
|
253
|
+
except Exception:
|
|
254
|
+
title = ""
|
|
255
|
+
try:
|
|
256
|
+
url = (page.url or "")[:120]
|
|
257
|
+
except Exception:
|
|
258
|
+
url = ""
|
|
259
|
+
mark = " ← current" if tid == self._current_tab else ""
|
|
260
|
+
lines.append(f" [{tid[:8]}] {url}{mark}")
|
|
261
|
+
if title:
|
|
262
|
+
lines.append(f" {title}")
|
|
263
|
+
return "\n".join(lines)
|
|
264
|
+
|
|
265
|
+
async def close_tab(self, tab_id_str: str | None = None) -> str:
|
|
266
|
+
await self._ensure_browser()
|
|
267
|
+
tid = self.resolve_tab_id(tab_id_str) if tab_id_str else self._current_tab
|
|
268
|
+
if not tid:
|
|
269
|
+
return "No tab to close."
|
|
270
|
+
page = self._pages.get(tid)
|
|
271
|
+
if page is not None:
|
|
272
|
+
try:
|
|
273
|
+
await page.close()
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
self._unregister_tab(page)
|
|
277
|
+
short = tid[:8]
|
|
278
|
+
return f"Tab {short} closed."
|
|
279
|
+
|
|
280
|
+
def mode_note(self) -> str:
|
|
281
|
+
"""当前浏览器怎么来的(排查用,也进 ved_status)。"""
|
|
282
|
+
return self._note or "(not started)"
|
|
283
|
+
|
|
284
|
+
async def close(self) -> str:
|
|
285
|
+
"""关闭/断开浏览器并清空所有捕获数据。
|
|
286
|
+
|
|
287
|
+
自己起的浏览器 → 关掉;接管来的(``BROWSER_ADDRESS``)→ 只断开,不动它。
|
|
288
|
+
"""
|
|
289
|
+
if self._mode == "fallback" and self._context is not None:
|
|
290
|
+
try:
|
|
291
|
+
await self._context.close()
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
if self._playwright is not None:
|
|
295
|
+
try:
|
|
296
|
+
await self._playwright.stop()
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
self._playwright = None
|
|
300
|
+
|
|
301
|
+
profile = paths.profile_dir()
|
|
302
|
+
external = bool(os.environ.get("BROWSER_ADDRESS", "").strip())
|
|
303
|
+
if self._launcher is not None and self._launcher.started:
|
|
304
|
+
self._launcher.stop(profile) # 本进程起的:杀进程 + 删档案
|
|
305
|
+
note = "Browser closed."
|
|
306
|
+
elif external:
|
|
307
|
+
if self._launcher is not None:
|
|
308
|
+
self._launcher.stop(profile, clear=False) # 外部浏览器:只断开,档案留着
|
|
309
|
+
note = "Detached (external browser via BROWSER_ADDRESS left running)."
|
|
310
|
+
else:
|
|
311
|
+
# 不是本进程起的,但档案是我们写的(例如服务重启后接管回来的)→ 真关掉
|
|
312
|
+
outcome = chromium.close_running(profile)
|
|
313
|
+
note = (f"Browser closed (pid={outcome['pid']})." if outcome.get("killed")
|
|
314
|
+
else f"Browser closed (nothing running, pid={outcome.get('pid') or '-'}).")
|
|
315
|
+
if self._launcher is not None:
|
|
316
|
+
self._launcher.stop(profile, clear=False)
|
|
317
|
+
self._launcher = None
|
|
318
|
+
|
|
319
|
+
self._context = None
|
|
320
|
+
self._pages.clear()
|
|
321
|
+
self._page_to_id.clear()
|
|
322
|
+
self._urls.clear()
|
|
323
|
+
self._current_tab = None
|
|
324
|
+
self._auto_page_hook = False
|
|
325
|
+
self._mode = ""
|
|
326
|
+
self._note = ""
|
|
327
|
+
observation.reset()
|
|
328
|
+
return note
|
|
329
|
+
|
|
330
|
+
async def _extract_page_info(self, page: Page) -> dict:
|
|
331
|
+
try:
|
|
332
|
+
await page.wait_for_load_state("domcontentloaded", timeout=5000)
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
try:
|
|
336
|
+
title = await page.title() or ""
|
|
337
|
+
except Exception:
|
|
338
|
+
title = ""
|
|
339
|
+
text = await self._get_text(page)
|
|
340
|
+
return {"tab_id": self.current_tab_id(), "title": title, "text": text}
|
|
341
|
+
|
|
342
|
+
async def get_text(self) -> str:
|
|
343
|
+
page = await self.get_current_page()
|
|
344
|
+
if page is None:
|
|
345
|
+
return ""
|
|
346
|
+
return await self._get_text(page)
|
|
347
|
+
|
|
348
|
+
@staticmethod
|
|
349
|
+
async def _get_text(page: Page) -> str:
|
|
350
|
+
max_len = int(os.environ.get("MAX_TEXT_LENGTH", "3000"))
|
|
351
|
+
try:
|
|
352
|
+
text = await page.evaluate("document.body.innerText || ''")
|
|
353
|
+
if text:
|
|
354
|
+
return text[:max_len]
|
|
355
|
+
except Exception:
|
|
356
|
+
pass
|
|
357
|
+
return ""
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def Path_label(path: str | None) -> str: # noqa: N802
|
|
361
|
+
"""简短浏览器标识(日志/状态里用)。"""
|
|
362
|
+
if not path:
|
|
363
|
+
return "(unknown browser)"
|
|
364
|
+
return os.path.basename(path)
|