bathys 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bathys/__init__.py +8 -0
- bathys/agents/HARNESS-DROPIN.md +50 -0
- bathys/agents/bathys-researcher.md +91 -0
- bathys/agents/skills/bathys-deep-dive/SKILL.md +55 -0
- bathys/agents/skills/bathys-source-audit/SKILL.md +48 -0
- bathys/batch.py +86 -0
- bathys/cache.py +47 -0
- bathys/compose.yaml +22 -0
- bathys/config.py +71 -0
- bathys/core.py +425 -0
- bathys/crawler.py +104 -0
- bathys/distill.py +135 -0
- bathys/doctor.py +139 -0
- bathys/installer.py +424 -0
- bathys/searx.py +149 -0
- bathys/searxng-settings.yml +13 -0
- bathys/server.py +399 -0
- bathys/services.py +263 -0
- bathys-0.6.1.dist-info/METADATA +147 -0
- bathys-0.6.1.dist-info/RECORD +23 -0
- bathys-0.6.1.dist-info/WHEEL +4 -0
- bathys-0.6.1.dist-info/entry_points.txt +4 -0
- bathys-0.6.1.dist-info/licenses/LICENSE +21 -0
bathys/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Bathys — deep-research MCP server.
|
|
2
|
+
|
|
3
|
+
One system: SearXNG sonar finds coordinates, Crawl4AI dives for the full text,
|
|
4
|
+
a query-focused distiller keeps only what answers the question. Built to spend
|
|
5
|
+
the model's tokens on answers, not on boilerplate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.6.1"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Bathys — блок для файла инструкций харнесса
|
|
2
|
+
|
|
3
|
+
Скопируй блок ниже (от `## Веб-доступ…` до конца файла) в файл инструкций
|
|
4
|
+
своего агента: ZCode — `AGENTS.md` (глобальный или проектный); Claude Code —
|
|
5
|
+
`CLAUDE.md`; Cursor — `.cursor/rules/*.mdc`; generic — системный промпт.
|
|
6
|
+
Блок самодостаточен: после него агент выбирает инструменты Bathys нативно и
|
|
7
|
+
не ходит в интернет мимо них.
|
|
8
|
+
|
|
9
|
+
Опционально (рекомендуется): скопируй `agents/bathys-researcher.md` в каталог
|
|
10
|
+
субагентов (zcode: `~/.zcode/agents/`; claude code: `.claude/agents/`) и
|
|
11
|
+
скиллы из `agents/skills/` — получишь профиль, заточенный под глубокий
|
|
12
|
+
ресёрч через Bathys.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Веб-доступ: всегда через Bathys (локальный поисковый сервис, MCP)
|
|
17
|
+
|
|
18
|
+
Поиск, чтение страниц и исследования — только через инструменты Bathys.
|
|
19
|
+
Сторонние web-инструменты не использовать: у Bathys свои дистилляция под
|
|
20
|
+
запрос, токен-бюджеты, кэш и метрики.
|
|
21
|
+
|
|
22
|
+
Выбор инструмента:
|
|
23
|
+
|
|
24
|
+
- исследовательский вопрос («что/как/почему/сравни») → `deep_research(query)`
|
|
25
|
+
— ищет, читает топ-источники, возвращает дистиллят под запрос;
|
|
26
|
+
- нужны только ссылки → `web_search(query)`; для программы/скрипта —
|
|
27
|
+
`web_search(query, as_json=true)`;
|
|
28
|
+
- один известный URL → `read_url(url, query=…)`; несколько (до 10) →
|
|
29
|
+
`read_urls(urls, query=…)` — общий бюджет, битая страница стоит строку.
|
|
30
|
+
|
|
31
|
+
Правила:
|
|
32
|
+
|
|
33
|
+
1. `query` — вопрос, не мешок ключевых слов: дистиллятор отбирает пассажи по
|
|
34
|
+
смыслу запроса. Максимум 3 итерации на формулировку, дальше — смена угла
|
|
35
|
+
(термины из найденного, другой язык, `category`).
|
|
36
|
+
2. Ключевой факт — два независимых домена; один источник — «по данным одного
|
|
37
|
+
источника»; конфликт — показать обе версии с URL.
|
|
38
|
+
3. Каждый нетривиальный факт в ответе сопровождай URL из секций Bathys.
|
|
39
|
+
4. Свежесть — `time_range` (`day`|`week`|`month`|`year`). `refresh=true` дорог
|
|
40
|
+
(ломает кэш): только при протухшем кэше — `cache HIT` при устаревших
|
|
41
|
+
данных — и один раз на главный источник.
|
|
42
|
+
5. Сигналы: `(not fetched — …)` — источник не прочитан, работай с остальными;
|
|
43
|
+
`robots-refused` — сайт запретил чтение, уважай; `No results … tried N
|
|
44
|
+
engine sets` — помоги формулировкой (синонимы, язык); `Answer:` — мгновенный
|
|
45
|
+
справочный ответ, спорное подтверди источником.
|
|
46
|
+
6. Футер `[bathys: …]` — статистика (сжатие ch, `cache HIT`/`MISS`, сек);
|
|
47
|
+
не цитируй его пользователю.
|
|
48
|
+
|
|
49
|
+
Готовые стратегии — MCP-промпты `bathys_deep_research`, `bathys_source_audit`,
|
|
50
|
+
`bathys_fresh_scan`: бери как стартовый шаблон плана.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bathys-researcher
|
|
3
|
+
description: >-
|
|
4
|
+
Специалист по веб-ресёрчу: весь интернет-доступ — через локальный MCP-сервис
|
|
5
|
+
Bathys (`deep_research`, `web_search`, `read_url`, `read_urls`). Вызывать для
|
|
6
|
+
исследования «что/как/почему/сравни» с выверенным ответом и URL-цитированием,
|
|
7
|
+
проверки спорных утверждений по источникам, свежего среза по теме. Не вызывать
|
|
8
|
+
для вопросов, отвечаемых из локального кода и контекста без интернета.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Bathys Researcher — агент глубокого ресёрча
|
|
12
|
+
|
|
13
|
+
Ты — специалист по веб-исследованиям. Весь твой доступ в интернет идёт
|
|
14
|
+
**только** через MCP-сервис Bathys (`deep_research`, `web_search`, `read_url`,
|
|
15
|
+
`read_urls`). Сторонние web-инструменты не использовать: у Bathys свои
|
|
16
|
+
дистилляция под запрос, бюджеты, кэш и метрики — обход ломает экономику
|
|
17
|
+
и учёт.
|
|
18
|
+
|
|
19
|
+
## Матрица выбора инструмента
|
|
20
|
+
|
|
21
|
+
| Ситуация | Инструмент |
|
|
22
|
+
|---|---|
|
|
23
|
+
| Исследовательский вопрос («что/как/почему/сравни») | `deep_research(query)` — поиск + чтение топ-источников + дистиллят под запрос одним вызовом |
|
|
24
|
+
| Нужны только ссылки (пул источников, мониторинг) | `web_search(query)` |
|
|
25
|
+
| Результат нужен программе/скрипту | `web_search(query, as_json=true)` — чистый JSON без футера |
|
|
26
|
+
| Один известный URL | `read_url(url, query=…)` — `query` оставляет только релевантные пассажи |
|
|
27
|
+
| Несколько известных URL (до 10) | `read_urls(urls, query=…)` — общий бюджет; битая страница стоит строку, не вызов |
|
|
28
|
+
|
|
29
|
+
Правило края: в руках список из 3+ URL — значит `read_urls`, не цикл
|
|
30
|
+
`read_url`. Промпты Bathys (`bathys_deep_research`, `bathys_source_audit`,
|
|
31
|
+
`bathys_fresh_scan`) — готовые стратегии; бери как стартовый шаблон плана,
|
|
32
|
+
а не изобретай с нуля.
|
|
33
|
+
|
|
34
|
+
## Метод (максимум 3 итерации на одну формулировку)
|
|
35
|
+
|
|
36
|
+
1. **Запрос — вопрос**, не мешок ключевых слов: дистиллятор Bathys отбирает
|
|
37
|
+
пассажи по смыслу запроса. RU/EN оба работают; для нишевой темы добавь
|
|
38
|
+
`language` и англоязычный дубль запроса.
|
|
39
|
+
2. **Первый заход — `deep_research`** с дефолтами (`max_sources=3`). Разбери
|
|
40
|
+
дистиллят по секциям: (a) что отвечает на вопрос, (b) что противоречит,
|
|
41
|
+
(c) каких данных не хватает. Уточни запрос терминами из (a)–(c) —
|
|
42
|
+
терминами предметной области из источников, не своими синонимами — и
|
|
43
|
+
повтори. После 3 итераций без сдвига смени угол: `web_search` по узкому
|
|
44
|
+
термину, затем `read_urls` по 2–4 лучшим URL.
|
|
45
|
+
3. **Свежесть**: «сейчас/последние/текущие» → `time_range`
|
|
46
|
+
(`day`/`week`/`month`/`year`); обзоры версий — `month`, новости — `day`.
|
|
47
|
+
Про `refresh=true` — раздел «Кэш» ниже.
|
|
48
|
+
4. **Верификация**: ключевое утверждение подтверждено при двух независимых
|
|
49
|
+
доменах; один источник — пиши «по данным одного источника»; конфликт —
|
|
50
|
+
покажи обе версии с URL, не выбирай молча сторону.
|
|
51
|
+
5. **Цитируй URL** из секций ответов Bathys — это твой след аудита.
|
|
52
|
+
6. **Стоп**: ответ получен; или 3 итерации без сдвига — скажи, чего не
|
|
53
|
+
хватило, и предложи сменить угол; или дистиллята достаточно для синтеза.
|
|
54
|
+
|
|
55
|
+
## Сигналы ответов
|
|
56
|
+
|
|
57
|
+
- `(not fetched — Причина)` в секции — источник не прочитан; не провал:
|
|
58
|
+
работай с остальными секциями, при нужде добери URL отдельным `read_url`.
|
|
59
|
+
- `robots-refused` — сайт запретил чтение краулерам: уважай запрет, ищи
|
|
60
|
+
другой источник той же информации, не обход.
|
|
61
|
+
- `No results … · tried N engine sets · unresponsive: …` — поисковики
|
|
62
|
+
капризничали, Bathys уже ретраился: помоги формулировкой — синонимы, язык,
|
|
63
|
+
`category` (`general`/`news`/`it`/`science`).
|
|
64
|
+
- `Answer:` — мгновенный справочный ответ SearXNG: годится как факт, но
|
|
65
|
+
спорное подтверди источником.
|
|
66
|
+
|
|
67
|
+
## Кэш и `refresh=true`
|
|
68
|
+
|
|
69
|
+
`refresh` дорог: ломает кэш и форсит сеть. Единственный повод — протухший
|
|
70
|
+
кэш: футер показывает `cache HIT`, а данные в дистилляте устарели (старые
|
|
71
|
+
даты, старые версии). Тогда один `refresh=true` на главный источник, не на
|
|
72
|
+
пакет. `cache MISS` на повторе означает, что Bathys уже перезабрал данные
|
|
73
|
+
свежими — refresh не нужен. Нюансы футера: `read_url` пишет `cache HIT`/`MISS`,
|
|
74
|
+
`read_urls` — ещё и `MIX` (смешанный), `web_search` ставит токен `cache HIT`
|
|
75
|
+
только на попадании, а футер `deep_research` флага кэша не содержит — там
|
|
76
|
+
ориентируйся на свежесть данных в секциях.
|
|
77
|
+
|
|
78
|
+
## Бюджеты
|
|
79
|
+
|
|
80
|
+
Дефолты разумны: `per_source_chars` 3500 (диапазон 300–8000) · `max_sources`
|
|
81
|
+
3 (1–6) · `max_chars` 8000 (300–50000) · пакет `total_chars` 12000
|
|
82
|
+
(300–30000). Поднимай только для систематического разбора (обзор,
|
|
83
|
+
сравнение). Ты получаешь дистиллят — твоя работа синтез, а не пересказ.
|
|
84
|
+
|
|
85
|
+
## Чего не делать
|
|
86
|
+
|
|
87
|
+
- `web_search` там, где нужен `deep_research` — получишь ссылки без
|
|
88
|
+
содержания и потратишь лишние вызовы на дочтение.
|
|
89
|
+
- Читать список URL по одному через `read_url`, если их 3+ — это `read_urls`.
|
|
90
|
+
- `refresh=true` «на всякий случай» и на весь пакет.
|
|
91
|
+
- Выдавать источники за свои слова: каждый нетривиальный факт — с URL.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bathys-deep-dive
|
|
3
|
+
description: >-
|
|
4
|
+
Глубокое исследование одного вопроса через Bathys: 1–3 итерации
|
|
5
|
+
`deep_research`, верификация по двум независимым доменам, ответ с
|
|
6
|
+
URL-цитированием. Применять, когда вопрос требует понимания («почему X»,
|
|
7
|
+
«как устроен Y», «что выбрать»), а не справочного факта. Не для проверки
|
|
8
|
+
готовых URL (bathys-source-audit), свежих новостей (промпт
|
|
9
|
+
`bathys_fresh_scan`) и простого поиска ссылок (`web_search`).
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Bathys Deep Dive — глубокое исследование одного вопроса
|
|
13
|
+
|
|
14
|
+
## Когда применять
|
|
15
|
+
|
|
16
|
+
- Вопрос требует понимания, а не факта: «почему X», «как устроен Y», «что
|
|
17
|
+
выбрать между Z и W»; ответ ляжет в решение (выбор библиотеки, диагноз,
|
|
18
|
+
оценка рисков).
|
|
19
|
+
- Не для: проверка готовых URL или утверждения — скилл `bathys-source-audit`;
|
|
20
|
+
свежий срез по теме — промпт `bathys_fresh_scan`; просто список ссылок —
|
|
21
|
+
`web_search`.
|
|
22
|
+
|
|
23
|
+
## Процедура
|
|
24
|
+
|
|
25
|
+
1. **Заход.** `deep_research(query, max_sources=3)` с запросом-вопросом.
|
|
26
|
+
Без предварительного `web_search`: первый заход уже ищет и читает
|
|
27
|
+
топ-источники.
|
|
28
|
+
2. **Разбор дистиллята.** Из секций `## i.` выпиши: (a) что отвечает на
|
|
29
|
+
вопрос, (b) что противоречит, (c) каких данных не хватает.
|
|
30
|
+
3. **Вторая итерация.** Переформулируй запрос терминами из (a)–(c) —
|
|
31
|
+
терминами предметной области, найденными в источниках, не своими
|
|
32
|
+
синонимами. Не хватает конкретики: `web_search` по узкому термину →
|
|
33
|
+
`read_urls` по 2–4 лучшим URL с `query=…`. Максимум 3 итерации на
|
|
34
|
+
формулировку, дальше — смена угла, не шестой заход.
|
|
35
|
+
4. **Свежесть.** Обзоры версий/цен/событий: `time_range="month"` (новости —
|
|
36
|
+
`day`; допустимо `day`/`week`/`month`/`year`). `refresh=true` — только
|
|
37
|
+
при протухшем кэше (`cache HIT`, а данные устарели), один раз на главный
|
|
38
|
+
источник.
|
|
39
|
+
5. **Верификация.** Ключевые утверждения — 2 независимых домена; один
|
|
40
|
+
источник — «по данным одного источника»; конфликт — показать обе версии
|
|
41
|
+
с URL.
|
|
42
|
+
6. **Синтез.** Вывод первым предложением; далее 3–7 пунктов, у каждого
|
|
43
|
+
нетривиального факта — URL; в конце «что не удалось проверить».
|
|
44
|
+
|
|
45
|
+
## Выход
|
|
46
|
+
|
|
47
|
+
- Вердикт/ответ + аргументация с URL у каждого нетривиального факта.
|
|
48
|
+
- Абзац «ограничения вывода»: возраст данных, единственные источники,
|
|
49
|
+
непроверенные пункты.
|
|
50
|
+
|
|
51
|
+
## Анти-паттерны
|
|
52
|
+
|
|
53
|
+
- Пересказ дистиллята целиком — это сырьё, не ответ.
|
|
54
|
+
- Больше 3 итераций без смены угла.
|
|
55
|
+
- `Answer:` из выдачи как проверенный факт без подтверждения источником.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bathys-source-audit
|
|
3
|
+
description: >-
|
|
4
|
+
Аудит утверждений и источников через Bathys: пакетное чтение URL под тезис
|
|
5
|
+
(`read_urls`), кросс-поиск опровержений (`deep_research`), вердикт по
|
|
6
|
+
каждому пункту. Применять, когда URL уже есть (от пользователя, из чужого
|
|
7
|
+
ответа, из код-ревью) или нужно проверить утверждение «X, потому что
|
|
8
|
+
<URL>». Не для открытого исследования без исходных ссылок
|
|
9
|
+
(bathys-deep-dive).
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Bathys Source Audit — аудит источников и утверждений
|
|
13
|
+
|
|
14
|
+
## Когда применять
|
|
15
|
+
|
|
16
|
+
- Есть список ссылок и нужно проверить, что они действительно говорят то,
|
|
17
|
+
что заявлено.
|
|
18
|
+
- Нужно проверить одно утверждение: «X правда, потому что <URL>».
|
|
19
|
+
- Нужна актуальность: «это ещё так в текущих версиях?»
|
|
20
|
+
|
|
21
|
+
## Процедура
|
|
22
|
+
|
|
23
|
+
1. **Пакетное чтение.** `read_urls(urls, query=…)` — в `query` спорный тезис
|
|
24
|
+
словами: дистилляция вплотную к проверяемому утверждению, а не «вообще о
|
|
25
|
+
чём статья». До 10 URL одним вызовом; битые отвалятся строкой
|
|
26
|
+
`(not fetched — …)` — это не провал.
|
|
27
|
+
2. **Контекст источника.** Для каждого URL: домен/автор, дата (`published`
|
|
28
|
+
в хитах поиска или дата в дистилляте), первоисточник или пересказ.
|
|
29
|
+
3. **Кросс-проверка.** Спорный тезис → `deep_research` с запросом вида
|
|
30
|
+
«X критика / X опровержение / X alternatives»: ищи расхождения, а не
|
|
31
|
+
подтверждения; при споре об актуальности добавь `time_range`.
|
|
32
|
+
4. **Свежесть.** Проверка «как сейчас» — один `refresh=true` на главный
|
|
33
|
+
источник (повод: `cache HIT` при устаревших данных), не на весь пакет.
|
|
34
|
+
5. **Вердикт по каждому пункту**: подтверждено (≥2 независимых домена) /
|
|
35
|
+
частично (1 источник) / опровергнуто (опровержение с URL) / непроверяемо
|
|
36
|
+
(`robots-refused`, 404 — указать причину).
|
|
37
|
+
|
|
38
|
+
## Выход
|
|
39
|
+
|
|
40
|
+
Таблица «тезис → вердикт → источники (URL)» + абзац общего вывода. Каждый
|
|
41
|
+
вердикт содержит хотя бы один URL либо явную причину его отсутствия.
|
|
42
|
+
|
|
43
|
+
## Анти-паттерны
|
|
44
|
+
|
|
45
|
+
- Читать URL «вообще» без `query` — дистиллят размоется, проверка станет
|
|
46
|
+
«похоже на правду».
|
|
47
|
+
- Считать два пересказа одного пресс-релиза независимыми источниками.
|
|
48
|
+
- Обходить `robots-refused`: фиксируй отказ и ищи другой первоисточник.
|
bathys/batch.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Batch reading of known URLs: no search step, one shared character budget.
|
|
2
|
+
|
|
3
|
+
The read_urls counterpart of Engine.research for the case where the caller
|
|
4
|
+
already holds the links (Tavily Extract parity). Dives run in parallel through
|
|
5
|
+
the engine dive semaphore; a failed page degrades to a one-line section and
|
|
6
|
+
frees its share of the budget for the pages that came back.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from . import distill
|
|
15
|
+
from .core import Engine, _ch
|
|
16
|
+
from .searx import normalize_url
|
|
17
|
+
|
|
18
|
+
_MAX_URLS = 10
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def read_many(engine: Engine, urls: list[str], *, query: str | None,
|
|
22
|
+
total_chars: int, refresh: bool = False) -> str:
|
|
23
|
+
started = time.monotonic()
|
|
24
|
+
total_chars = max(300, min(30_000, total_chars))
|
|
25
|
+
|
|
26
|
+
# Dedup by normalized URL (utm/fragment cleanup), keep the first spelling.
|
|
27
|
+
kept: list[str] = []
|
|
28
|
+
seen: set[str] = set()
|
|
29
|
+
for u in urls:
|
|
30
|
+
u = (u or "").strip()
|
|
31
|
+
if not u:
|
|
32
|
+
continue
|
|
33
|
+
key = normalize_url(u)
|
|
34
|
+
if not key or key in seen:
|
|
35
|
+
continue
|
|
36
|
+
seen.add(key)
|
|
37
|
+
kept.append(u)
|
|
38
|
+
if not kept:
|
|
39
|
+
raise ValueError("read_urls: expected 1-10 http(s) urls, got none")
|
|
40
|
+
skipped, kept = kept[_MAX_URLS:], kept[:_MAX_URLS]
|
|
41
|
+
|
|
42
|
+
async def dive(u: str):
|
|
43
|
+
async with engine._dive_sem:
|
|
44
|
+
try:
|
|
45
|
+
# max_chars here is only an upper bound for the throwaway
|
|
46
|
+
# distill inside _read; shares are cut after the gather.
|
|
47
|
+
return await engine._read(u, query=query, max_chars=total_chars,
|
|
48
|
+
refresh=refresh), None
|
|
49
|
+
except Exception as e:
|
|
50
|
+
# _read raises "{Class}: {msg}" both fresh and from the error cache.
|
|
51
|
+
return None, str(e) or e.__class__.__name__
|
|
52
|
+
|
|
53
|
+
results = await asyncio.gather(*(dive(u) for u in kept))
|
|
54
|
+
|
|
55
|
+
ok = sum(1 for res, _ in results if res is not None)
|
|
56
|
+
share = total_chars // ok if ok else 0
|
|
57
|
+
remainder = total_chars - share * ok if ok else 0
|
|
58
|
+
|
|
59
|
+
sections: list[str] = []
|
|
60
|
+
fetched = returned = 0
|
|
61
|
+
first_ok = True
|
|
62
|
+
for i, ((res, err), u) in enumerate(zip(results, kept), 1):
|
|
63
|
+
if res is None:
|
|
64
|
+
sections.append(f"## {i}. {u}\n(not fetched — {err})")
|
|
65
|
+
continue
|
|
66
|
+
budget = share + (remainder if first_ok else 0)
|
|
67
|
+
first_ok = False
|
|
68
|
+
slim = distill.slim_markdown(res.page.text)
|
|
69
|
+
distilled = distill.passages(slim, query, budget)
|
|
70
|
+
fetched += res.page.raw_chars
|
|
71
|
+
returned += len(distilled)
|
|
72
|
+
sections.append(f"## {i}. {res.page.title or u}\n{res.page.url}\n\n{distilled}")
|
|
73
|
+
if skipped:
|
|
74
|
+
sections.append("Skipped (over the 10-url limit): " + ", ".join(skipped))
|
|
75
|
+
secs = round(time.monotonic() - started, 1)
|
|
76
|
+
cache_flags = {res.cache_hit for res, _ in results if res is not None}
|
|
77
|
+
cache = ("HIT" if cache_flags == {True} else
|
|
78
|
+
"MISS" if cache_flags == {False} else "MIX") if cache_flags else None
|
|
79
|
+
engine._log_metrics("read_urls", cache=cache, chars_in=fetched, chars_out=returned,
|
|
80
|
+
secs=secs, ok=ok > 0,
|
|
81
|
+
error=None if ok else "all_failed")
|
|
82
|
+
sections.append(
|
|
83
|
+
f"[bathys: batch {len(kept)} urls · {ok}/{len(kept)} ok · "
|
|
84
|
+
f"{_ch(fetched)} ch fetched → {_ch(returned)} ch returned · {secs}s]"
|
|
85
|
+
)
|
|
86
|
+
return "\n\n".join(sections)
|
bathys/cache.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Tiny SQLite TTL cache.
|
|
2
|
+
|
|
3
|
+
Pages are cached raw (clean markdown); distillation happens after a cache hit,
|
|
4
|
+
so re-reading the same URL with a different query never re-downloads anything.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def key(*parts: object) -> str:
|
|
18
|
+
return hashlib.sha256("\x1f".join(str(p) for p in parts).encode()).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Cache:
|
|
22
|
+
key = staticmethod(key)
|
|
23
|
+
|
|
24
|
+
def __init__(self, path: Path) -> None:
|
|
25
|
+
path = Path(path)
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
self._db = sqlite3.connect(path, check_same_thread=False)
|
|
29
|
+
self._db.execute(
|
|
30
|
+
"CREATE TABLE IF NOT EXISTS cache (k TEXT PRIMARY KEY, exp REAL NOT NULL, v TEXT NOT NULL)"
|
|
31
|
+
)
|
|
32
|
+
self._db.execute("CREATE INDEX IF NOT EXISTS cache_exp ON cache (exp)")
|
|
33
|
+
|
|
34
|
+
def get(self, k: str) -> tuple[bool, object]:
|
|
35
|
+
with self._lock:
|
|
36
|
+
row = self._db.execute("SELECT exp, v FROM cache WHERE k = ?", (k,)).fetchone()
|
|
37
|
+
if row is None or row[0] <= time.time():
|
|
38
|
+
return False, None
|
|
39
|
+
return True, json.loads(row[1])
|
|
40
|
+
|
|
41
|
+
def set(self, k: str, value: object, ttl: int) -> None:
|
|
42
|
+
with self._lock, self._db:
|
|
43
|
+
self._db.execute(
|
|
44
|
+
"INSERT INTO cache (k, exp, v) VALUES (?, ?, ?) "
|
|
45
|
+
"ON CONFLICT(k) DO UPDATE SET exp = excluded.exp, v = excluded.v",
|
|
46
|
+
(k, time.time() + ttl, json.dumps(value, ensure_ascii=False)),
|
|
47
|
+
)
|
bathys/compose.yaml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Used by bathys when a container engine (docker/podman) is available.
|
|
2
|
+
# Settings are mounted from the bundled searxng-settings.yml.
|
|
3
|
+
# Image pinned to the same upstream commit as the native-mode SHA pin
|
|
4
|
+
# (config.SEARXNG_REF) — re-pin both together, quarterly (F-302).
|
|
5
|
+
services:
|
|
6
|
+
searxng:
|
|
7
|
+
image: searxng/searxng:2026.9.5-c7f3080aa
|
|
8
|
+
container_name: bathys-searxng
|
|
9
|
+
restart: unless-stopped
|
|
10
|
+
ports:
|
|
11
|
+
- "127.0.0.1:8888:8080"
|
|
12
|
+
volumes:
|
|
13
|
+
- ./searxng-settings.yml:/etc/searxng/settings.yml:ro
|
|
14
|
+
environment:
|
|
15
|
+
- SEARXNG_BASE_URL=http://127.0.0.1:8888/
|
|
16
|
+
cap_drop: [ALL]
|
|
17
|
+
cap_add: [CHOWN, SETGID, SETUID]
|
|
18
|
+
logging:
|
|
19
|
+
driver: json-file
|
|
20
|
+
options:
|
|
21
|
+
max-size: "1m"
|
|
22
|
+
max-file: "1"
|
bathys/config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Runtime configuration, read once from the environment with sane defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
11
|
+
v = os.environ.get(name)
|
|
12
|
+
if v is None:
|
|
13
|
+
return default
|
|
14
|
+
return v.strip().lower() not in {"", "0", "false", "no", "off"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Pinned SearXNG source commit (F-302, supply-chain). The upstream repo has no
|
|
18
|
+
# git tags; this full SHA is reviewable on github.com/searxng/searxng and matches
|
|
19
|
+
# the docker image tag pinned in compose.yaml (2026.9.5-c7f3080aa). Re-pin
|
|
20
|
+
# deliberately (quarterly), never silently track master.
|
|
21
|
+
SEARXNG_REF = "c7f3080aac5de13b619c4a5ab36590a2c5165e1c"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Config:
|
|
26
|
+
searxng_url: str
|
|
27
|
+
auto_start: bool
|
|
28
|
+
start_mode: str # "auto" | "docker" | "native"
|
|
29
|
+
start_cmd: str | None
|
|
30
|
+
searxng_ref: str
|
|
31
|
+
data_dir: Path
|
|
32
|
+
cache_dir: Path
|
|
33
|
+
searxng_home: Path
|
|
34
|
+
search_ttl: int
|
|
35
|
+
page_ttl: int
|
|
36
|
+
search_timeout: float
|
|
37
|
+
crawl_timeout: float
|
|
38
|
+
startup_timeout: float
|
|
39
|
+
search_min_interval: float
|
|
40
|
+
search_retries: int
|
|
41
|
+
dive_concurrency: int
|
|
42
|
+
respect_robots: bool
|
|
43
|
+
metrics: bool
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def load(cls) -> "Config":
|
|
47
|
+
data_dir = Path(
|
|
48
|
+
os.environ.get("BATHYS_DATA_DIR", str(Path.home() / ".local" / "share" / "bathys"))
|
|
49
|
+
).expanduser()
|
|
50
|
+
return cls(
|
|
51
|
+
searxng_url=os.environ.get("BATHYS_SEARXNG_URL", "http://127.0.0.1:8888").rstrip("/"),
|
|
52
|
+
auto_start=_env_bool("BATHYS_AUTO_START", True),
|
|
53
|
+
start_mode=(os.environ.get("BATHYS_START_MODE") or "auto").strip().lower(),
|
|
54
|
+
start_cmd=os.environ.get("BATHYS_START_CMD") or None,
|
|
55
|
+
searxng_ref=(os.environ.get("BATHYS_SEARXNG_REF") or SEARXNG_REF).strip(),
|
|
56
|
+
data_dir=data_dir,
|
|
57
|
+
cache_dir=Path(os.environ.get("BATHYS_CACHE_DIR", str(Path.home() / ".cache" / "bathys"))).expanduser(),
|
|
58
|
+
searxng_home=Path(
|
|
59
|
+
os.environ.get("BATHYS_SEARXNG_HOME", str(data_dir / "searxng-home"))
|
|
60
|
+
).expanduser(),
|
|
61
|
+
search_ttl=int(os.environ.get("BATHYS_SEARCH_TTL", "3600")),
|
|
62
|
+
page_ttl=int(os.environ.get("BATHYS_PAGE_TTL", "86400")),
|
|
63
|
+
search_timeout=float(os.environ.get("BATHYS_SEARCH_TIMEOUT", "15")),
|
|
64
|
+
crawl_timeout=float(os.environ.get("BATHYS_CRAWL_TIMEOUT", "40")),
|
|
65
|
+
startup_timeout=float(os.environ.get("BATHYS_STARTUP_TIMEOUT", "90")),
|
|
66
|
+
search_min_interval=float(os.environ.get("BATHYS_SEARCH_MIN_INTERVAL", "1.0")),
|
|
67
|
+
search_retries=max(0, min(3, int(os.environ.get("BATHYS_SEARCH_RETRIES", "2")))),
|
|
68
|
+
dive_concurrency=max(1, min(8, int(os.environ.get("BATHYS_DIVE_CONCURRENCY", "4")))),
|
|
69
|
+
respect_robots=_env_bool("BATHYS_ROBOTS", True),
|
|
70
|
+
metrics=_env_bool("BATHYS_METRICS", True),
|
|
71
|
+
)
|