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.
Files changed (69) hide show
  1. package/.agents/plugins/marketplace.json +1 -1
  2. package/.codex-plugin/plugin.json +4 -1
  3. package/README.md +15 -9
  4. package/README.zh.md +16 -9
  5. package/config/providers.json +26 -0
  6. package/config/settings.example.json +36 -0
  7. package/docs/bookmark-research-architecture.md +2 -2
  8. package/docs/canvas-tree-reading-acceptance.md +98 -0
  9. package/docs/configuration.en.md +70 -0
  10. package/docs/configuration.md +70 -0
  11. package/docs/details.en.md +10 -6
  12. package/docs/details.md +3 -3
  13. package/docs/harness-compatibility.md +1 -1
  14. package/docs/installation.en.md +4 -2
  15. package/docs/installation.md +4 -2
  16. package/docs/instructions.en.md +1 -0
  17. package/docs/instructions.md +1 -0
  18. package/docs/user-guide.en.md +1 -1
  19. package/docs/user-guide.md +1 -1
  20. package/hosts/dsh/plugin.js +3 -0
  21. package/package.json +1 -1
  22. package/scripts/bench_sync.py +191 -0
  23. package/scripts/bench_tools_list.py +129 -0
  24. package/scripts/evaluate_ladder.py +149 -0
  25. package/scripts/export_bundle.py +2 -0
  26. package/scripts/host_install.py +4 -1
  27. package/scripts/install.py +120 -28
  28. package/skills/bookmark-research/SKILL.md +28 -9
  29. package/skills/bookmark-research/references/package-semantics.md +56 -6
  30. package/skills/bookmark-research/references/research-workflow.md +5 -2
  31. package/skills/bookmark-research/references/settings-and-archive.md +5 -3
  32. package/skills/bookmark-research/references/wiki.md +3 -1
  33. package/skills/bookmark-research/references/zh/package-semantics.md +56 -6
  34. package/skills/bookmark-research/references/zh/research-workflow.md +5 -2
  35. package/skills/bookmark-research/references/zh/settings-and-archive.md +5 -3
  36. package/skills/bookmark-research/references/zh/skill-guide.md +20 -8
  37. package/skills/bookmark-research/references/zh/wiki.md +3 -1
  38. package/src/archive.py +100 -0
  39. package/src/bookmark_index.py +808 -41
  40. package/src/browser_client.py +342 -0
  41. package/src/cli.py +298 -14
  42. package/src/credentials.py +2 -1
  43. package/src/fetch_cache.py +60 -0
  44. package/src/grok_search.py +272 -0
  45. package/src/jobs.py +349 -0
  46. package/src/junk.py +33 -0
  47. package/src/locate.py +862 -0
  48. package/src/mcp_dispatch.py +22 -6
  49. package/src/mcp_server.py +573 -23
  50. package/src/netscape.py +124 -0
  51. package/src/onboarding.py +59 -9
  52. package/src/perplexity.py +78 -0
  53. package/src/raw_library.py +183 -45
  54. package/src/readiness.py +40 -5
  55. package/src/ref_codec.py +70 -0
  56. package/src/research.py +125 -12
  57. package/src/research_plan.py +11 -1
  58. package/src/routing.py +5 -2
  59. package/src/search_results.py +80 -2
  60. package/src/settings.py +61 -16
  61. package/src/source_inputs.py +109 -3
  62. package/src/source_manager.py +71 -12
  63. package/src/source_versions.py +20 -2
  64. package/src/source_watcher.py +4 -2
  65. package/src/storage.py +60 -0
  66. package/src/url_classify.py +45 -0
  67. package/src/web_search.py +106 -10
  68. package/src/wiki.py +269 -18
  69. 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
+ }