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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,23 @@ Then your agent can call `deep_research` and read bot-walled sources directly. P
| Flag | Default | Notes |
| --- | --- | --- |
| `--depth` | `detailed` | `quick` / `detailed` / `report` |
| `--engine` | `duckduckgo` | `searxng` / `auto` |
| `--engine` | `duckduckgo` | `searxng` / `tavily` / `auto` |
| `--stealth` | `auto` | `always` / `off` |
| `--provider` / `--model` | auto-detected | `OPENAI` → `ANTHROPIC` → `GEMINI`, or `ollama` |
| `--respect-robots` | off | honor robots.txt |
| `--proxy` | — | SOCKS5 for the Stealth Fetch |

### Tavily search engine

To use [Tavily](https://tavily.com) as the search backend:

```bash
export TAVILY_API_KEY=tvly-...
deepcloak "your query" --engine tavily
```

Tavily is an optional dependency — install with `pip install deepcloak[tavily]`.

## ⚠️ Responsible use

DeepCloak Bypasses bot-detection. **You are responsible for having the right to access whatever you fetch.** robots.txt is **ignored by default**; pass `--respect-robots` to honor it ([ADR-0002](docs/adr/0002-ignore-robots-by-default.md)). Don't use it to violate sites' terms or the law.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies = [

[project.optional-dependencies]
mcp = ["mcp>=1.0"]
tavily = ["tavily-python>=0.5"]
dev = ["pytest>=8.0", "ruff>=0.6"]

[project.scripts]
Expand Down
2 changes: 1 addition & 1 deletion src/deepcloak/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--version", action="version", version=f"deepcloak {__version__}")
p.add_argument("query", nargs="?", help="The research question.")
p.add_argument("--depth", choices=["quick", "detailed", "report"], default="detailed")
p.add_argument("--engine", choices=["duckduckgo", "searxng", "auto"], default=None)
p.add_argument("--engine", choices=["duckduckgo", "searxng", "tavily", "auto"], default=None)
p.add_argument("--searxng-url", dest="searxng_url", default=None,
help="SearXNG instance URL (for --engine searxng)")
p.add_argument("--stealth", choices=["auto", "always", "off"], default=None)
Expand Down
10 changes: 8 additions & 2 deletions src/deepcloak/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class ConfigError(Exception):

_VALID_STEALTH = {"auto", "always", "off"}
_VALID_DEPTH = {"quick", "detailed", "report"}
_VALID_ENGINE = {"duckduckgo", "searxng", "auto"}
_VALID_ENGINE = {"duckduckgo", "searxng", "tavily", "auto"}

_DEFAULT_MODEL = {
"openai": "gpt-4.1",
Expand Down Expand Up @@ -63,6 +63,7 @@ class Settings:
out: str | None
proxy: str | None
searxng_url: str | None
tavily_api_key: str | None = None
base_url: str | None = None # for provider "openai-endpoint" (local OpenAI-compatible)

def to_ldr_env(self) -> dict[str, str]:
Expand Down Expand Up @@ -96,7 +97,7 @@ def to_ldr_overrides(self) -> dict:
o: dict[str, object] = {
"llm.provider": _LDR_PROVIDER[self.provider],
"search.snippets_only": False,
"search.tool": self.search_engine,
"search.tool": self.search_engine if self.search_engine in {"duckduckgo", "searxng"} else "duckduckgo",
}
if self.model:
o["llm.model"] = self.model
Expand Down Expand Up @@ -146,6 +147,10 @@ def resolve(cli: Mapping, env: Mapping) -> Settings:
"LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_INSTANCE_URL"
)

tavily_api_key = env.get("TAVILY_API_KEY")
if search_engine == "tavily" and not tavily_api_key:
raise ConfigError("--engine tavily requires TAVILY_API_KEY to be set.")

base_url = cli.get("base_url") or env.get("LDR_LLM_OPENAI_ENDPOINT_URL")
if provider == "openai-endpoint" and not base_url:
raise ConfigError(
Expand All @@ -164,5 +169,6 @@ def resolve(cli: Mapping, env: Mapping) -> Settings:
out=cli.get("out"),
proxy=cli.get("proxy"),
searxng_url=searxng_url,
tavily_api_key=tavily_api_key,
base_url=base_url,
)
24 changes: 23 additions & 1 deletion src/deepcloak/research_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,29 @@ def _run_ldr(
# full pages we fetched through the stealth path (Bypassing walls),
# instead of search snippets. This is what makes a research run actually
# read bot-walled sources.
if settings.searxng_url:
if settings.tavily_api_key and settings.search_engine == "tavily":
try:
from functools import partial

from tavily import TavilyClient

from .retriever import build_stealth_retriever, tavily_search

tavily_client = TavilyClient(api_key=settings.tavily_api_key)
fn_kwargs["retrievers"] = {
"stealth": build_stealth_retriever(
search_fn=partial(tavily_search, tavily_client),
mode=settings.stealth_mode,
evidence_log=evidence_log,
on_event=on_event,
respect_robots=settings.respect_robots,
proxy=settings.proxy,
)
}
overrides["search.tool"] = "stealth"
except Exception:
pass
elif settings.searxng_url:
try:
from .retriever import build_stealth_retriever

Expand Down
33 changes: 29 additions & 4 deletions src/deepcloak/retriever.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from .fetch_router import fetch
from .stealth_downloader import plain_get, stealth_get

__all__ = ["build_stealth_retriever", "searxng_search"]
__all__ = ["build_stealth_retriever", "searxng_search", "tavily_search"]


def searxng_search(base_url: str, query: str, max_results: int = 8) -> list[dict]:
Expand All @@ -39,6 +39,21 @@ def searxng_search(base_url: str, query: str, max_results: int = 8) -> list[dict
return out


def tavily_search(client: Any, query: str, max_results: int = 8) -> list[dict]:
"""Query Tavily and return [{url, title}] hits.

``client`` is a pre-constructed ``TavilyClient`` instance — construct it
once in the caller and pass via ``functools.partial``.
"""
response = client.search(query=query, max_results=max_results)
out: list[dict] = []
for item in response.get("results", []):
url = item.get("url")
if url:
out.append({"url": url, "title": item.get("title", "")})
return out


def _cap_content(text: str, max_chars: int | None) -> str:
"""Cap a document's text so many Bypassed pages still fit a model's context.

Expand Down Expand Up @@ -68,7 +83,8 @@ def _extract_text(html: str) -> str:

def build_stealth_retriever(
*,
searxng_url: str,
searxng_url: str | None = None,
search_fn: Any = None,
mode: str = "auto",
max_results: int = 8,
max_chars: int = 2000,
Expand All @@ -78,19 +94,28 @@ def build_stealth_retriever(
robots_ok: Any = None,
proxy: str | None = None,
):
"""Construct a LangChain BaseRetriever backed by the stealth fetch path."""
"""Construct a LangChain BaseRetriever backed by the stealth fetch path.

``search_fn`` is a callable ``(query, max_results) -> [{url, title}]``.
When omitted, defaults to ``searxng_search`` bound to *searxng_url*.
"""
from functools import partial

from langchain_core.retrievers import BaseRetriever, Document # type: ignore

if search_fn is None:
if searxng_url is None:
raise ValueError("Either search_fn or searxng_url must be provided")
search_fn = partial(searxng_search, searxng_url)

stealth_fetch = partial(stealth_get, proxy=proxy)

class StealthRetriever(BaseRetriever):
model_config = {"arbitrary_types_allowed": True}

def _get_relevant_documents(self, query: str, *, run_manager=None): # noqa: D401
docs = []
for hit in searxng_search(searxng_url, query, max_results):
for hit in search_fn(query, max_results):
url = hit["url"]
result = fetch(
url,
Expand Down
26 changes: 26 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,32 @@ def test_openai_endpoint_needs_no_key_and_maps_env():
assert env["LDR_LLM_MODEL"] == "qwen"


def test_tavily_engine_reads_api_key_from_env():
s = resolve(
cli={"engine": "tavily"},
env={"OPENAI_API_KEY": "x", "TAVILY_API_KEY": "tvly-test"},
)
assert s.search_engine == "tavily"
assert s.tavily_api_key == "tvly-test"


def test_tavily_engine_without_key_raises():
with pytest.raises(ConfigError) as exc:
resolve(cli={"engine": "tavily"}, env={"OPENAI_API_KEY": "x"})
assert "TAVILY_API_KEY" in str(exc.value)


def test_tavily_to_ldr_overrides_maps_to_safe_default():
"""When search_engine is 'tavily', to_ldr_overrides() should emit a
LDR-known search.tool (duckduckgo), not the raw 'tavily' string."""
s = resolve(
cli={"engine": "tavily"},
env={"OPENAI_API_KEY": "x", "TAVILY_API_KEY": "tvly-test"},
)
overrides = s.to_ldr_overrides()
assert overrides["search.tool"] == "duckduckgo"


def test_openai_endpoint_base_url_from_env():
s = resolve(
cli={"provider": "openai-endpoint"},
Expand Down
37 changes: 36 additions & 1 deletion tests/test_retriever.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
a 16k-context local model overflows before it can write the report.
"""

from deepcloak.retriever import _cap_content
from unittest.mock import MagicMock

from deepcloak.retriever import _cap_content, tavily_search


def test_long_content_is_capped_to_max_chars():
Expand All @@ -23,3 +25,36 @@ def test_zero_or_none_disables_the_cap():
text = "y" * 10_000
assert _cap_content(text, 0) == text
assert _cap_content(text, None) == text


def test_tavily_search_returns_url_title_hits():
mock_client = MagicMock()
mock_client.search.return_value = {
"results": [
{"url": "https://a.com", "title": "A"},
{"url": "https://b.com", "title": "B"},
]
}
hits = tavily_search(mock_client, "test query", max_results=5)
mock_client.search.assert_called_once_with(query="test query", max_results=5)
assert hits == [
{"url": "https://a.com", "title": "A"},
{"url": "https://b.com", "title": "B"},
]


def test_tavily_search_filters_items_missing_url():
mock_client = MagicMock()
mock_client.search.return_value = {
"results": [
{"url": "https://a.com", "title": "A"},
{"title": "No URL"},
{"url": "", "title": "Empty URL"},
{"url": "https://c.com", "title": "C"},
]
}
hits = tavily_search(mock_client, "q")
assert hits == [
{"url": "https://a.com", "title": "A"},
{"url": "https://c.com", "title": "C"},
]