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/settings.py
CHANGED
|
@@ -16,16 +16,13 @@ from package_paths import is_package_dir
|
|
|
16
16
|
class Settings:
|
|
17
17
|
"""Load on each operation so MCP clients can change preferences without restart."""
|
|
18
18
|
|
|
19
|
-
PROVIDERS = ("exa", "parallel", "tavily", "jina", "deepseek")
|
|
19
|
+
PROVIDERS = ("exa", "parallel", "tavily", "jina", "deepseek", "perplexity")
|
|
20
|
+
FETCH_PROVIDERS = ("exa", "parallel", "tavily", "jina", "deepseek", "browser")
|
|
20
21
|
TOOL_SCOPE_PRESETS = ("all", "quick", "deep")
|
|
21
22
|
# Daily context, retrieval, knowledge storage and provider-managed research.
|
|
22
23
|
# Persistent inventories and diagnostics remain in the explicit full catalog.
|
|
23
24
|
QUICK_TOOLS = (
|
|
24
|
-
"
|
|
25
|
-
"search_web", "fetch_web", "search_archive", "wiki_write", "wiki_get", "wiki_list", "wiki_search",
|
|
26
|
-
"research_services", "research_service_prepare", "research_service_start", "research_service_status",
|
|
27
|
-
"research_service_result", "research_service_cancel", "research_service_attach",
|
|
28
|
-
"get_settings", "update_settings")
|
|
25
|
+
"locate", "read", "walk", "search", "fetch", "ingest", "save", "spawn", "job", "health")
|
|
29
26
|
OPENAI_RESEARCH_MODELS = ("gpt-5.6-sol", "o3-deep-research", "o4-mini-deep-research")
|
|
30
27
|
|
|
31
28
|
def __init__(self, path=None):
|
|
@@ -83,8 +80,12 @@ class Settings:
|
|
|
83
80
|
"openai": {"model": "gpt-5.6-sol", "max_tool_calls": 24,
|
|
84
81
|
"allow_untested_model": False},
|
|
85
82
|
"parallel": {"processor": "pro"}},
|
|
86
|
-
"
|
|
87
|
-
|
|
83
|
+
"custom_agentic": {"enabled": False, "base_url": "", "model": "",
|
|
84
|
+
"api_mode": "chat_completions", "search_mode": "prompt"},
|
|
85
|
+
"browser": {"enabled": False, "driver": "auto", "cdp_url": "http://127.0.0.1:9222",
|
|
86
|
+
"headless": True, "timeout_seconds": 20, "max_concurrency": 2},
|
|
87
|
+
"wiki": {"directory": str(cls.data_directory() / "wiki"), "after_research": "auto",
|
|
88
|
+
"max_source_age_days": 30, "aliases": []},
|
|
88
89
|
"wiki_preflight": True,
|
|
89
90
|
"freshness_days": 30,
|
|
90
91
|
"plan": {"max_characters": 8000, "min_relevance": 0.05, "homepage_limit": 2},
|
|
@@ -112,7 +113,7 @@ class Settings:
|
|
|
112
113
|
defaults = cls.defaults()
|
|
113
114
|
if set(value) - set(defaults):
|
|
114
115
|
raise ValueError("Unknown settings fields; credentials belong in the environment or private credential file")
|
|
115
|
-
for section in ("search", "fetch", "server", "archive", "research", "readiness", "professional_research", "wiki", "plan", "output"):
|
|
116
|
+
for section in ("search", "fetch", "server", "archive", "research", "readiness", "professional_research", "custom_agentic", "browser", "wiki", "plan", "output"):
|
|
116
117
|
if not isinstance(value.get(section), dict) or set(value[section]) != set(defaults[section]):
|
|
117
118
|
raise ValueError("Invalid settings fields in " + section)
|
|
118
119
|
for label, number, minimum, maximum in (
|
|
@@ -163,14 +164,20 @@ class Settings:
|
|
|
163
164
|
raise ValueError("search.strategy must be hedged, staggered, sequential or wait_all")
|
|
164
165
|
if value["fetch"]["strategy"] not in ("sequential", "staggered", "hedged"):
|
|
165
166
|
raise ValueError("fetch.strategy must be sequential, staggered or hedged")
|
|
166
|
-
for section, field, minimum in (("search", "providers",
|
|
167
|
-
|
|
167
|
+
for section, field, allowed, minimum in (("search", "providers", cls.PROVIDERS, 1),
|
|
168
|
+
("fetch", "providers", cls.FETCH_PROVIDERS, 1),
|
|
169
|
+
("search", "fallback_providers", cls.PROVIDERS, 0)):
|
|
168
170
|
providers = value[section][field]
|
|
169
|
-
if (not isinstance(providers, list) or not minimum <= len(providers) <= len(
|
|
170
|
-
or any(p not in
|
|
171
|
+
if (not isinstance(providers, list) or not minimum <= len(providers) <= len(allowed)
|
|
172
|
+
or any(p not in allowed for p in providers) or len(set(providers)) != len(providers)):
|
|
171
173
|
raise ValueError(section + "." + field + " must select supported providers without duplicates")
|
|
172
|
-
if value["fetch"]["provider"] not in cls.
|
|
174
|
+
if value["fetch"]["provider"] not in cls.FETCH_PROVIDERS:
|
|
173
175
|
raise ValueError("fetch.provider must select a supported provider")
|
|
176
|
+
for field in ("provider", "providers"):
|
|
177
|
+
configured = value["fetch"][field]
|
|
178
|
+
names = [configured] if field == "provider" else configured
|
|
179
|
+
if any(name not in cls.FETCH_PROVIDERS for name in names):
|
|
180
|
+
raise ValueError("fetch settings may select only providers that support page extraction")
|
|
174
181
|
if type(value["archive"]["enabled"]) is not bool:
|
|
175
182
|
raise ValueError("archive.enabled must be a boolean")
|
|
176
183
|
directory = value["archive"]["directory"]
|
|
@@ -213,12 +220,50 @@ class Settings:
|
|
|
213
220
|
processor = services["parallel"]["processor"]
|
|
214
221
|
if not isinstance(processor, str) or not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", processor):
|
|
215
222
|
raise ValueError("professional_research.parallel.processor must be a processor name")
|
|
223
|
+
custom = value["custom_agentic"]
|
|
224
|
+
if type(custom["enabled"]) is not bool:
|
|
225
|
+
raise ValueError("custom_agentic.enabled must be a boolean")
|
|
226
|
+
if custom["api_mode"] not in ("chat_completions", "responses"):
|
|
227
|
+
raise ValueError("custom_agentic.api_mode must be chat_completions or responses")
|
|
228
|
+
if custom["search_mode"] not in ("prompt", "native"):
|
|
229
|
+
raise ValueError("custom_agentic.search_mode must be prompt or native")
|
|
230
|
+
if custom["search_mode"] == "native" and custom["api_mode"] != "responses":
|
|
231
|
+
raise ValueError("custom_agentic native web search requires the Responses API")
|
|
232
|
+
if not isinstance(custom["base_url"], str) or len(custom["base_url"]) > 1024:
|
|
233
|
+
raise ValueError("custom_agentic.base_url must be a string up to 1024 characters")
|
|
234
|
+
if not isinstance(custom["model"], str) or len(custom["model"]) > 192:
|
|
235
|
+
raise ValueError("custom_agentic.model must be a string up to 192 characters")
|
|
236
|
+
if custom["enabled"]:
|
|
237
|
+
from grok_search import normalize_compatible_base_url
|
|
238
|
+
normalize_compatible_base_url(custom["base_url"])
|
|
239
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/+-]{0,191}", custom["model"]):
|
|
240
|
+
raise ValueError("custom_agentic.model must be a valid model identifier")
|
|
241
|
+
browser = value["browser"]
|
|
242
|
+
if type(browser["enabled"]) is not bool:
|
|
243
|
+
raise ValueError("browser.enabled must be a boolean")
|
|
244
|
+
if browser["driver"] not in ("auto", "playwright", "cdp", "mock"):
|
|
245
|
+
raise ValueError("browser.driver must be auto, playwright, cdp or mock")
|
|
246
|
+
if not isinstance(browser["cdp_url"], str) or len(browser["cdp_url"]) > 1024:
|
|
247
|
+
raise ValueError("browser.cdp_url must be a string up to 1024 characters")
|
|
248
|
+
if type(browser["headless"]) is not bool:
|
|
249
|
+
raise ValueError("browser.headless must be a boolean")
|
|
250
|
+
for label, number, minimum, maximum in (
|
|
251
|
+
("browser.timeout_seconds", browser["timeout_seconds"], 1, 60),
|
|
252
|
+
("browser.max_concurrency", browser["max_concurrency"], 1, 8)):
|
|
253
|
+
if type(number) is not int or not minimum <= number <= maximum:
|
|
254
|
+
raise ValueError("%s must be an integer between %s and %s" % (label, minimum, maximum))
|
|
216
255
|
wiki_directory = value["wiki"]["directory"]
|
|
217
256
|
if not isinstance(wiki_directory, str) or not wiki_directory.strip() or "\x00" in wiki_directory:
|
|
218
257
|
raise ValueError("wiki.directory must be a nonempty path")
|
|
219
258
|
value["wiki"]["directory"] = str(cls.external_path(wiki_directory, "Wiki directory"))
|
|
220
259
|
if value["wiki"]["after_research"] not in ("suggest", "auto", "off"):
|
|
221
260
|
raise ValueError("wiki.after_research must be suggest, auto or off")
|
|
261
|
+
aliases = value["wiki"].get("aliases", [])
|
|
262
|
+
if not isinstance(aliases, list):
|
|
263
|
+
raise ValueError("wiki.aliases must be a list of alias groups")
|
|
264
|
+
for group in aliases:
|
|
265
|
+
if not isinstance(group, (list, tuple)) or not all(isinstance(t, str) for t in group):
|
|
266
|
+
raise ValueError("each alias group in wiki.aliases must be a list of strings")
|
|
222
267
|
output = value["output"]
|
|
223
268
|
if output["mode"] not in ("central", "beside_input"):
|
|
224
269
|
raise ValueError("output.mode must be central or beside_input")
|
|
@@ -233,9 +278,9 @@ class Settings:
|
|
|
233
278
|
|
|
234
279
|
@staticmethod
|
|
235
280
|
def fetch_call_count(provider, urls):
|
|
236
|
-
# Reader
|
|
281
|
+
# Reader and Browser have one request/render per URL; the other providers batch URLs
|
|
237
282
|
# in one MCP tool call. Reserve this before any network work begins.
|
|
238
|
-
return len(urls) if provider
|
|
283
|
+
return len(urls) if provider in ("jina", "browser") else 1
|
|
239
284
|
|
|
240
285
|
@staticmethod
|
|
241
286
|
def _unique_object(pairs):
|
package/src/source_inputs.py
CHANGED
|
@@ -36,6 +36,53 @@ def _safe_name(name):
|
|
|
36
36
|
return parts
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def _looks_like_section(obj):
|
|
40
|
+
return isinstance(obj, dict) and (
|
|
41
|
+
obj.get("format") == "bookmark-canvas-section"
|
|
42
|
+
or (obj.get("sectionType") in ("permanent", "temporary")
|
|
43
|
+
and ("items" in obj or "tree" in obj)))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _unwrap_export(obj, depth=0):
|
|
47
|
+
"""Mirror the canvas app: accept {data|tree|bookmarkTree|bookmarks|items} wrappers."""
|
|
48
|
+
if depth > 5 or not isinstance(obj, dict) or _looks_like_section(obj):
|
|
49
|
+
return obj
|
|
50
|
+
for key in ("data", "tree", "bookmarkTree", "bookmarks", "items"):
|
|
51
|
+
value = obj.get(key)
|
|
52
|
+
if isinstance(value, (dict, list)):
|
|
53
|
+
return _unwrap_export(value, depth + 1)
|
|
54
|
+
return obj
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _wrap_items(items, name):
|
|
58
|
+
return {"format": "bookmark-canvas-section", "schemaVersion": 2,
|
|
59
|
+
"sectionType": "temporary", "title": Path(name).stem,
|
|
60
|
+
"label": "导入", "tempKind": "special", "source": "import-json",
|
|
61
|
+
"items": items}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _normalize_section_bytes(raw, name):
|
|
65
|
+
try:
|
|
66
|
+
obj = json.loads(raw.decode("utf-8"))
|
|
67
|
+
except (UnicodeDecodeError, ValueError):
|
|
68
|
+
return raw
|
|
69
|
+
if _looks_like_section(obj) and obj.get("format") == "bookmark-canvas-section":
|
|
70
|
+
return raw # already a section: keep bytes identical for idempotent re-import
|
|
71
|
+
if _looks_like_section(obj):
|
|
72
|
+
obj = dict(obj, format="bookmark-canvas-section")
|
|
73
|
+
return json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
|
74
|
+
candidate = _unwrap_export(obj)
|
|
75
|
+
if _looks_like_section(candidate):
|
|
76
|
+
if "format" not in candidate:
|
|
77
|
+
candidate = dict(candidate, format="bookmark-canvas-section")
|
|
78
|
+
return json.dumps(candidate, ensure_ascii=False).encode("utf-8")
|
|
79
|
+
if isinstance(candidate, list):
|
|
80
|
+
return json.dumps(_wrap_items(candidate, name), ensure_ascii=False).encode("utf-8")
|
|
81
|
+
if isinstance(candidate, dict) and isinstance(candidate.get("children"), list):
|
|
82
|
+
return json.dumps(_wrap_items([candidate], name), ensure_ascii=False).encode("utf-8")
|
|
83
|
+
return raw
|
|
84
|
+
|
|
85
|
+
|
|
39
86
|
def _section_name(name, raw, section_paths):
|
|
40
87
|
obj = json_object(raw)
|
|
41
88
|
prefix = "永久栏目" if obj.get("sectionType") == "permanent" else "临时栏目"
|
|
@@ -175,6 +222,36 @@ def _zip(path, section_paths):
|
|
|
175
222
|
raise ValueError("Cannot read ZIP export: " + str(exc)) from exc
|
|
176
223
|
|
|
177
224
|
|
|
225
|
+
def scan_stats(path):
|
|
226
|
+
path = Path(path).expanduser().resolve()
|
|
227
|
+
if path.is_file():
|
|
228
|
+
if path.suffix.lower() in (".zip", ".json"):
|
|
229
|
+
st = path.stat()
|
|
230
|
+
return {path.name: [st.st_mtime_ns, st.st_size]}
|
|
231
|
+
raise ValueError("Input file must be ZIP or section JSON: " + str(path))
|
|
232
|
+
if not path.is_dir():
|
|
233
|
+
raise ValueError("Input directory does not exist: " + str(path))
|
|
234
|
+
|
|
235
|
+
stats = {}
|
|
236
|
+
for canvas in path.glob("*.canvas"):
|
|
237
|
+
if not canvas.is_symlink() and canvas.is_file():
|
|
238
|
+
st = canvas.stat()
|
|
239
|
+
stats[canvas.relative_to(path).as_posix()] = [st.st_mtime_ns, st.st_size]
|
|
240
|
+
for loose in path.glob("*.json"):
|
|
241
|
+
if not loose.is_symlink() and loose.is_file():
|
|
242
|
+
st = loose.stat()
|
|
243
|
+
stats[loose.relative_to(path).as_posix()] = [st.st_mtime_ns, st.st_size]
|
|
244
|
+
for directory in PROTOCOL_DIRS:
|
|
245
|
+
branch = path / directory
|
|
246
|
+
if branch.is_symlink() or not branch.is_dir():
|
|
247
|
+
continue
|
|
248
|
+
for entry in branch.rglob("*"):
|
|
249
|
+
if not entry.is_symlink() and entry.is_file() and entry.suffix == ".json":
|
|
250
|
+
st = entry.stat()
|
|
251
|
+
stats[entry.relative_to(path).as_posix()] = [st.st_mtime_ns, st.st_size]
|
|
252
|
+
return stats
|
|
253
|
+
|
|
254
|
+
|
|
178
255
|
def read_input(path, section_paths=None):
|
|
179
256
|
path = Path(path).expanduser().resolve()
|
|
180
257
|
section_paths = section_paths or {}
|
|
@@ -186,17 +263,46 @@ def read_input(path, section_paths=None):
|
|
|
186
263
|
files, single_section = _zip(path, section_paths)
|
|
187
264
|
elif path.is_file() and path.suffix.lower() == ".json":
|
|
188
265
|
kind, single_section = "section", True
|
|
189
|
-
raw = _read_file(path, path.parent)
|
|
266
|
+
raw = _normalize_section_bytes(_read_file(path, path.parent), path.name)
|
|
190
267
|
files = {_section_name(path.name, raw, section_paths): raw}
|
|
268
|
+
elif path.is_file() and path.suffix.lower() in (".html", ".htm"):
|
|
269
|
+
from netscape import encode as _encode_html_section, section_from_html
|
|
270
|
+
kind, single_section = "section", True
|
|
271
|
+
text = _read_file(path, path.parent).decode("utf-8", "replace")
|
|
272
|
+
raw = _encode_html_section(section_from_html(text, title=path.stem))
|
|
273
|
+
files = {_section_name(path.stem + ".json", raw, section_paths): raw}
|
|
191
274
|
else:
|
|
192
|
-
raise ValueError("Input must be an existing package directory, ZIP, or
|
|
275
|
+
raise ValueError("Input must be an existing package directory, ZIP, section JSON, or bookmark HTML: " + str(path))
|
|
193
276
|
if not files:
|
|
194
277
|
raise ValueError("No protocol JSON or .canvas files in export; the saved index was retained")
|
|
195
278
|
if sum(name.endswith(".canvas") for name in files) > 1:
|
|
196
279
|
raise ValueError("An export must have at most one .canvas entry")
|
|
197
280
|
file_hashes = hashes(files)
|
|
281
|
+
file_stats = {}
|
|
282
|
+
if kind == "directory":
|
|
283
|
+
for name in files:
|
|
284
|
+
file_entry = path / name
|
|
285
|
+
try:
|
|
286
|
+
st = file_entry.stat()
|
|
287
|
+
file_stats[name] = [st.st_mtime_ns, st.st_size]
|
|
288
|
+
except OSError:
|
|
289
|
+
pass
|
|
290
|
+
for loose in path.glob("*.json"):
|
|
291
|
+
if not loose.is_symlink() and loose.is_file():
|
|
292
|
+
try:
|
|
293
|
+
st = loose.stat()
|
|
294
|
+
file_stats[loose.relative_to(path).as_posix()] = [st.st_mtime_ns, st.st_size]
|
|
295
|
+
except OSError:
|
|
296
|
+
pass
|
|
297
|
+
elif kind in ("zip", "section"):
|
|
298
|
+
try:
|
|
299
|
+
st = path.stat()
|
|
300
|
+
file_stats[path.name] = [st.st_mtime_ns, st.st_size]
|
|
301
|
+
except OSError:
|
|
302
|
+
pass
|
|
198
303
|
return {"path": str(path), "kind": kind, "single_section": single_section,
|
|
199
|
-
"files": files, "hashes": file_hashes, "
|
|
304
|
+
"files": files, "hashes": file_hashes, "stats": file_stats,
|
|
305
|
+
"fingerprint": fingerprint(file_hashes)}
|
|
200
306
|
|
|
201
307
|
|
|
202
308
|
def git_directory(path):
|
package/src/source_manager.py
CHANGED
|
@@ -14,7 +14,7 @@ from datetime import datetime, timezone
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
16
|
from bookmark_index import _dump, _inside, _string
|
|
17
|
-
from source_inputs import fingerprint, git_busy, git_directory, hashes, read_input
|
|
17
|
+
from source_inputs import fingerprint, git_busy, git_directory, hashes, read_input, scan_stats
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
SCHEMA = (
|
|
@@ -35,10 +35,11 @@ class SourceManager:
|
|
|
35
35
|
self.index, self.connection = index, index.connection
|
|
36
36
|
self.clock, self.debounce, self.deletion_grace = clock, debounce, deletion_grace
|
|
37
37
|
self.storage = index.db_path.with_name(index.db_path.name + ".sources") if index.db_path else None
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
if not index.readonly:
|
|
39
|
+
with index.transaction():
|
|
40
|
+
for statement in SCHEMA:
|
|
41
|
+
self.connection.execute(statement)
|
|
42
|
+
self._recover_retired_snapshots()
|
|
42
43
|
|
|
43
44
|
def _recover_retired_snapshots(self):
|
|
44
45
|
"""Recover a removal interrupted before commit, using surviving DB references."""
|
|
@@ -307,7 +308,7 @@ class SourceManager:
|
|
|
307
308
|
self.connection.execute("INSERT OR IGNORE INTO source_aliases VALUES(?,?)", (source_id, prepared["path"]))
|
|
308
309
|
record = {"mode": mode, "completeness": completeness, "input_path": prepared["path"],
|
|
309
310
|
"input_kind": prepared["kind"], "version_id": version_id, "snapshot_path": str(snapshot_path),
|
|
310
|
-
"observed_hashes": prepared["hashes"], "checked_at": self._now(),
|
|
311
|
+
"observed_hashes": prepared["hashes"], "observed_stats": prepared.get("stats"), "checked_at": self._now(),
|
|
311
312
|
"state": "snapshot" if mode == "snapshot" else "current", "error": None,
|
|
312
313
|
"pending_files": [], "pending_fingerprint": None, "pending_since": None,
|
|
313
314
|
"retained_missing_files": summary["retained_missing_files"], "legacy": False}
|
|
@@ -344,6 +345,39 @@ class SourceManager:
|
|
|
344
345
|
"SELECT COUNT(*) FROM %s WHERE source_id=?" % table, (source_id,)).fetchone()[0]
|
|
345
346
|
for table in ("files", "sections", "items", "nodes", "edges", "memberships")}
|
|
346
347
|
|
|
348
|
+
def _reference_impact(self, source_id):
|
|
349
|
+
"""Find durable research scopes and Wiki revisions that name a source."""
|
|
350
|
+
from settings import Settings
|
|
351
|
+
settings = Settings().load()
|
|
352
|
+
research_root = Path(settings["output"]["directory"])
|
|
353
|
+
wiki_root = Path(settings["wiki"]["directory"])
|
|
354
|
+
research_ids, pages = set(), set()
|
|
355
|
+
if research_root.is_dir() and not research_root.is_symlink():
|
|
356
|
+
for state_path in research_root.glob("*/state.json"):
|
|
357
|
+
if state_path.is_symlink():
|
|
358
|
+
continue
|
|
359
|
+
try:
|
|
360
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
361
|
+
except (OSError, ValueError):
|
|
362
|
+
continue
|
|
363
|
+
if source_id in state.get("source_scope", {}).get("source_ids", []):
|
|
364
|
+
research_ids.add(state.get("research_id") or state_path.parent.name)
|
|
365
|
+
revisions = wiki_root / "revisions"
|
|
366
|
+
if revisions.is_dir() and not revisions.is_symlink():
|
|
367
|
+
for revision_path in revisions.glob("*/*.json"):
|
|
368
|
+
if revision_path.is_symlink():
|
|
369
|
+
continue
|
|
370
|
+
try:
|
|
371
|
+
record = json.loads(revision_path.read_text(encoding="utf-8"))
|
|
372
|
+
except (OSError, ValueError):
|
|
373
|
+
continue
|
|
374
|
+
if any(ref.get("source_id") == source_id
|
|
375
|
+
for evidence in record.get("evidence", [])
|
|
376
|
+
for citation in evidence.get("citations", [])
|
|
377
|
+
for ref in citation.get("bookmark_refs", []) if isinstance(ref, dict)):
|
|
378
|
+
pages.add(record.get("page_id") or revision_path.parent.name)
|
|
379
|
+
return {"affected_pages": sorted(pages), "affected_research_ids": sorted(research_ids)}
|
|
380
|
+
|
|
347
381
|
def _snapshot_directory(self, source_id):
|
|
348
382
|
if self.storage is None:
|
|
349
383
|
return None
|
|
@@ -400,9 +434,11 @@ class SourceManager:
|
|
|
400
434
|
snapshot = self._snapshot_directory(source_id)
|
|
401
435
|
aliases = [row[0] for row in self.connection.execute(
|
|
402
436
|
"SELECT input_path FROM source_aliases WHERE source_id=? ORDER BY input_path", (source_id,))]
|
|
437
|
+
impact = self._reference_impact(source_id)
|
|
403
438
|
if not confirm:
|
|
404
439
|
return {"source_id": source_id, "status": "confirmation_required", "would_remove": counts,
|
|
405
440
|
"aliases": aliases, "snapshot_directory": str(snapshot) if snapshot else None,
|
|
441
|
+
**impact,
|
|
406
442
|
"impact": "Research tasks that froze this source keep their saved inventory but stop resolving "
|
|
407
443
|
"its items; Wiki pages citing those claims require review. Re-run with confirm=true "
|
|
408
444
|
"(CLI: --confirm) to delete."}
|
|
@@ -411,7 +447,7 @@ class SourceManager:
|
|
|
411
447
|
self.connection.execute("DELETE FROM %s WHERE source_id=?" % table, (source_id,))
|
|
412
448
|
self.connection.execute("DELETE FROM sources WHERE source_id=?", (source_id,))
|
|
413
449
|
return {"source_id": source_id, "status": "removed", "removed": counts,
|
|
414
|
-
"aliases": aliases, **cleanup}
|
|
450
|
+
"aliases": aliases, **impact, **cleanup}
|
|
415
451
|
|
|
416
452
|
def merge(self, source_id, into_source_id, confirm=False):
|
|
417
453
|
"""Drop a duplicate source whose content version equals the target's.
|
|
@@ -432,9 +468,11 @@ class SourceManager:
|
|
|
432
468
|
"instead of merging")
|
|
433
469
|
aliases = [row[0] for row in self.connection.execute(
|
|
434
470
|
"SELECT input_path FROM source_aliases WHERE source_id=? ORDER BY input_path", (source_id,))]
|
|
471
|
+
impact = self._reference_impact(source_id)
|
|
435
472
|
if not confirm:
|
|
436
473
|
return {"source_id": source_id, "into_source_id": into_source_id, "status": "confirmation_required",
|
|
437
474
|
"version_id": record["version_id"], "moved_aliases": aliases,
|
|
475
|
+
**impact,
|
|
438
476
|
"impact": "The duplicate is deleted and its paths re-point at into_source_id; saved research "
|
|
439
477
|
"inventories that froze the duplicate stop resolving. Re-run with confirm=true "
|
|
440
478
|
"(CLI: --confirm) to merge."}
|
|
@@ -442,13 +480,18 @@ class SourceManager:
|
|
|
442
480
|
self.connection.execute("INSERT OR IGNORE INTO source_aliases VALUES(?,?)", (into_source_id, alias))
|
|
443
481
|
self.connection.execute("INSERT OR IGNORE INTO source_aliases VALUES(?,?)",
|
|
444
482
|
(into_source_id, target.get("input_path")))
|
|
483
|
+
self.connection.execute(
|
|
484
|
+
"INSERT INTO source_redirects(old_source_id,new_source_id,created_at,reason) VALUES(?,?,?,?) "
|
|
485
|
+
"ON CONFLICT(old_source_id) DO UPDATE SET new_source_id=excluded.new_source_id, "
|
|
486
|
+
"created_at=excluded.created_at, reason=excluded.reason",
|
|
487
|
+
(source_id, into_source_id, self._now(), "merged_duplicate"))
|
|
445
488
|
for table in ("items", "sections", "files", "nodes", "edges", "memberships",
|
|
446
489
|
"source_snapshots", "source_aliases", "source_records"):
|
|
447
490
|
self.connection.execute("DELETE FROM %s WHERE source_id=?" % table, (source_id,))
|
|
448
491
|
self.connection.execute("DELETE FROM sources WHERE source_id=?", (source_id,))
|
|
449
492
|
return {"source_id": source_id, "into_source_id": into_source_id, "status": "merged",
|
|
450
493
|
"version_id": record.get("version_id"), "moved_aliases": aliases,
|
|
451
|
-
**cleanup}
|
|
494
|
+
**impact, **cleanup}
|
|
452
495
|
|
|
453
496
|
def _observation(self, source_id, record, state, **fields):
|
|
454
497
|
record.update(state=state, checked_at=self._now(), **fields)
|
|
@@ -458,7 +501,7 @@ class SourceManager:
|
|
|
458
501
|
"pending_files": record.get("pending_files", []), "error": record.get("error"),
|
|
459
502
|
"version_id": record.get("version_id"), "checked_at": record["checked_at"]}
|
|
460
503
|
|
|
461
|
-
def refresh(self, source_id, force=True):
|
|
504
|
+
def refresh(self, source_id, force=True, verify_hashes=False):
|
|
462
505
|
record = self._record(source_id)
|
|
463
506
|
if record["mode"] == "snapshot":
|
|
464
507
|
return {"source_id": source_id, "mode": "snapshot", "state": "snapshot", "performed": False,
|
|
@@ -467,17 +510,33 @@ class SourceManager:
|
|
|
467
510
|
with self.index.transaction():
|
|
468
511
|
record = self._record(source_id)
|
|
469
512
|
if record["mode"] == "snapshot":
|
|
470
|
-
return self.refresh(source_id, force)
|
|
513
|
+
return self.refresh(source_id, force, verify_hashes=verify_hashes)
|
|
471
514
|
if git_busy(record["input_path"]):
|
|
472
515
|
return self._observation(source_id, record, "pending", error="Git is updating the source",
|
|
473
516
|
pending_fingerprint=None, pending_since=None)
|
|
517
|
+
|
|
518
|
+
# Pre-screen via mtime and size if not forcing full verification and not awaiting debounce
|
|
519
|
+
if not verify_hashes and not record.get("pending_files") and record.get("observed_stats") and record.get("observed_hashes"):
|
|
520
|
+
try:
|
|
521
|
+
current_stats = scan_stats(record["input_path"])
|
|
522
|
+
if current_stats == record["observed_stats"]:
|
|
523
|
+
return self._observation(source_id, record, "current",
|
|
524
|
+
observed_hashes=record["observed_hashes"],
|
|
525
|
+
observed_stats=record["observed_stats"],
|
|
526
|
+
pending_files=[],
|
|
527
|
+
pending_fingerprint=None, pending_since=None, error=None)
|
|
528
|
+
except (OSError, ValueError):
|
|
529
|
+
pass
|
|
530
|
+
|
|
474
531
|
prepared = read_input(record["input_path"])
|
|
475
532
|
before = record["observed_hashes"] or {row[0]: row[1] for row in self.connection.execute(
|
|
476
533
|
"SELECT file_path,sha256 FROM files WHERE source_id=?", (source_id,))}
|
|
477
534
|
after = prepared["hashes"]
|
|
478
535
|
pending = sorted(name for name in before.keys() | after.keys() if before.get(name) != after.get(name))
|
|
479
536
|
if not pending:
|
|
480
|
-
return self._observation(source_id, record, "current", observed_hashes=after,
|
|
537
|
+
return self._observation(source_id, record, "current", observed_hashes=after,
|
|
538
|
+
observed_stats=prepared.get("stats"),
|
|
539
|
+
pending_files=[],
|
|
481
540
|
pending_fingerprint=None, pending_since=None, error=None)
|
|
482
541
|
deletion = bool(before.keys() - after.keys()) and record["completeness"] == "complete"
|
|
483
542
|
delay = self.deletion_grace if deletion else self.debounce
|
|
@@ -507,7 +566,7 @@ class SourceManager:
|
|
|
507
566
|
def source_status(self, source_id):
|
|
508
567
|
"""Read lifecycle state without recounting items or loading section bodies."""
|
|
509
568
|
record = self._record(source_id)
|
|
510
|
-
visible = {key: value for key, value in record.items() if key not in ("observed_hashes", "pending_fingerprint", "pending_since")}
|
|
569
|
+
visible = {key: value for key, value in record.items() if key not in ("observed_hashes", "observed_stats", "pending_fingerprint", "pending_since")}
|
|
511
570
|
checked = record.get("checked_at")
|
|
512
571
|
if record["mode"] == "live" and record["state"] == "current" and (not checked or
|
|
513
572
|
self.clock() - datetime.fromisoformat(checked).timestamp() > 10):
|
package/src/source_versions.py
CHANGED
|
@@ -5,6 +5,8 @@ from contextlib import closing
|
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
import storage
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
class SourceVersions:
|
|
10
12
|
def __init__(self, path):
|
|
@@ -18,9 +20,15 @@ class SourceVersions:
|
|
|
18
20
|
except (ValueError, TypeError, AttributeError):
|
|
19
21
|
return None
|
|
20
22
|
|
|
21
|
-
def _connect(self):
|
|
23
|
+
def _connect(self, readonly=False):
|
|
22
24
|
if self.path.is_symlink():
|
|
23
25
|
raise ValueError("Source version index must not be a symlink")
|
|
26
|
+
if readonly:
|
|
27
|
+
if not self.path.is_file():
|
|
28
|
+
raise sqlite3.OperationalError("Source version index is not built yet: %s" % self.path)
|
|
29
|
+
connection = storage.connect(self.path, readonly=True)
|
|
30
|
+
connection.row_factory = sqlite3.Row
|
|
31
|
+
return storage.mark_query_only(connection)
|
|
24
32
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
33
|
connection = sqlite3.connect(str(self.path), timeout=10)
|
|
26
34
|
connection.row_factory = sqlite3.Row
|
|
@@ -69,11 +77,21 @@ class SourceVersions:
|
|
|
69
77
|
"DO UPDATE SET attempted_at=excluded.attempted_at,status=excluded.status",
|
|
70
78
|
(SourceArchive._key(url), datetime.now(timezone.utc).isoformat(), status))
|
|
71
79
|
|
|
80
|
+
def _open_read(self):
|
|
81
|
+
"""Reads keep working when the observations directory is read-only."""
|
|
82
|
+
try:
|
|
83
|
+
connection = self._connect()
|
|
84
|
+
except (sqlite3.DatabaseError, OSError) as error:
|
|
85
|
+
if not storage.is_readonly_failure(error) or not self.path.is_file():
|
|
86
|
+
raise
|
|
87
|
+
return self._connect(readonly=True)
|
|
88
|
+
return connection
|
|
89
|
+
|
|
72
90
|
def inspect(self, url, sha256, acquired_at, published_at, scope_id=None, source_id=None):
|
|
73
91
|
from archive import SourceArchive
|
|
74
92
|
if not self.path.is_file():
|
|
75
93
|
return {"available": False, "latest": None, "newer": None, "last_attempt": None}
|
|
76
|
-
with closing(self.
|
|
94
|
+
with closing(self._open_read()) as connection:
|
|
77
95
|
key = SourceArchive._key(url)
|
|
78
96
|
baseline = connection.execute("SELECT seq FROM snapshots WHERE scope_id=? AND source_id=? AND sha256=?",
|
|
79
97
|
(scope_id, source_id, sha256)).fetchone()
|
package/src/source_watcher.py
CHANGED
|
@@ -9,13 +9,14 @@ from source_manager import SourceManager
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class SourceWatcher:
|
|
12
|
-
def __init__(self, db_path, interval=1.0, debounce=2.0, deletion_grace=5.0, on_change=None):
|
|
12
|
+
def __init__(self, db_path, interval=1.0, debounce=2.0, deletion_grace=5.0, on_change=None, verify_hashes=False):
|
|
13
13
|
for value in (interval, debounce, deletion_grace):
|
|
14
14
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
|
|
15
15
|
raise ValueError("Watcher intervals must be finite positive seconds")
|
|
16
16
|
self.db_path = Path(db_path)
|
|
17
17
|
self.interval, self.debounce, self.deletion_grace = interval, debounce, deletion_grace
|
|
18
18
|
self.on_change = on_change
|
|
19
|
+
self.verify_hashes = bool(verify_hashes)
|
|
19
20
|
self.stop_event, self.ready = threading.Event(), threading.Event()
|
|
20
21
|
self.thread = None
|
|
21
22
|
self.last_error = None
|
|
@@ -35,6 +36,7 @@ class SourceWatcher:
|
|
|
35
36
|
return {"running": bool(self.thread and self.thread.is_alive() and not self.stop_event.is_set()),
|
|
36
37
|
"mechanism": "poll", "interval_seconds": self.interval,
|
|
37
38
|
"debounce_seconds": self.debounce, "deletion_grace_seconds": self.deletion_grace,
|
|
39
|
+
"verify_hashes": self.verify_hashes,
|
|
38
40
|
"error": self.last_error, "lifetime": "host_process"}
|
|
39
41
|
|
|
40
42
|
def run(self):
|
|
@@ -50,7 +52,7 @@ class SourceWatcher:
|
|
|
50
52
|
for source_id in manager.live_sources():
|
|
51
53
|
if self.stop_event.is_set():
|
|
52
54
|
break
|
|
53
|
-
result = manager.refresh(source_id, force=False)
|
|
55
|
+
result = manager.refresh(source_id, force=False, verify_hashes=self.verify_hashes)
|
|
54
56
|
signature = (result["state"], result.get("version_id"),
|
|
55
57
|
tuple(result.get("pending_files", [])), result.get("error"))
|
|
56
58
|
if self.on_change is not None and signature != previous.get(source_id):
|
package/src/storage.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""One open policy for every local SQLite store.
|
|
2
|
+
|
|
3
|
+
Read paths must keep working when the data directory is read-only: a shared
|
|
4
|
+
index, a read-only mount, or a host sandbox that only allows the workspace.
|
|
5
|
+
Writers keep WAL and schema initialization; readers fall back to ``mode=ro``
|
|
6
|
+
instead of failing or silently creating sidecar files next to the index.
|
|
7
|
+
"""
|
|
8
|
+
import sqlite3
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_READONLY_HINTS = (
|
|
12
|
+
"readonly",
|
|
13
|
+
"read-only",
|
|
14
|
+
"unable to open database",
|
|
15
|
+
"operation not permitted",
|
|
16
|
+
"permission denied",
|
|
17
|
+
"no authorization to access",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_readonly_failure(error):
|
|
22
|
+
"""True when the filesystem refused a write, rather than the query being wrong."""
|
|
23
|
+
if isinstance(error, (PermissionError, NotADirectoryError)):
|
|
24
|
+
return True
|
|
25
|
+
message = str(error).casefold()
|
|
26
|
+
return any(hint in message for hint in _READONLY_HINTS)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def connect(path, readonly=False, timeout=10.0):
|
|
30
|
+
"""Open ``path`` read-only (URI, ``mode=ro``) or read-write."""
|
|
31
|
+
path = Path(path)
|
|
32
|
+
if not readonly:
|
|
33
|
+
return sqlite3.connect(str(path), timeout=timeout)
|
|
34
|
+
try:
|
|
35
|
+
connection = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True, timeout=timeout)
|
|
36
|
+
connection.execute("SELECT count(*) FROM sqlite_master").fetchone() # open now, not on first use
|
|
37
|
+
return connection
|
|
38
|
+
except sqlite3.OperationalError as error:
|
|
39
|
+
# A WAL database with no ``-shm`` beside it cannot be opened read-only on
|
|
40
|
+
# a read-only directory. The sidecars are absent exactly when no writer is
|
|
41
|
+
# active, so reading the main file as immutable is safe here.
|
|
42
|
+
if "unable to open database file" not in str(error).casefold():
|
|
43
|
+
raise
|
|
44
|
+
return sqlite3.connect(path.as_uri() + "?mode=ro&immutable=1", uri=True, timeout=timeout)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def mark_query_only(connection):
|
|
48
|
+
"""Tell shared helpers that this connection must not attempt self-healing writes."""
|
|
49
|
+
try:
|
|
50
|
+
connection.execute("PRAGMA query_only=ON")
|
|
51
|
+
except sqlite3.DatabaseError:
|
|
52
|
+
pass
|
|
53
|
+
return connection
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_query_only(connection):
|
|
57
|
+
try:
|
|
58
|
+
return bool(connection.execute("PRAGMA query_only").fetchone()[0])
|
|
59
|
+
except (sqlite3.DatabaseError, TypeError):
|
|
60
|
+
return False
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Cheap URL classification used by fetch routing and duplicate reports."""
|
|
2
|
+
from urllib.parse import urlsplit
|
|
3
|
+
|
|
4
|
+
# Base domains whose page is a search session, not a document: fetching the page
|
|
5
|
+
# only returns the same result list a fresh search would produce.
|
|
6
|
+
SEARCH_DOMAINS = (
|
|
7
|
+
"bing.com", "google.com", "google.com.hk", "duckduckgo.com",
|
|
8
|
+
"search.yahoo.com", "search.brave.com", "baidu.com", "yandex.com",
|
|
9
|
+
"yandex.ru", "sogou.com", "so.com", "startpage.com", "ecosia.org",
|
|
10
|
+
"search.naver.com",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def host_of(url):
|
|
15
|
+
try:
|
|
16
|
+
return (urlsplit(url).hostname or "").casefold()
|
|
17
|
+
except (ValueError, TypeError):
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _matches(host, domain):
|
|
22
|
+
return host == domain or host.endswith("." + domain)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_search_result_url(url):
|
|
26
|
+
"""True for search-engine result pages (query pages), not documents."""
|
|
27
|
+
if not isinstance(url, str) or not url:
|
|
28
|
+
return False
|
|
29
|
+
host = host_of(url)
|
|
30
|
+
if not host or not any(_matches(host, domain) for domain in SEARCH_DOMAINS):
|
|
31
|
+
return False
|
|
32
|
+
parts = urlsplit(url)
|
|
33
|
+
path = (parts.path or "").casefold()
|
|
34
|
+
query = parts.query or ""
|
|
35
|
+
if _matches(host, "google.com") or _matches(host, "google.com.hk"):
|
|
36
|
+
return path in ("/search", "/url", "/webhp") or "q=" in query
|
|
37
|
+
if _matches(host, "bing.com"):
|
|
38
|
+
return path.startswith("/search") or "q=" in query
|
|
39
|
+
if _matches(host, "baidu.com"):
|
|
40
|
+
return path.startswith("/s") or "wd=" in query
|
|
41
|
+
if _matches(host, "duckduckgo.com"):
|
|
42
|
+
return True
|
|
43
|
+
if _matches(host, "yandex.com") or _matches(host, "yandex.ru"):
|
|
44
|
+
return "text=" in query or "q=" in query
|
|
45
|
+
return path in ("/search", "/s", "/web") or "q=" in query
|