diff --git a/references/api_sources/search/serpbase.md b/references/api_sources/search/serpbase.md new file mode 100644 index 0000000..daf3479 --- /dev/null +++ b/references/api_sources/search/serpbase.md @@ -0,0 +1,101 @@ +# SerpBase (Google SERP API) + +## Overview + +- **Endpoint base:** `https://api.serpbase.dev` +- **Auth:** API key (header `X-API-Key`) +- **Free tier:** 100 free searches на регистрацию, без кредитной карты +- **Paid:** pay-as-you-go, ~$0.30/1000 queries, без подписки +- **Docs:** https://serpbase.dev +- **Unique:** **реальный Google organic** через REST — без HTML-парсинга и CAPTCHA-армс-рейса (в отличие от SerpAPI — платного, без free tier) + +## What it returns + +JSON с парсированными Google SERP — organic results. Бизнес-ошибки приходят как HTTP 200 с ненулевым `status` + `message`. + +```json +{ + "status": 0, + "organic": [ + { + "position": 1, + "title": "...", + "link": "https://...", + "snippet": "...", + "display_link": "..." + } + ], + "related_searches": ["...", "..."], + "knowledge_graph": { "title": "..." } +} +``` + +## When to use + +- Нужен **реальный Google** ranking — SEO research, competitive intel, GEO/AEO +- Google-specific features (knowledge graph, related searches, AI Overviews) +- Когда WebSearch харнесса не даёт Google organic (или даёт через HTML, который ломается) +- Локальные Google (hl/gl) — региональная выдача + +## When not to use + +- General research — Tavily/Brave дешевле для простых фактов +- Semantic «similar to this article» — Exa лучше +- Когда Google-специфика не нужна + +## Auth setup + +1. https://serpbase.dev → sign up +2. 100 free searches, ключ из dashboard +3. В env: `export SERPBASE_API_KEY="..."` + +## Query patterns + +Прямой вызов из скилла: `python3 scripts/search_query.py --engine serpbase --query "..." [--n 10] [--json]` +(читает `SERPBASE_API_KEY` из env, exit 2 если ключа нет). Ниже — сырой HTTP-контракт, +который скрипт реализует. + +### Google organic search + +``` +GET https://api.serpbase.dev/google/search +Headers: X-API-Key: {SERPBASE_API_KEY} +Accept: application/json + +Params: q={query}&num=10 # опционально: hl, gl — локаль выдачи +``` + +Нормализация в скилле: `organic[]` → `{rank, title, url: link, snippet, engine: "serpbase", fetched_at}` — тот же контракт, что у brave/tavily/exa. + +## Example queries для deepdive + +**Phase 4 — текущий Google-рейтинг по теме отчёта:** + +``` +GET /google/search?q=market+microstructure+prediction+markets&num=10&hl=en +Headers: X-API-Key: {SERPBASE_API_KEY} +``` + +**Phase 4 — competitive intel (что Google реально ранжирует сейчас):** + +``` +GET /google/search?q=postgres+logical+replication+vs+cdc&num=10 +``` + +## Limitations + +- Google organic без «own index» — это снимок реальной выдачи Google, а не независимый от Google индекс (для триангуляции это отдельная траектория: другой канал, чем WebSearch харнесса) +- Платный сверх free tier — но pay-as-you-go без подписки +- Не семантический поиск — keyword-based + +## Combine with + +- **Brave/Tavily** — для broader coverage / answers с sources +- **Exa** — для semantic «find similar» +- **WebSearch харнесса** — как вторая Google-траектория для overlap_rate (search-engine-2 ось) + +## Fallback if API down or rate-limited + +1. Brave Search +2. Tavily с `search_depth: advanced` +3. Standard WebSearch через харнесс diff --git a/references/capability_discovery.md b/references/capability_discovery.md index 9f4c5cf..3ddedfd 100644 --- a/references/capability_discovery.md +++ b/references/capability_discovery.md @@ -15,7 +15,7 @@ Capability Discovery закрывает этот gap. **Прозрачность для пользователя:** что используется, что пропускается, что бы стоило настроить. -**Второй поисковый движок.** Один движок = одна траектория поиска — конформизм, который правило триангуляции запрещает для источников и должно запрещать для самого канала поиска. Если в env есть `BRAVE_SEARCH_API_KEY`, `TAVILY_API_KEY` или `EXA_API_KEY` — на medium/deep Phase 4 обязана выделить одну поисковую ось под него через `scripts/search_query.py --engine {brave,tavily,exa} --query "..."`, параллельно обычному WebSearch харнесса. Пересечение результатов между движками фиксируется как `overlap_rate` в `plan.md` §15 (низкий overlap — сигнал, что второй движок реально расширяет покрытие, а не дублирует первый). +**Второй поисковый движок.** Один движок = одна траектория поиска — конформизм, который правило триангуляции запрещает для источников и должно запрещать для самого канала поиска. Если в env есть `BRAVE_SEARCH_API_KEY`, `TAVILY_API_KEY`, `EXA_API_KEY` или `SERPBASE_API_KEY` — на medium/deep Phase 4 обязана выделить одну поисковую ось под него через `scripts/search_query.py --engine {brave,tavily,exa,serpbase} --query "..."`, параллельно обычному WebSearch харнесса. Пересечение результатов между движками фиксируется как `overlap_rate` в `plan.md` §15 (низкий overlap — сигнал, что второй движок реально расширяет покрытие, а не дублирует первый). ## Workflow фазы @@ -39,6 +39,7 @@ if env $GITHUB_TOKEN exists → mark GitHub as 'authenticated' | `BRAVE_SEARCH_API_KEY` | Brave Search | второй поисковый движок, обязателен для одной оси Phase 4 если ключ есть | | `TAVILY_API_KEY` | Tavily | второй поисковый движок, обязателен для одной оси Phase 4 если ключ есть | | `EXA_API_KEY` | Exa.ai | второй поисковый движок, обязателен для одной оси Phase 4 если ключ есть | +| `SERPBASE_API_KEY` | SerpBase (Google SERP API) | второй поисковый движок — реальный Google organic через REST, обязателен для одной оси Phase 4 если ключ есть | | `SERPAPI_KEY` | SerpAPI | **не используется** — платный, без free tier | | `NEWSAPI_KEY` | NewsAPI | для news | | `ALPHA_VANTAGE_KEY` | Alpha Vantage | для stock prices | diff --git a/references/source_dispatch.md b/references/source_dispatch.md index 87b0d63..e0cefaa 100644 --- a/references/source_dispatch.md +++ b/references/source_dispatch.md @@ -40,7 +40,7 @@ | «job market / hiring X» | `data-statistical-gov` (BLS, Eurostat labor) | `competitive-signals` (LinkedIn — HTML, Glassdoor) | `industry-reports` (HR firms — Korn Ferry, Mercer) | | «philosophical / qualitative / framework» | `academic` + `web-general` (long-form essays — substack, blogs, books online) | `forum-discussion` (LessWrong, Marginal Revolution, philosopher's stone) | own community archives | -**Второй поисковый движок как отдельная ось.** Если в env есть `BRAVE_SEARCH_API_KEY` / `TAVILY_API_KEY` / `EXA_API_KEY` (см. `capability_discovery.md`) — независимо от строки матрицы добавь ось `search-engine-2` через `scripts/search_query.py --engine --query "..."`, помимо `web-general`/WebSearch харнесса. Это не замена Primary/Secondary из матрицы, а дополнительная независимая траектория поиска; пересечение с WebSearch пишется в `plan.md` §15 как `overlap_rate`. +**Второй поисковый движок как отдельная ось.** Если в env есть `BRAVE_SEARCH_API_KEY` / `TAVILY_API_KEY` / `EXA_API_KEY` / `SERPBASE_API_KEY` (см. `capability_discovery.md`) — независимо от строки матрицы добавь ось `search-engine-2` через `scripts/search_query.py --engine --query "..."`, помимо `web-general`/WebSearch харнесса. Это не замена Primary/Secondary из матрицы, а дополнительная независимая траектория поиска; пересечение с WebSearch пишется в `plan.md` §15 как `overlap_rate`. **Если подвопрос не матчится** ни одну строку — действуй по умолчанию: `web-general` + `academic` + `news-current` минимум. И **сразу же запиши в plan.md секцию 12, что dispatch был ad-hoc** — это сигнал для Phase 6 (adversarial) что покрытие могло быть слабым. diff --git a/runner/capabilities.py b/runner/capabilities.py index f9ec750..6770420 100644 --- a/runner/capabilities.py +++ b/runner/capabilities.py @@ -7,17 +7,20 @@ from __future__ import annotations -# 17 known API-key env vars from references/capability_discovery.md. -# BRAVE_SEARCH_API_KEY / TAVILY_API_KEY / EXA_API_KEY are included: each is a -# second, independent search engine (own index / own ranking), not a rebrand of -# harness WebSearch — a second trajectory makes overlap_rate between engines -# measurable (see references/capability_discovery.md, references/source_dispatch.md). +# 18 known API-key env vars from references/capability_discovery.md. +# BRAVE_SEARCH_API_KEY / TAVILY_API_KEY / EXA_API_KEY / SERPBASE_API_KEY are +# included: each is a second, independent search engine (own index / own +# ranking; SerpBase is real Google organic results over a REST API), not a +# rebrand of harness WebSearch — a second trajectory makes overlap_rate between +# engines measurable (see references/capability_discovery.md, +# references/source_dispatch.md). # SERPAPI_KEY stays excluded: no free tier, so auditing it would advertise # coverage most users can't actually use. KNOWN_KEYS: tuple[str, ...] = ( "BRAVE_SEARCH_API_KEY", "TAVILY_API_KEY", "EXA_API_KEY", + "SERPBASE_API_KEY", "FRED_API_KEY", "GITHUB_TOKEN", "NEWSAPI_KEY", diff --git a/scripts/README.md b/scripts/README.md index ec84fa4..408b5af 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -16,10 +16,10 @@ never stopping early; exit 1 if any step failed. `--offline` skips the liveness `references/source_dispatch.md`): ```bash -python3 scripts/search_query.py --engine {brave,tavily,exa} --query "..." [--n 10] [--json] +python3 scripts/search_query.py --engine {brave,tavily,exa,serpbase} --query "..." [--n 10] [--json] ``` -Ключ читается из env: `BRAVE_SEARCH_API_KEY`, `TAVILY_API_KEY`, `EXA_API_KEY`. Без +Ключ читается из env: `BRAVE_SEARCH_API_KEY`, `TAVILY_API_KEY`, `EXA_API_KEY`, `SERPBASE_API_KEY`. Без ключа — exit 2 с именем переменной. HTTP-ошибка — exit 1. Один ретрай при обрыве транспорта, timeout 20s. Без `--json` печатает `NN title — url` построчно; `--json` — нормализованный список `{rank, title, url, snippet, engine, fetched_at}`. diff --git a/scripts/search_query.py b/scripts/search_query.py index f3c1ef1..d131ad4 100644 --- a/scripts/search_query.py +++ b/scripts/search_query.py @@ -1,15 +1,17 @@ #!/usr/bin/env python3 -"""Query a real search-engine API directly (Brave / Tavily / Exa), bypassing the -harness's own WebSearch. Purpose: a second, independent search trajectory — see -references/capability_discovery.md and references/source_dispatch.md for when -Phase 4 must use this instead of (or alongside) WebSearch. +"""Query a real search-engine API directly (Brave / Tavily / Exa / SerpBase), +bypassing the harness's own WebSearch. Purpose: a second, independent search +trajectory — see references/capability_discovery.md and references/source_dispatch.md +for when Phase 4 must use this instead of (or alongside) WebSearch. Usage: python3 scripts/search_query.py --engine brave --query "..." [--n 10] [--json] python3 scripts/search_query.py --engine tavily --query "..." python3 scripts/search_query.py --engine exa --query "..." + python3 scripts/search_query.py --engine serpbase --query "..." [--n 10] [--json] -Reads the API key from env: BRAVE_SEARCH_API_KEY, TAVILY_API_KEY, EXA_API_KEY. +Reads the API key from env: BRAVE_SEARCH_API_KEY, TAVILY_API_KEY, EXA_API_KEY, +SERPBASE_API_KEY. Exit codes: 0 ok, 1 request/HTTP error, 2 missing API key, 3 bad arguments. """ @@ -26,6 +28,7 @@ "brave": "BRAVE_SEARCH_API_KEY", "tavily": "TAVILY_API_KEY", "exa": "EXA_API_KEY", + "serpbase": "SERPBASE_API_KEY", } TIMEOUT = 20 @@ -82,10 +85,23 @@ def _request_exa(query: str, n: int, api_key: str) -> dict: } +def _request_serpbase(query: str, n: int, api_key: str) -> dict: + return { + "method": "GET", + "url": "https://api.serpbase.dev/google/search", + "headers": { + "Accept": "application/json", + "X-API-Key": api_key, + }, + "params": {"q": query, "num": n}, + } + + BUILDERS = { "brave": _request_brave, "tavily": _request_tavily, "exa": _request_exa, + "serpbase": _request_serpbase, } @@ -129,10 +145,23 @@ def _normalize_exa(payload: dict) -> list[dict]: ] +def _normalize_serpbase(payload: dict) -> list[dict]: + results = payload.get("organic", []) or [] + return [ + { + "title": r.get("title", ""), + "url": r.get("link", ""), + "snippet": r.get("snippet", ""), + } + for r in results + ] + + NORMALIZERS = { "brave": _normalize_brave, "tavily": _normalize_tavily, "exa": _normalize_exa, + "serpbase": _normalize_serpbase, } diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index a7316b2..0156b54 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -21,7 +21,7 @@ def test_audit_env_empty_env_all_absent(): def test_audit_env_covers_all_known_keys(): audit = audit_env({}) assert {a["key"] for a in audit} == set(KNOWN_KEYS) - assert len(KNOWN_KEYS) == 17 + assert len(KNOWN_KEYS) == 18 def test_audit_env_empty_string_is_absent(): diff --git a/tests/test_docs_swarm.py b/tests/test_docs_swarm.py index 88f2c26..490f626 100644 --- a/tests/test_docs_swarm.py +++ b/tests/test_docs_swarm.py @@ -10,9 +10,14 @@ SWARM_MD = ROOT / "references" / "swarm_postprocess.md" # SERPAPI has no free tier and is the only search key the skill still does not call. -# BRAVE/TAVILY/EXA became the second search engine (scripts/search_query.py). +# BRAVE/TAVILY/EXA/SERPBASE became the second search engine (scripts/search_query.py). DEAD_SEARCH_KEYS = ("SERPAPI_KEY",) -LIVE_SEARCH_KEYS = ("BRAVE_SEARCH_API_KEY", "TAVILY_API_KEY", "EXA_API_KEY") +LIVE_SEARCH_KEYS = ( + "BRAVE_SEARCH_API_KEY", + "TAVILY_API_KEY", + "EXA_API_KEY", + "SERPBASE_API_KEY", +) def test_dispatch_documents_qclass_field(): diff --git a/tests/test_search_query.py b/tests/test_search_query.py index 3d22ccf..1caeaea 100644 --- a/tests/test_search_query.py +++ b/tests/test_search_query.py @@ -95,6 +95,38 @@ def fake_request(method, timeout=None, **kwargs): assert hits[0]["title"] == "T3" +def test_serpbase_request_shape(monkeypatch): + captured = {} + + def fake_request(method, timeout=None, **kwargs): + captured["method"] = method + captured.update(kwargs) + return FakeResponse( + { + "organic": [ + { + "title": "T4", + "link": "https://example.com/page", + "snippet": "S4", + } + ] + } + ) + + monkeypatch.setattr(sq.requests, "request", fake_request) + hits = sq.run_query("serpbase", "google organic", 10, {"SERPBASE_API_KEY": "sb-key"}) + + assert captured["method"] == "GET" + assert captured["url"] == "https://api.serpbase.dev/google/search" + assert captured["headers"]["X-API-Key"] == "sb-key" + assert captured["params"]["q"] == "google organic" + assert captured["params"]["num"] == 10 + assert hits[0]["title"] == "T4" + assert hits[0]["url"] == "https://example.com/page" + assert hits[0]["snippet"] == "S4" + assert hits[0]["engine"] == "serpbase" + + def test_http_error_exits_1(monkeypatch): def fake_request(method, timeout=None, **kwargs): return FakeResponse({}, status=500)