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
package/src/netscape.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Read the canvas card's HTML export (Netscape bookmark format).
|
|
2
|
+
|
|
3
|
+
The canvas right-click export writes ``<!DOCTYPE NETSCAPE-Bookmark-file-1>`` with
|
|
4
|
+
``<DT><H3>`` folders and ``<DT><A HREF>`` bookmarks, so the same reader also
|
|
5
|
+
accepts a Chrome bookmark export. Anything else falls back to plain ``<a href>``
|
|
6
|
+
links, mirroring the canvas app's own import.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
from html.parser import HTMLParser
|
|
10
|
+
|
|
11
|
+
MAX_ITEMS = 20000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _NetscapeParser(HTMLParser):
|
|
15
|
+
def __init__(self):
|
|
16
|
+
super().__init__(convert_charrefs=True)
|
|
17
|
+
self.stack = [[]]
|
|
18
|
+
self.pending_folder = None
|
|
19
|
+
self.mode = None
|
|
20
|
+
self.buffer = []
|
|
21
|
+
self.href = ""
|
|
22
|
+
self.pending_title = ""
|
|
23
|
+
self.count = 0
|
|
24
|
+
|
|
25
|
+
def handle_starttag(self, tag, attrs):
|
|
26
|
+
tag = tag.lower()
|
|
27
|
+
if tag == "dl":
|
|
28
|
+
if self.pending_folder is not None:
|
|
29
|
+
self.stack.append(self.pending_folder["children"])
|
|
30
|
+
self.pending_folder = None
|
|
31
|
+
else:
|
|
32
|
+
self.stack.append(self.stack[-1])
|
|
33
|
+
elif tag == "h3":
|
|
34
|
+
self.mode, self.buffer = "h3", []
|
|
35
|
+
elif tag == "a":
|
|
36
|
+
self.mode, self.buffer = "a", []
|
|
37
|
+
values = dict(attrs)
|
|
38
|
+
self.href = (values.get("href") or "").strip()
|
|
39
|
+
elif tag == "title" and not self.pending_title:
|
|
40
|
+
self.mode, self.buffer = "title", []
|
|
41
|
+
|
|
42
|
+
def handle_endtag(self, tag):
|
|
43
|
+
tag = tag.lower()
|
|
44
|
+
if tag == "dl":
|
|
45
|
+
if len(self.stack) > 1:
|
|
46
|
+
self.stack.pop()
|
|
47
|
+
elif tag == "h3" and self.mode == "h3":
|
|
48
|
+
title = "".join(self.buffer).strip()
|
|
49
|
+
folder = {"title": title or "Folder", "url": "", "type": "folder", "children": []}
|
|
50
|
+
self.stack[-1].append(folder)
|
|
51
|
+
self.pending_folder = folder
|
|
52
|
+
self.mode, self.buffer = None, []
|
|
53
|
+
elif tag == "a" and self.mode == "a":
|
|
54
|
+
title = "".join(self.buffer).strip()
|
|
55
|
+
if self.href:
|
|
56
|
+
self.stack[-1].append({"title": title or self.href, "url": self.href,
|
|
57
|
+
"type": "bookmark", "children": []})
|
|
58
|
+
self.count += 1
|
|
59
|
+
if self.count > MAX_ITEMS:
|
|
60
|
+
raise ValueError("HTML export has too many bookmarks")
|
|
61
|
+
self.mode, self.buffer, self.href = None, [], ""
|
|
62
|
+
elif tag == "title" and self.mode == "title":
|
|
63
|
+
self.pending_title = "".join(self.buffer).strip()
|
|
64
|
+
self.mode, self.buffer = None, []
|
|
65
|
+
|
|
66
|
+
def handle_data(self, data):
|
|
67
|
+
if self.mode is not None:
|
|
68
|
+
self.buffer.append(data)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def section_from_html(text, title=""):
|
|
72
|
+
"""Parse a Netscape bookmark export into a temporary section payload."""
|
|
73
|
+
parser = _NetscapeParser()
|
|
74
|
+
parser.feed(text)
|
|
75
|
+
parser.close()
|
|
76
|
+
items = parser.stack[0]
|
|
77
|
+
if not items:
|
|
78
|
+
# Some exports are plain HTML; keep every anchor as a flat bookmark.
|
|
79
|
+
fallback = _links_only(text)
|
|
80
|
+
items = fallback
|
|
81
|
+
if not items:
|
|
82
|
+
raise ValueError("No bookmark links found in this HTML export")
|
|
83
|
+
return {"format": "bookmark-canvas-section", "schemaVersion": 2,
|
|
84
|
+
"sectionType": "temporary", "title": title or "Imported bookmarks",
|
|
85
|
+
"label": "导入", "tempKind": "special", "source": "import-html",
|
|
86
|
+
"items": items}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _LinkParser(HTMLParser):
|
|
90
|
+
def __init__(self):
|
|
91
|
+
super().__init__(convert_charrefs=True)
|
|
92
|
+
self.items = []
|
|
93
|
+
self.href = None
|
|
94
|
+
self.buffer = []
|
|
95
|
+
|
|
96
|
+
def handle_starttag(self, tag, attrs):
|
|
97
|
+
if tag.lower() == "a":
|
|
98
|
+
values = dict(attrs)
|
|
99
|
+
href = (values.get("href") or "").strip()
|
|
100
|
+
if href.startswith(("http://", "https://")):
|
|
101
|
+
self.href, self.buffer = href, []
|
|
102
|
+
|
|
103
|
+
def handle_endtag(self, tag):
|
|
104
|
+
if tag.lower() == "a" and self.href:
|
|
105
|
+
title = "".join(self.buffer).strip() or self.href
|
|
106
|
+
self.items.append({"title": title, "url": self.href, "type": "bookmark",
|
|
107
|
+
"children": []})
|
|
108
|
+
self.href, self.buffer = None, []
|
|
109
|
+
|
|
110
|
+
def handle_data(self, data):
|
|
111
|
+
if self.href is not None:
|
|
112
|
+
self.buffer.append(data)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _links_only(text):
|
|
116
|
+
parser = _LinkParser()
|
|
117
|
+
parser.feed(text)
|
|
118
|
+
parser.close()
|
|
119
|
+
return parser.items
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def encode(section):
|
|
123
|
+
return json.dumps(section, ensure_ascii=False, allow_nan=False,
|
|
124
|
+
separators=(",", ":")).encode("utf-8")
|
package/src/onboarding.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from contextlib import contextmanager
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import re
|
|
6
7
|
import shlex
|
|
7
8
|
import shutil
|
|
8
9
|
import sys
|
|
@@ -351,13 +352,13 @@ class Onboarding:
|
|
|
351
352
|
hints = {"exa": ("Exa search and reading", "Exa 搜索与阅读"), "parallel": ("Parallel search and extract", "Parallel 搜索与提取"),
|
|
352
353
|
"tavily": ("Tavily search and extract", "Tavily 搜索与提取"),
|
|
353
354
|
"jina": ("Jina Reader; search needs a key", "Jina Reader;搜索需要密钥"),
|
|
354
|
-
"deepseek": ("DeepSeek Search / Fetch; needs a key", "DeepSeek 搜索/读取;需要密钥")
|
|
355
|
-
|
|
356
|
-
#
|
|
357
|
-
#
|
|
355
|
+
"deepseek": ("DeepSeek Search / Fetch; needs a key", "DeepSeek 搜索/读取;需要密钥"),
|
|
356
|
+
"perplexity": ("Perplexity structured Search; needs a key; page reading uses another provider", "Perplexity 结构化搜索;需要密钥;网页读取需使用其他服务")}
|
|
357
|
+
# The selected sources are the concurrent primary tier; the remaining
|
|
358
|
+
# providers form the fallback tier. Search-only providers are labeled.
|
|
358
359
|
options = [Option(name, name, name, *hints.get(name, ("", None))) for name in Settings.PROVIDERS]
|
|
359
|
-
primary = console.multiselect("Search
|
|
360
|
-
"
|
|
360
|
+
primary = console.multiselect("Search sources (concurrent, in this order)",
|
|
361
|
+
"搜索来源(并发执行,按此顺序)", options, current["search"]["providers"])
|
|
361
362
|
providers = list(primary)
|
|
362
363
|
fallbacks = [name for name in Settings.PROVIDERS if name not in providers]
|
|
363
364
|
storage = self._storage(current)
|
|
@@ -382,12 +383,59 @@ class Onboarding:
|
|
|
382
383
|
elif service == "parallel":
|
|
383
384
|
console.say("Parallel processor remains " + config["parallel"]["processor"] + "; change it with config set if needed.",
|
|
384
385
|
"Parallel processor 沿用 " + config["parallel"]["processor"] + ";可通过 config set 修改。")
|
|
386
|
+
custom = current["custom_agentic"]
|
|
387
|
+
custom_choice = console.select("Custom OpenAI-compatible model endpoint (optional)",
|
|
388
|
+
"自定义 OpenAI 兼容模型端点(可选)", [
|
|
389
|
+
Option("disabled", "Disabled", "关闭", "keep custom model search unavailable", "不启用自定义模型搜索"),
|
|
390
|
+
Option("enabled", "Configure endpoint", "配置端点",
|
|
391
|
+
"separate answer channel; not merged into URL search results",
|
|
392
|
+
"独立回答通道;不并入 URL 搜索结果")],
|
|
393
|
+
"enabled" if custom["enabled"] else "disabled")
|
|
394
|
+
if custom_choice == "enabled":
|
|
395
|
+
from grok_search import normalize_compatible_base_url
|
|
396
|
+
|
|
397
|
+
def check_base_url(value):
|
|
398
|
+
try:
|
|
399
|
+
normalize_compatible_base_url(value)
|
|
400
|
+
except ValueError as error:
|
|
401
|
+
return (str(error), "请输入有效的 HTTP(S) Base URL,不要包含密钥、查询参数或片段。")
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
base_url = normalize_compatible_base_url(console.input(
|
|
405
|
+
"OpenAI-compatible Base URL (for example https://gateway.example/v1)",
|
|
406
|
+
"OpenAI 兼容 Base URL(例如 https://gateway.example/v1)",
|
|
407
|
+
custom["base_url"], check_base_url))
|
|
408
|
+
|
|
409
|
+
def check_model(value):
|
|
410
|
+
return None if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/+-]{0,191}", value) else (
|
|
411
|
+
"Use a model ID with letters, numbers, '.', '_', ':', '/', '+' or '-'.",
|
|
412
|
+
"模型 ID 仅可包含字母、数字及 . _ : / + -。")
|
|
413
|
+
|
|
414
|
+
model = console.input("Model ID", "模型 ID", custom["model"], check_model)
|
|
415
|
+
api_mode = console.select("API protocol", "API 协议", [
|
|
416
|
+
Option("chat_completions", "Chat Completions", "Chat Completions"),
|
|
417
|
+
Option("responses", "Responses", "Responses")], custom["api_mode"])
|
|
418
|
+
search_options = [Option("prompt", "Ask the upstream model to use its own web search",
|
|
419
|
+
"提示上游模型使用其自身联网搜索",
|
|
420
|
+
"the endpoint must provide this capability",
|
|
421
|
+
"端点必须自己具备联网能力")]
|
|
422
|
+
if api_mode == "responses":
|
|
423
|
+
search_options.append(Option("native", "Request the Responses web_search tool",
|
|
424
|
+
"请求 Responses web_search 工具",
|
|
425
|
+
"the upstream must implement this tool",
|
|
426
|
+
"上游必须支持此工具"))
|
|
427
|
+
search_mode = console.select("Search capability", "搜索能力", search_options,
|
|
428
|
+
custom["search_mode"] if any(row.value == custom["search_mode"] for row in search_options) else "prompt")
|
|
429
|
+
custom_agentic = {"enabled": True, "base_url": base_url, "model": model,
|
|
430
|
+
"api_mode": api_mode, "search_mode": search_mode}
|
|
431
|
+
else:
|
|
432
|
+
custom_agentic = {"enabled": False}
|
|
385
433
|
# Only changed values are returned, so an unchanged answer keeps the saved
|
|
386
434
|
# Wiki follow-up, readiness policy and archive toggle exactly as they were.
|
|
387
435
|
result = {"research": {"depth": depth, "response_language": language},
|
|
388
436
|
"search": {"providers": providers, "fallback_providers": fallbacks},
|
|
389
437
|
"fetch": {"provider": providers[0], "providers": providers},
|
|
390
|
-
"professional_research": services}
|
|
438
|
+
"professional_research": services, "custom_agentic": custom_agentic}
|
|
391
439
|
result.update(storage)
|
|
392
440
|
return result
|
|
393
441
|
|
|
@@ -432,14 +480,16 @@ class Onboarding:
|
|
|
432
480
|
console.note("API keys", "API 密钥", [
|
|
433
481
|
("Retrieval adapters are included; anonymous search/reading works where the provider permits it.",
|
|
434
482
|
"检索适配器已包含在插件中;服务方允许时可使用匿名搜索和阅读。"),
|
|
483
|
+
("Select keys for services enabled above; use Space to select, Enter to continue. You can skip this step.",
|
|
484
|
+
"选择上面启用的服务所需的密钥;空格勾选、回车继续,也可以跳过。"),
|
|
435
485
|
("Host MCP OAuth login stays in the host. An OpenAI API key is separate from a ChatGPT/Codex login.",
|
|
436
486
|
"宿主 MCP 的 OAuth 登录由宿主管理;OpenAI API Key 与 ChatGPT/Codex 登录分开。"),
|
|
437
487
|
("Keys are hidden and saved in a local, unencrypted file readable only by your user: " + str(self.credentials.path),
|
|
438
488
|
"密钥隐藏输入,并保存为仅当前用户可读的本地明文文件:" + str(self.credentials.path)),
|
|
439
489
|
("Environment variables override saved keys. Select nothing to configure credentials later.",
|
|
440
490
|
"环境变量优先于保存的密钥;不选择即可稍后配置。"),
|
|
441
|
-
("
|
|
442
|
-
"
|
|
491
|
+
("Key names and storage: https://github.com/Browser-bookmark-hub/Bookmark-Research/blob/main/docs/configuration.en.md",
|
|
492
|
+
"Key 名称与保存位置说明:https://github.com/Browser-bookmark-hub/Bookmark-Research/blob/main/docs/configuration.md")])
|
|
443
493
|
status = {"environment": ("set in environment (overrides saved key)", "环境变量已设置(优先于保存的密钥)"),
|
|
444
494
|
"private_file": ("configured, not yet verified", "已配置,尚未验证"), None: ("missing", "未配置")}
|
|
445
495
|
options = [Option(row["name"], row["name"].split("_")[0].lower(), None, *status[row["source"] if row["configured"] else None], hint_always=True)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Perplexity Search API client; returns structured sources, not a model answer."""
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
from release import user_agent
|
|
10
|
+
from remote_mcp import McpError, McpHttpClient
|
|
11
|
+
from service_http import ResearchHttp, _NoRedirect
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PerplexityClient:
|
|
15
|
+
SEARCH_URL = "https://api.perplexity.ai/search"
|
|
16
|
+
|
|
17
|
+
def __init__(self, headers=None, timeout=30, search_url=None):
|
|
18
|
+
self.headers = dict(headers or {})
|
|
19
|
+
self.timeout = timeout
|
|
20
|
+
self.search_url = search_url or self.SEARCH_URL
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
self._requests = 0
|
|
23
|
+
|
|
24
|
+
def snapshot_usage(self):
|
|
25
|
+
with self._lock:
|
|
26
|
+
return {"http_requests": self._requests, "tool_calls": self._requests}
|
|
27
|
+
|
|
28
|
+
def _request(self, query, limit):
|
|
29
|
+
payload = json.dumps({"query": query, "max_results": max(1, min(int(limit), 20))},
|
|
30
|
+
ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
31
|
+
request = urllib.request.Request(self.search_url, data=payload, method="POST", headers={
|
|
32
|
+
"Accept": "application/json", "Content-Type": "application/json",
|
|
33
|
+
"User-Agent": user_agent(), **self.headers})
|
|
34
|
+
with self._lock:
|
|
35
|
+
self._requests += 1
|
|
36
|
+
try:
|
|
37
|
+
with urllib.request.build_opener(_NoRedirect()).open(request, timeout=self.timeout) as response:
|
|
38
|
+
body = response.read(McpHttpClient.MAX_BYTES + 1)
|
|
39
|
+
if len(body) > McpHttpClient.MAX_BYTES:
|
|
40
|
+
raise McpError("Perplexity response exceeds size limit", "response_too_large")
|
|
41
|
+
value = json.loads(body, object_pairs_hook=ResearchHttp._unique_object,
|
|
42
|
+
parse_constant=ResearchHttp._invalid_constant,
|
|
43
|
+
parse_float=ResearchHttp._finite_float)
|
|
44
|
+
if not isinstance(value, dict) or not isinstance(value.get("results"), list):
|
|
45
|
+
raise McpError("Perplexity returned invalid search results", "result_format")
|
|
46
|
+
rows = []
|
|
47
|
+
for item in value["results"]:
|
|
48
|
+
if not isinstance(item, dict) or not isinstance(item.get("url"), str):
|
|
49
|
+
continue
|
|
50
|
+
snippet = item.get("snippet")
|
|
51
|
+
rows.append({"url": item["url"], "title": item.get("title") or item["url"],
|
|
52
|
+
"snippet": snippet if isinstance(snippet, str) else "",
|
|
53
|
+
"date": item.get("date"), "last_updated": item.get("last_updated")})
|
|
54
|
+
return {"structuredContent": {"results": rows}}
|
|
55
|
+
except urllib.error.HTTPError as error:
|
|
56
|
+
status = error.code
|
|
57
|
+
kind = {400: "invalid_request", 401: "authentication_required", 403: "access_denied",
|
|
58
|
+
408: "timeout", 429: "rate_limited"}.get(status, "http_error")
|
|
59
|
+
retry = McpHttpClient._retry_after((error.headers or {}).get("Retry-After"))
|
|
60
|
+
try:
|
|
61
|
+
error.close()
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
raise McpError("Perplexity Search HTTP " + str(status), kind,
|
|
65
|
+
status == 429 or status >= 500, http_status=status, retry_after=retry) from None
|
|
66
|
+
except TimeoutError:
|
|
67
|
+
raise McpError("Perplexity Search timed out", "timeout", True) from None
|
|
68
|
+
except (urllib.error.URLError, OSError, http.client.HTTPException):
|
|
69
|
+
raise McpError("Perplexity Search connection failed", "connection_error", True) from None
|
|
70
|
+
except (ValueError, UnicodeError):
|
|
71
|
+
raise McpError("Perplexity returned invalid JSON", "result_format") from None
|
|
72
|
+
|
|
73
|
+
def call(self, purpose, query=None, urls=None, limit=5):
|
|
74
|
+
if purpose != "search":
|
|
75
|
+
raise McpError("Perplexity Search does not provide URL extraction", "capability_unavailable")
|
|
76
|
+
if not self.headers.get("Authorization"):
|
|
77
|
+
raise McpError("Perplexity Search requires PERPLEXITY_API_KEY", "authentication_required")
|
|
78
|
+
return self._request(query, limit)
|
package/src/raw_library.py
CHANGED
|
@@ -22,9 +22,12 @@ import re
|
|
|
22
22
|
import sqlite3
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
|
|
25
|
+
import storage
|
|
25
26
|
from settings import Settings
|
|
26
27
|
|
|
27
28
|
SCHEMA_VERSION = 1
|
|
29
|
+
_EMPTY_REPORT = {"missing_tasks": 0, "scanned_units": 0, "removed_units": 0,
|
|
30
|
+
"new_documents": 0, "errors": []}
|
|
28
31
|
MAX_BODY_BYTES = 50 * 1024 * 1024
|
|
29
32
|
MAX_TERMS = 16
|
|
30
33
|
TRIGRAM_MINIMUM = 3
|
|
@@ -80,7 +83,19 @@ class RawLibrary:
|
|
|
80
83
|
|
|
81
84
|
# Storage -----------------------------------------------------------------
|
|
82
85
|
|
|
83
|
-
def _connect(self):
|
|
86
|
+
def _connect(self, readonly=False):
|
|
87
|
+
if readonly:
|
|
88
|
+
if not self.db_path.is_file():
|
|
89
|
+
raise sqlite3.OperationalError("Raw library index is not built yet: %s" % self.db_path)
|
|
90
|
+
connection = storage.connect(self.db_path, readonly=True, timeout=30)
|
|
91
|
+
connection.row_factory = sqlite3.Row
|
|
92
|
+
try:
|
|
93
|
+
stored = dict(connection.execute("SELECT key, value FROM meta").fetchall())
|
|
94
|
+
except sqlite3.DatabaseError:
|
|
95
|
+
stored = {}
|
|
96
|
+
if stored.get("mode") in ("trigram", "substring"):
|
|
97
|
+
self.mode = stored["mode"]
|
|
98
|
+
return storage.mark_query_only(connection)
|
|
84
99
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
100
|
for attempt in (0, 1):
|
|
86
101
|
connection = sqlite3.connect(str(self.db_path), timeout=30, isolation_level=None)
|
|
@@ -88,9 +103,9 @@ class RawLibrary:
|
|
|
88
103
|
connection.execute("PRAGMA journal_mode=WAL")
|
|
89
104
|
self._schema(connection)
|
|
90
105
|
return connection
|
|
91
|
-
except sqlite3.DatabaseError:
|
|
106
|
+
except sqlite3.DatabaseError as error:
|
|
92
107
|
connection.close()
|
|
93
|
-
if attempt:
|
|
108
|
+
if attempt or storage.is_readonly_failure(error):
|
|
94
109
|
raise
|
|
95
110
|
# A derived cache: a damaged file is discarded and rebuilt.
|
|
96
111
|
for suffix in ("", "-wal", "-shm"):
|
|
@@ -98,6 +113,16 @@ class RawLibrary:
|
|
|
98
113
|
if candidate.exists():
|
|
99
114
|
candidate.unlink()
|
|
100
115
|
|
|
116
|
+
def _open_for_search(self):
|
|
117
|
+
"""Prefer the writable index (it self-heals); a read-only one stays searchable."""
|
|
118
|
+
try:
|
|
119
|
+
connection = self._connect()
|
|
120
|
+
except (sqlite3.DatabaseError, OSError) as error:
|
|
121
|
+
if not storage.is_readonly_failure(error) or not self.db_path.is_file():
|
|
122
|
+
raise
|
|
123
|
+
return self._connect(readonly=True), False
|
|
124
|
+
return connection, True
|
|
125
|
+
|
|
101
126
|
def _schema(self, connection):
|
|
102
127
|
connection.execute("CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
|
103
128
|
stored = dict(connection.execute("SELECT key, value FROM meta").fetchall())
|
|
@@ -343,15 +368,17 @@ class RawLibrary:
|
|
|
343
368
|
text = re.sub(r"\s+", " ", text).strip()
|
|
344
369
|
return ("…" if start > 0 else "") + text + ("…" if start + width < len(body) else "")
|
|
345
370
|
|
|
346
|
-
def search(self, query, limit=10, offset=0, url=None, occurrence_limit=50, occurrence_offset=0):
|
|
371
|
+
def search(self, query, limit=10, offset=0, url=None, occurrence_limit=50, occurrence_offset=0, expand_versions=False):
|
|
347
372
|
"""Literal search over saved page text; every term must occur (quoted query = one phrase).
|
|
348
373
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
374
|
+
By default, results are grouped by URL, reporting version counts,
|
|
375
|
+
time spans, and all captured versions. When expand_versions=True,
|
|
376
|
+
bodies are merged by SHA-256. Occurrences are paged with
|
|
377
|
+
occurrence_limit / occurrence_offset.
|
|
353
378
|
"""
|
|
354
379
|
terms = self._terms(query)
|
|
380
|
+
if type(expand_versions) is not bool:
|
|
381
|
+
raise ValueError("expand_versions must be a boolean")
|
|
355
382
|
for value, label, minimum, maximum in ((limit, "limit", 1, 50), (offset, "offset", 0, 10000000),
|
|
356
383
|
(occurrence_limit, "occurrence_limit", 1, 1000),
|
|
357
384
|
(occurrence_offset, "occurrence_offset", 0, 10000000)):
|
|
@@ -359,9 +386,20 @@ class RawLibrary:
|
|
|
359
386
|
raise ValueError("%s must be between %s and %s" % (label, minimum, maximum))
|
|
360
387
|
if url is not None and (not isinstance(url, str) or not url or len(url) > 2048 or "\x00" in url):
|
|
361
388
|
raise ValueError("url filter must be 1 to 2048 characters")
|
|
362
|
-
connection = self.
|
|
389
|
+
connection, writable = self._open_for_search()
|
|
363
390
|
try:
|
|
364
|
-
|
|
391
|
+
if writable:
|
|
392
|
+
try:
|
|
393
|
+
report = self._refresh(connection)
|
|
394
|
+
except sqlite3.DatabaseError as error:
|
|
395
|
+
if not storage.is_readonly_failure(error):
|
|
396
|
+
raise
|
|
397
|
+
# Querying a shared index on a read-only mount: nothing was written.
|
|
398
|
+
connection.close()
|
|
399
|
+
connection, writable = self._connect(readonly=True), False
|
|
400
|
+
report = _EMPTY_REPORT
|
|
401
|
+
else:
|
|
402
|
+
report = _EMPTY_REPORT
|
|
365
403
|
indexed = self._statistics(connection, report)
|
|
366
404
|
fts = self.mode == "trigram" and all(len(term) >= TRIGRAM_MINIMUM for term in terms)
|
|
367
405
|
clauses, parameters = [], []
|
|
@@ -376,49 +414,149 @@ class RawLibrary:
|
|
|
376
414
|
clauses.append("d.sha256 IN (SELECT sha256 FROM occurrences WHERE instr(lower(url), lower(?)) > 0)")
|
|
377
415
|
parameters.append(url)
|
|
378
416
|
base = ("FROM page_text JOIN documents d ON d.id = page_text.rowid WHERE " + " AND ".join(clauses))
|
|
379
|
-
total = connection.execute("SELECT COUNT(*) " + base, parameters).fetchone()[0]
|
|
380
417
|
order = "bm25(page_text), d.id DESC" if fts else "d.id DESC"
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
418
|
+
|
|
419
|
+
if expand_versions:
|
|
420
|
+
total = connection.execute("SELECT COUNT(*) " + base, parameters).fetchone()[0]
|
|
421
|
+
rows = connection.execute("SELECT d.sha256, d.characters, page_text.body " + base +
|
|
422
|
+
" ORDER BY " + order + " LIMIT ? OFFSET ?",
|
|
423
|
+
parameters + [limit, offset]).fetchall()
|
|
424
|
+
results = []
|
|
425
|
+
for sha256, characters, body in rows:
|
|
426
|
+
count = connection.execute("SELECT COUNT(*) FROM occurrences WHERE sha256 = ?",
|
|
427
|
+
(sha256,)).fetchone()[0]
|
|
428
|
+
occurrences = []
|
|
429
|
+
for row in connection.execute(
|
|
430
|
+
"SELECT kind, capture_id, research_id, source_id, path, url, title, retrieved_at "
|
|
431
|
+
"FROM occurrences WHERE sha256 = ? "
|
|
432
|
+
"ORDER BY (retrieved_at IS NULL), retrieved_at DESC, unit, position "
|
|
433
|
+
"LIMIT ? OFFSET ?", (sha256, occurrence_limit, occurrence_offset)):
|
|
434
|
+
kind, capture_id, research_id, source_id, path, found_url, title, retrieved_at = row
|
|
435
|
+
entry = {"kind": kind}
|
|
436
|
+
if kind == "archive":
|
|
437
|
+
entry["capture_id"] = capture_id
|
|
438
|
+
else:
|
|
439
|
+
entry.update(research_id=research_id, source_id=source_id)
|
|
440
|
+
entry.update(path=path, url=found_url, title=title, retrieved_at=retrieved_at)
|
|
441
|
+
occurrences.append(entry)
|
|
442
|
+
shown = occurrence_offset + len(occurrences)
|
|
443
|
+
results.append({"sha256": sha256, "characters": characters,
|
|
444
|
+
"url": next((entry["url"] for entry in occurrences if entry["url"]), None),
|
|
445
|
+
"title": next((entry["title"] for entry in occurrences if entry["title"]), None),
|
|
446
|
+
"snippet": self._snippet(body, terms),
|
|
447
|
+
"occurrence_count": count,
|
|
448
|
+
"occurrence_offset": occurrence_offset,
|
|
449
|
+
"occurrences_truncated": shown < count,
|
|
450
|
+
"occurrences_next_offset": shown if shown < count else None,
|
|
451
|
+
"occurrences": occurrences})
|
|
452
|
+
else:
|
|
453
|
+
doc_rows = connection.execute("SELECT d.sha256, d.characters " + base +
|
|
454
|
+
" ORDER BY " + order, parameters).fetchall()
|
|
455
|
+
shas = [row[0] for row in doc_rows]
|
|
456
|
+
sha_chars = {row[0]: row[1] for row in doc_rows}
|
|
457
|
+
|
|
458
|
+
occ_map = {}
|
|
459
|
+
if shas:
|
|
460
|
+
for i in range(0, len(shas), 900):
|
|
461
|
+
chunk = shas[i : i + 900]
|
|
462
|
+
placeholders = ",".join("?" for _ in chunk)
|
|
463
|
+
q = (
|
|
464
|
+
"SELECT sha256, kind, capture_id, research_id, source_id, path, url, title, retrieved_at "
|
|
465
|
+
"FROM occurrences WHERE sha256 IN (" + placeholders + ") "
|
|
466
|
+
"ORDER BY (retrieved_at IS NULL), retrieved_at DESC, unit, position"
|
|
467
|
+
)
|
|
468
|
+
for r in connection.execute(q, chunk):
|
|
469
|
+
occ_map.setdefault(r[0], []).append(r[1:])
|
|
470
|
+
|
|
471
|
+
groups = {}
|
|
472
|
+
for sha256 in shas:
|
|
473
|
+
characters = sha_chars[sha256]
|
|
474
|
+
occ_rows = occ_map.get(sha256, [])
|
|
475
|
+
found_url = next((r[5] for r in occ_rows if r[5]), None)
|
|
476
|
+
grp_key = found_url if found_url else sha256
|
|
477
|
+
if grp_key not in groups:
|
|
478
|
+
groups[grp_key] = {
|
|
479
|
+
"url": found_url,
|
|
480
|
+
"versions": [],
|
|
481
|
+
"all_occurrences": [],
|
|
482
|
+
}
|
|
483
|
+
v_title = next((r[6] for r in occ_rows if r[6]), None)
|
|
484
|
+
v_retrieved = next((r[7] for r in occ_rows if r[7]), None)
|
|
485
|
+
groups[grp_key]["versions"].append({
|
|
486
|
+
"sha256": sha256,
|
|
487
|
+
"characters": characters,
|
|
488
|
+
"title": v_title,
|
|
489
|
+
"retrieved_at": v_retrieved,
|
|
490
|
+
"occurrence_count": len(occ_rows),
|
|
491
|
+
})
|
|
492
|
+
for r in occ_rows:
|
|
493
|
+
k, cap_id, res_id, src_id, p, u, t, ret_at = r
|
|
494
|
+
entry = {"kind": k}
|
|
495
|
+
if k == "archive":
|
|
496
|
+
entry["capture_id"] = cap_id
|
|
497
|
+
else:
|
|
498
|
+
entry.update(research_id=res_id, source_id=src_id)
|
|
499
|
+
entry.update(path=p, url=u, title=t, retrieved_at=ret_at)
|
|
500
|
+
groups[grp_key]["all_occurrences"].append(entry)
|
|
501
|
+
|
|
502
|
+
total = len(groups)
|
|
503
|
+
paged_keys = list(groups.keys())[offset : offset + limit]
|
|
504
|
+
|
|
505
|
+
needed_shas = {v["sha256"] for k in paged_keys for v in groups[k]["versions"]}
|
|
506
|
+
body_map = {}
|
|
507
|
+
if needed_shas:
|
|
508
|
+
placeholders = ",".join("?" for _ in needed_shas)
|
|
509
|
+
bq = (
|
|
510
|
+
"SELECT d.sha256, page_text.body FROM page_text "
|
|
511
|
+
"JOIN documents d ON d.id = page_text.rowid "
|
|
512
|
+
"WHERE d.sha256 IN (" + placeholders + ")"
|
|
513
|
+
)
|
|
514
|
+
for s_hash, body in connection.execute(bq, list(needed_shas)).fetchall():
|
|
515
|
+
body_map[s_hash] = self._snippet(body, terms)
|
|
516
|
+
|
|
517
|
+
results = []
|
|
518
|
+
for key in paged_keys:
|
|
519
|
+
grp = groups[key]
|
|
520
|
+
versions = grp["versions"]
|
|
521
|
+
for v in versions:
|
|
522
|
+
v["snippet"] = body_map.get(v["sha256"], "")
|
|
523
|
+
versions.sort(key=lambda v: (v["retrieved_at"] is not None, v["retrieved_at"] or ""), reverse=True)
|
|
524
|
+
all_occs = grp["all_occurrences"]
|
|
525
|
+
all_occs.sort(key=lambda o: (o.get("retrieved_at") is not None, o.get("retrieved_at") or ""), reverse=True)
|
|
526
|
+
total_occ_count = len(all_occs)
|
|
527
|
+
paged_occs = all_occs[occurrence_offset : occurrence_offset + occurrence_limit]
|
|
528
|
+
shown = occurrence_offset + len(paged_occs)
|
|
529
|
+
dates = [v["retrieved_at"] for v in versions if v["retrieved_at"]]
|
|
530
|
+
earliest = min(dates) if dates else None
|
|
531
|
+
latest = max(dates) if dates else None
|
|
532
|
+
top_version = versions[0]
|
|
533
|
+
results.append({
|
|
534
|
+
"url": grp["url"],
|
|
535
|
+
"title": top_version["title"],
|
|
536
|
+
"sha256": top_version["sha256"],
|
|
537
|
+
"characters": top_version["characters"],
|
|
538
|
+
"snippet": top_version["snippet"],
|
|
539
|
+
"version_count": len(versions),
|
|
540
|
+
"time_span": {"earliest": earliest, "latest": latest},
|
|
541
|
+
"versions": versions,
|
|
542
|
+
"occurrence_count": total_occ_count,
|
|
543
|
+
"occurrence_offset": occurrence_offset,
|
|
544
|
+
"occurrences_truncated": shown < total_occ_count,
|
|
545
|
+
"occurrences_next_offset": shown if shown < total_occ_count else None,
|
|
546
|
+
"occurrences": paged_occs,
|
|
547
|
+
})
|
|
412
548
|
finally:
|
|
413
549
|
connection.close()
|
|
414
550
|
next_offset = offset + len(results)
|
|
415
551
|
return {"query": query, "total": total, "offset": offset,
|
|
416
552
|
"next_offset": next_offset if next_offset < total else None,
|
|
417
553
|
"occurrence_limit": occurrence_limit, "occurrence_offset": occurrence_offset,
|
|
554
|
+
"expand_versions": expand_versions, "grouped_by": "hash" if expand_versions else "url",
|
|
418
555
|
"match": "fts_trigram" if fts else "substring", "results": results, "indexed": indexed,
|
|
419
|
-
"
|
|
420
|
-
|
|
421
|
-
|
|
556
|
+
"index_refresh": "performed" if writable else "skipped_read_only",
|
|
557
|
+
"note": ("Literal matching over saved page text (knowledge archive and research evidence); "
|
|
558
|
+
"not web search and not semantic retrieval. " +
|
|
559
|
+
("Results are grouped by URL with version counts and time spans. Use expand_versions=true to view distinct content hashes." if not expand_versions else "Identical bodies are merged by SHA-256 and each result pages its occurrences with occurrence_offset/occurrence_limit."))}
|
|
422
560
|
|
|
423
561
|
def search_terms(self, terms, limit=5):
|
|
424
562
|
"""Search several literal terms and merge them into one preflight list.
|