bookmark-research 0.6.0 → 0.7.0
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.
- package/.agents/plugins/marketplace.json +1 -1
- package/.codex-plugin/plugin.json +4 -1
- package/README.md +15 -9
- package/README.zh.md +16 -9
- package/config/providers.json +26 -0
- package/config/settings.example.json +36 -0
- package/docs/bookmark-research-architecture.md +2 -2
- package/docs/canvas-tree-reading-acceptance.md +98 -0
- package/docs/configuration.en.md +70 -0
- package/docs/configuration.md +70 -0
- package/docs/details.en.md +10 -6
- package/docs/details.md +3 -3
- package/docs/harness-compatibility.md +1 -1
- package/docs/installation.en.md +4 -2
- package/docs/installation.md +4 -2
- package/docs/instructions.en.md +1 -0
- package/docs/instructions.md +1 -0
- package/docs/user-guide.en.md +1 -1
- package/docs/user-guide.md +1 -1
- package/hosts/dsh/plugin.js +3 -0
- package/package.json +1 -1
- package/scripts/bench_sync.py +191 -0
- package/scripts/bench_tools_list.py +129 -0
- package/scripts/evaluate_ladder.py +149 -0
- package/scripts/export_bundle.py +2 -0
- package/scripts/host_install.py +4 -1
- package/scripts/install.py +120 -28
- package/skills/bookmark-research/SKILL.md +28 -9
- package/skills/bookmark-research/references/package-semantics.md +56 -6
- package/skills/bookmark-research/references/research-workflow.md +5 -2
- package/skills/bookmark-research/references/settings-and-archive.md +5 -3
- package/skills/bookmark-research/references/wiki.md +3 -1
- package/skills/bookmark-research/references/zh/package-semantics.md +56 -6
- package/skills/bookmark-research/references/zh/research-workflow.md +5 -2
- package/skills/bookmark-research/references/zh/settings-and-archive.md +5 -3
- package/skills/bookmark-research/references/zh/skill-guide.md +20 -8
- package/skills/bookmark-research/references/zh/wiki.md +3 -1
- package/src/archive.py +100 -0
- package/src/bookmark_index.py +808 -41
- package/src/browser_client.py +342 -0
- package/src/cli.py +298 -14
- package/src/credentials.py +2 -1
- package/src/fetch_cache.py +60 -0
- package/src/grok_search.py +272 -0
- package/src/jobs.py +349 -0
- package/src/junk.py +33 -0
- package/src/locate.py +862 -0
- package/src/mcp_dispatch.py +22 -6
- package/src/mcp_server.py +573 -23
- package/src/netscape.py +124 -0
- package/src/onboarding.py +59 -9
- package/src/perplexity.py +78 -0
- package/src/raw_library.py +183 -45
- package/src/readiness.py +40 -5
- package/src/ref_codec.py +70 -0
- package/src/research.py +125 -12
- package/src/research_plan.py +11 -1
- package/src/routing.py +5 -2
- package/src/search_results.py +80 -2
- package/src/settings.py +61 -16
- package/src/source_inputs.py +109 -3
- package/src/source_manager.py +71 -12
- package/src/source_versions.py +20 -2
- package/src/source_watcher.py +4 -2
- package/src/storage.py +60 -0
- package/src/url_classify.py +45 -0
- package/src/web_search.py +106 -10
- package/src/wiki.py +269 -18
- package/src/wiki_search.py +119 -27
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Local Browser Rendering provider using Playwright or Chrome DevTools Protocol (CDP).
|
|
2
|
+
|
|
3
|
+
Disabled by default; must be explicitly enabled via settings.browser.enabled=True.
|
|
4
|
+
No mandatory external dependencies: optional playwright import, CDP driver, or mock.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import concurrent.futures
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import socket
|
|
13
|
+
import struct
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from release import user_agent
|
|
22
|
+
from remote_mcp import McpError, McpHttpClient
|
|
23
|
+
from settings import Settings
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BrowserClient:
|
|
27
|
+
"""Render dynamic JavaScript pages using a local browser engine."""
|
|
28
|
+
|
|
29
|
+
_mock_handler = None # Class-level mock handler for testing
|
|
30
|
+
|
|
31
|
+
def __init__(self, settings=None, timeout=None):
|
|
32
|
+
self.settings = settings if settings is not None else Settings()
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
self._renders = 0
|
|
36
|
+
self._requests = 0
|
|
37
|
+
|
|
38
|
+
def snapshot_usage(self):
|
|
39
|
+
with self._lock:
|
|
40
|
+
return {"http_requests": self._requests, "tool_calls": self._renders}
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def set_mock(cls, handler):
|
|
44
|
+
cls._mock_handler = handler
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def clear_mock(cls):
|
|
48
|
+
cls._mock_handler = None
|
|
49
|
+
|
|
50
|
+
def _render_mock(self, url, timeout):
|
|
51
|
+
handler = BrowserClient._mock_handler
|
|
52
|
+
if callable(handler):
|
|
53
|
+
res = handler(url)
|
|
54
|
+
if isinstance(res, Exception):
|
|
55
|
+
raise res
|
|
56
|
+
return res
|
|
57
|
+
sample = ("This is rendered prose content that has enough sentences to pass the prose weight check and be classified as usable. Sentence one. Sentence two. Sentence three. Sentence four. " * 5)
|
|
58
|
+
return {
|
|
59
|
+
"url": url,
|
|
60
|
+
"title": "Rendered Page Title",
|
|
61
|
+
"text": sample,
|
|
62
|
+
"content": sample
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def _render_playwright(self, url, timeout, headless=True):
|
|
66
|
+
try:
|
|
67
|
+
from playwright.sync_api import sync_playwright
|
|
68
|
+
except ImportError:
|
|
69
|
+
raise McpError("Playwright is not installed. Install with 'pip install playwright' or configure CDP driver", "dependency_missing")
|
|
70
|
+
|
|
71
|
+
with sync_playwright() as p:
|
|
72
|
+
browser = p.chromium.launch(headless=headless)
|
|
73
|
+
try:
|
|
74
|
+
page = browser.new_page(user_agent=user_agent())
|
|
75
|
+
page.set_default_timeout(int(timeout * 1000))
|
|
76
|
+
page.goto(url, wait_until="networkidle")
|
|
77
|
+
title = page.title()
|
|
78
|
+
# Evaluate full body innerText
|
|
79
|
+
text = page.evaluate("() => document.body ? document.body.innerText : ''")
|
|
80
|
+
final_url = page.url or url
|
|
81
|
+
return {"url": final_url, "title": title, "text": text, "content": text}
|
|
82
|
+
finally:
|
|
83
|
+
browser.close()
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _cdp_ws_evaluate(ws_url, expression, timeout=10):
|
|
87
|
+
if not ws_url:
|
|
88
|
+
return None
|
|
89
|
+
parsed = urllib.parse.urlparse(ws_url)
|
|
90
|
+
host = parsed.hostname or "127.0.0.1"
|
|
91
|
+
port = parsed.port or 9222
|
|
92
|
+
path = (parsed.path or "/") + (("?" + parsed.query) if parsed.query else "")
|
|
93
|
+
|
|
94
|
+
sock = socket.create_connection((host, port), timeout=timeout)
|
|
95
|
+
try:
|
|
96
|
+
sock.settimeout(timeout)
|
|
97
|
+
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
98
|
+
handshake = (
|
|
99
|
+
f"GET {path} HTTP/1.1\r\n"
|
|
100
|
+
f"Host: {host}:{port}\r\n"
|
|
101
|
+
"Upgrade: websocket\r\n"
|
|
102
|
+
"Connection: Upgrade\r\n"
|
|
103
|
+
f"Sec-WebSocket-Key: {key}\r\n"
|
|
104
|
+
"Sec-WebSocket-Version: 13\r\n\r\n"
|
|
105
|
+
)
|
|
106
|
+
sock.sendall(handshake.encode("ascii"))
|
|
107
|
+
|
|
108
|
+
response = b""
|
|
109
|
+
while b"\r\n\r\n" not in response:
|
|
110
|
+
chunk = sock.recv(4096)
|
|
111
|
+
if not chunk:
|
|
112
|
+
return None
|
|
113
|
+
response += chunk
|
|
114
|
+
|
|
115
|
+
if b" 101 " not in response:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
msg = json.dumps({
|
|
119
|
+
"id": 1,
|
|
120
|
+
"method": "Runtime.evaluate",
|
|
121
|
+
"params": {
|
|
122
|
+
"expression": expression,
|
|
123
|
+
"returnByValue": True,
|
|
124
|
+
"awaitPromise": True
|
|
125
|
+
}
|
|
126
|
+
}).encode("utf-8")
|
|
127
|
+
|
|
128
|
+
length = len(msg)
|
|
129
|
+
header = bytearray([0x81])
|
|
130
|
+
if length <= 125:
|
|
131
|
+
header.append(0x80 | length)
|
|
132
|
+
elif length <= 65535:
|
|
133
|
+
header.append(0x80 | 126)
|
|
134
|
+
header.extend(struct.pack("!H", length))
|
|
135
|
+
else:
|
|
136
|
+
header.append(0x80 | 127)
|
|
137
|
+
header.extend(struct.pack("!Q", length))
|
|
138
|
+
|
|
139
|
+
mask = os.urandom(4)
|
|
140
|
+
header.extend(mask)
|
|
141
|
+
masked_payload = bytes(b ^ mask[i % 4] for i, b in enumerate(msg))
|
|
142
|
+
sock.sendall(bytes(header) + masked_payload)
|
|
143
|
+
|
|
144
|
+
buf = bytearray()
|
|
145
|
+
deadline = time.time() + timeout
|
|
146
|
+
while time.time() < deadline:
|
|
147
|
+
chunk = sock.recv(65536)
|
|
148
|
+
if not chunk:
|
|
149
|
+
break
|
|
150
|
+
buf.extend(chunk)
|
|
151
|
+
while len(buf) >= 2:
|
|
152
|
+
opcode = buf[0] & 0x0F
|
|
153
|
+
payload_len = buf[1] & 0x7F
|
|
154
|
+
offset = 2
|
|
155
|
+
if payload_len == 126:
|
|
156
|
+
if len(buf) < 4:
|
|
157
|
+
break
|
|
158
|
+
payload_len = struct.unpack("!H", buf[2:4])[0]
|
|
159
|
+
offset = 4
|
|
160
|
+
elif payload_len == 127:
|
|
161
|
+
if len(buf) < 10:
|
|
162
|
+
break
|
|
163
|
+
payload_len = struct.unpack("!Q", buf[2:10])[0]
|
|
164
|
+
offset = 10
|
|
165
|
+
|
|
166
|
+
if len(buf) < offset + payload_len:
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
frame_data = buf[offset:offset + payload_len]
|
|
170
|
+
buf = buf[offset + payload_len:]
|
|
171
|
+
if opcode == 0x1:
|
|
172
|
+
try:
|
|
173
|
+
res = json.loads(frame_data.decode("utf-8"))
|
|
174
|
+
if res.get("id") == 1:
|
|
175
|
+
return res.get("result", {}).get("result", {}).get("value")
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
return None
|
|
179
|
+
finally:
|
|
180
|
+
sock.close()
|
|
181
|
+
|
|
182
|
+
def _render_cdp(self, url, timeout, cdp_url="http://127.0.0.1:9222"):
|
|
183
|
+
version_url = cdp_url.rstrip("/") + "/json/version"
|
|
184
|
+
req = urllib.request.Request(version_url, headers={"User-Agent": user_agent()})
|
|
185
|
+
try:
|
|
186
|
+
with urllib.request.urlopen(req, timeout=min(5, timeout)) as resp:
|
|
187
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
188
|
+
except (OSError, urllib.error.URLError, TimeoutError) as exc:
|
|
189
|
+
raise McpError("Cannot connect to Chrome DevTools Protocol at %s: %s" % (cdp_url, exc),
|
|
190
|
+
"connection_error", True) from None
|
|
191
|
+
|
|
192
|
+
new_tab_url = cdp_url.rstrip("/") + "/json/new?" + urllib.request.quote(url, safe="")
|
|
193
|
+
new_req = urllib.request.Request(new_tab_url, method="PUT", headers={"User-Agent": user_agent()})
|
|
194
|
+
try:
|
|
195
|
+
with urllib.request.urlopen(new_req, timeout=min(5, timeout)) as resp:
|
|
196
|
+
tab = json.loads(resp.read().decode("utf-8"))
|
|
197
|
+
except (OSError, urllib.error.URLError, TimeoutError) as exc:
|
|
198
|
+
raise McpError("CDP failed to create page tab: %s" % exc, "browser_error", True) from None
|
|
199
|
+
|
|
200
|
+
tab_id = tab.get("id")
|
|
201
|
+
ws_url = tab.get("webSocketDebuggerUrl")
|
|
202
|
+
close_url = cdp_url.rstrip("/") + "/json/close/" + str(tab_id) if tab_id else None
|
|
203
|
+
try:
|
|
204
|
+
time.sleep(min(2.0, timeout))
|
|
205
|
+
list_url = cdp_url.rstrip("/") + "/json/list"
|
|
206
|
+
with urllib.request.urlopen(list_url, timeout=5) as resp:
|
|
207
|
+
tabs = json.loads(resp.read().decode("utf-8"))
|
|
208
|
+
if isinstance(tabs, list):
|
|
209
|
+
active = next((t for t in tabs if isinstance(t, dict) and t.get("id") == tab_id), tab)
|
|
210
|
+
elif isinstance(tabs, dict):
|
|
211
|
+
active = tabs
|
|
212
|
+
else:
|
|
213
|
+
active = tab
|
|
214
|
+
title = active.get("title", "")
|
|
215
|
+
eval_result = None
|
|
216
|
+
if ws_url:
|
|
217
|
+
try:
|
|
218
|
+
eval_expr = "(() => ({ title: document.title || '', text: (document.body ? document.body.innerText : (document.documentElement ? document.documentElement.innerText : '')) }))()"
|
|
219
|
+
eval_result = self._cdp_ws_evaluate(ws_url, eval_expr, timeout=min(5, timeout))
|
|
220
|
+
except Exception:
|
|
221
|
+
eval_result = None
|
|
222
|
+
|
|
223
|
+
body_text = None
|
|
224
|
+
if isinstance(eval_result, dict):
|
|
225
|
+
body_text = (eval_result.get("text") or "").strip()
|
|
226
|
+
title = eval_result.get("title") or title
|
|
227
|
+
|
|
228
|
+
if not body_text:
|
|
229
|
+
raise McpError("CDP failed to extract page text: no usable body text extracted from " + url, "browser_error", True)
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
"url": active.get("url", url),
|
|
233
|
+
"title": title,
|
|
234
|
+
"text": body_text,
|
|
235
|
+
"content": body_text
|
|
236
|
+
}
|
|
237
|
+
finally:
|
|
238
|
+
if close_url:
|
|
239
|
+
try:
|
|
240
|
+
close_req = urllib.request.Request(close_url, method="GET")
|
|
241
|
+
urllib.request.urlopen(close_req, timeout=2)
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
def _render_one(self, url, driver, timeout, cdp_url, headless):
|
|
246
|
+
with self._lock:
|
|
247
|
+
self._requests += 1
|
|
248
|
+
self._renders += 1
|
|
249
|
+
|
|
250
|
+
if self._mock_handler is not None or driver == "mock":
|
|
251
|
+
return self._render_mock(url, timeout)
|
|
252
|
+
|
|
253
|
+
if driver == "playwright":
|
|
254
|
+
return self._render_playwright(url, timeout, headless=headless)
|
|
255
|
+
elif driver == "cdp":
|
|
256
|
+
return self._render_cdp(url, timeout, cdp_url=cdp_url)
|
|
257
|
+
elif driver == "auto":
|
|
258
|
+
# Try playwright first, then CDP
|
|
259
|
+
try:
|
|
260
|
+
import playwright.sync_api # noqa: F401
|
|
261
|
+
return self._render_playwright(url, timeout, headless=headless)
|
|
262
|
+
except ImportError:
|
|
263
|
+
return self._render_cdp(url, timeout, cdp_url=cdp_url)
|
|
264
|
+
else:
|
|
265
|
+
raise ValueError("Unknown browser driver: %s" % driver)
|
|
266
|
+
|
|
267
|
+
def call(self, purpose, query=None, urls=None, limit=None):
|
|
268
|
+
if purpose != "fetch":
|
|
269
|
+
raise ValueError("Browser provider supports fetch only")
|
|
270
|
+
if not urls:
|
|
271
|
+
raise ValueError("urls must be a non-empty list")
|
|
272
|
+
|
|
273
|
+
preferences = self.settings.load()
|
|
274
|
+
browser_cfg = preferences.get("browser", {})
|
|
275
|
+
if not browser_cfg.get("enabled"):
|
|
276
|
+
raise McpError("Browser rendering provider is disabled in settings; enable with settings.browser.enabled=true",
|
|
277
|
+
"capability_unavailable")
|
|
278
|
+
|
|
279
|
+
driver = browser_cfg.get("driver", "auto")
|
|
280
|
+
cdp_url = browser_cfg.get("cdp_url", "http://127.0.0.1:9222")
|
|
281
|
+
headless = browser_cfg.get("headless", True)
|
|
282
|
+
timeout = self.timeout if self.timeout is not None else browser_cfg.get("timeout_seconds", 20)
|
|
283
|
+
max_concurrency = min(browser_cfg.get("max_concurrency", 2), len(urls))
|
|
284
|
+
|
|
285
|
+
successful_results, failed_results = [], []
|
|
286
|
+
|
|
287
|
+
def execute(url):
|
|
288
|
+
try:
|
|
289
|
+
result = self._render_one(url, driver, timeout, cdp_url, headless)
|
|
290
|
+
return result, None
|
|
291
|
+
except McpError as exc:
|
|
292
|
+
return None, {"url": url, **exc.details()}
|
|
293
|
+
except TimeoutError:
|
|
294
|
+
return None, {"url": url, "error": "Browser rendering timed out after %ds" % timeout,
|
|
295
|
+
"error_kind": "timeout", "retryable": True}
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
return None, {"url": url, "error": "Browser render failed: %s" % exc,
|
|
298
|
+
"error_kind": "browser_error", "retryable": False}
|
|
299
|
+
|
|
300
|
+
if len(urls) == 1:
|
|
301
|
+
res, err = execute(urls[0])
|
|
302
|
+
if res is not None:
|
|
303
|
+
successful_results.append(res)
|
|
304
|
+
else:
|
|
305
|
+
failed_results.append(err)
|
|
306
|
+
else:
|
|
307
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_concurrency) as pool:
|
|
308
|
+
futures = {pool.submit(execute, url): url for url in urls}
|
|
309
|
+
for future in concurrent.futures.as_completed(futures):
|
|
310
|
+
url = futures[future]
|
|
311
|
+
try:
|
|
312
|
+
res, err = future.result(timeout=timeout + 2)
|
|
313
|
+
if res is not None:
|
|
314
|
+
successful_results.append(res)
|
|
315
|
+
else:
|
|
316
|
+
failed_results.append(err)
|
|
317
|
+
except concurrent.futures.TimeoutError:
|
|
318
|
+
failed_results.append({
|
|
319
|
+
"url": url,
|
|
320
|
+
"error": "Browser rendering timed out after %ds" % timeout,
|
|
321
|
+
"error_kind": "timeout",
|
|
322
|
+
"retryable": True
|
|
323
|
+
})
|
|
324
|
+
except Exception as exc:
|
|
325
|
+
failed_results.append({
|
|
326
|
+
"url": url,
|
|
327
|
+
"error": "Browser render failed: %s" % exc,
|
|
328
|
+
"error_kind": "browser_error",
|
|
329
|
+
"retryable": False
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
content_blocks = [
|
|
333
|
+
{"type": "text", "text": json.dumps(item, ensure_ascii=False)}
|
|
334
|
+
for item in successful_results
|
|
335
|
+
]
|
|
336
|
+
return {
|
|
337
|
+
"content": content_blocks,
|
|
338
|
+
"structuredContent": {
|
|
339
|
+
"results": successful_results,
|
|
340
|
+
"failed_results": failed_results
|
|
341
|
+
}
|
|
342
|
+
}
|