Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions references/api_sources/search/serpbase.md
Original file line number Diff line number Diff line change
@@ -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 через харнесс
3 changes: 2 additions & 1 deletion references/capability_discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 фазы

Expand All @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion references/source_dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <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) что покрытие могло быть слабым.

Expand Down
13 changes: 8 additions & 5 deletions runner/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`.
Expand Down
39 changes: 34 additions & 5 deletions scripts/search_query.py
Original file line number Diff line number Diff line change
@@ -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.
"""

Expand All @@ -26,6 +28,7 @@
"brave": "BRAVE_SEARCH_API_KEY",
"tavily": "TAVILY_API_KEY",
"exa": "EXA_API_KEY",
"serpbase": "SERPBASE_API_KEY",
}

TIMEOUT = 20
Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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,
}


Expand Down
2 changes: 1 addition & 1 deletion tests/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
9 changes: 7 additions & 2 deletions tests/test_docs_swarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
32 changes: 32 additions & 0 deletions tests/test_search_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down