deepcloak 0.1.0__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.
deepcloak/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """DeepCloak โ€” local-first deep research that reads bot-walled pages."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,68 @@
1
+ """Detect a Bot Wall in a plain HTTP response.
2
+
3
+ Pure module: ``classify()`` takes a status code, headers, and body and returns a
4
+ ``BotWall`` (the kind + the signal that triggered it) or ``None``. A detected
5
+ Bot Wall is what triggers an Escalation in ``fetch_router``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ __all__ = ["BotWall", "classify", "from_response"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class BotWall:
19
+ kind: str # cloudflare | datadome | turnstile | recaptcha | rate_limit | blocked
20
+ signal: str # human-readable reason, recorded in the Evidence Record
21
+
22
+
23
+ # Vendor markers checked in the body (lower-cased), strongest signal first.
24
+ _TURNSTILE = ("challenges.cloudflare.com/turnstile", "cf-turnstile")
25
+ _DATADOME = ("captcha-delivery.com", "protected by datadome", "geo.captcha-delivery")
26
+ _RECAPTCHA = ("g-recaptcha", "recaptcha/api.js", "google.com/recaptcha")
27
+ _CLOUDFLARE = (
28
+ "just a moment",
29
+ "checking your browser",
30
+ "/cdn-cgi/challenge-platform",
31
+ "attention required! | cloudflare",
32
+ )
33
+
34
+
35
+ def classify(status_code: int, headers: Mapping[str, str], body: str) -> BotWall | None:
36
+ body_l = (body or "").lower()
37
+ hdr_l = {str(k).lower(): str(v).lower() for k, v in dict(headers or {}).items()}
38
+
39
+ if any(m in body_l for m in _TURNSTILE):
40
+ return BotWall("turnstile", "Cloudflare Turnstile widget present")
41
+ if "datadome" in hdr_l.get("set-cookie", "") or "x-datadome" in hdr_l or any(
42
+ m in body_l for m in _DATADOME
43
+ ):
44
+ return BotWall("datadome", "DataDome challenge present")
45
+ if any(m in body_l for m in _RECAPTCHA):
46
+ return BotWall("recaptcha", "reCAPTCHA widget present")
47
+ if any(m in body_l for m in _CLOUDFLARE):
48
+ return BotWall("cloudflare", "Cloudflare interstitial challenge")
49
+ if hdr_l.get("cf-mitigated") == "challenge":
50
+ return BotWall("cloudflare", "cf-mitigated: challenge header")
51
+
52
+ if status_code == 429:
53
+ return BotWall("rate_limit", "HTTP 429 Too Many Requests")
54
+ if status_code == 503 and ("cf-ray" in hdr_l or "cloudflare" in body_l):
55
+ return BotWall("cloudflare", "HTTP 503 from Cloudflare")
56
+ if status_code == 403:
57
+ return BotWall("blocked", "HTTP 403 Forbidden")
58
+
59
+ return None
60
+
61
+
62
+ def from_response(resp: Any) -> BotWall | None:
63
+ """Convenience wrapper for a requests-style response object."""
64
+ return classify(
65
+ status_code=getattr(resp, "status_code", 0),
66
+ headers=getattr(resp, "headers", {}) or {},
67
+ body=getattr(resp, "text", "") or "",
68
+ )
deepcloak/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ """DeepCloak command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import __version__
9
+
10
+ __all__ = ["main", "build_parser"]
11
+
12
+ _SUBCOMMANDS = ("setup", "mcp")
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ p = argparse.ArgumentParser(
17
+ prog="deepcloak",
18
+ description="Deep research that reads bot-walled pages.",
19
+ epilog="Subcommands: 'deepcloak setup' (install stealth browser), "
20
+ "'deepcloak mcp' (run MCP server).",
21
+ )
22
+ p.add_argument("--version", action="version", version=f"deepcloak {__version__}")
23
+ p.add_argument("query", nargs="?", help="The research question.")
24
+ p.add_argument("--depth", choices=["quick", "detailed", "report"], default="detailed")
25
+ p.add_argument("--engine", choices=["duckduckgo", "searxng", "auto"], default=None)
26
+ p.add_argument("--searxng-url", dest="searxng_url", default=None,
27
+ help="SearXNG instance URL (for --engine searxng)")
28
+ p.add_argument("--stealth", choices=["auto", "always", "off"], default=None)
29
+ p.add_argument("--respect-robots", action="store_true", dest="respect_robots")
30
+ p.add_argument("--proxy", default=None)
31
+ p.add_argument("--provider", default=None,
32
+ help="openai/anthropic/gemini/ollama/openai-endpoint")
33
+ p.add_argument("--model", default=None)
34
+ p.add_argument("--base-url", dest="base_url", default=None,
35
+ help="OpenAI-compatible URL (for openai-endpoint provider)")
36
+ p.add_argument("--out", default=None, help="Write the report to this file.")
37
+ return p
38
+
39
+
40
+ def _cli_dict(args: argparse.Namespace) -> dict:
41
+ keys = ("depth", "engine", "stealth", "respect_robots", "proxy",
42
+ "provider", "model", "base_url", "searxng_url", "out")
43
+ return {k: getattr(args, k) for k in keys if getattr(args, k) not in (None, False)}
44
+
45
+
46
+ def main(argv: list[str] | None = None) -> int:
47
+ argv = sys.argv[1:] if argv is None else list(argv)
48
+
49
+ # Subcommands are dispatched manually so the default `deepcloak "<query>"`
50
+ # form doesn't collide with an argparse subparser positional.
51
+ if argv and argv[0] == "setup":
52
+ from .setup import run_setup
53
+
54
+ return run_setup()
55
+ if argv and argv[0] == "mcp":
56
+ from .mcp_server import serve
57
+
58
+ serve()
59
+ return 0
60
+
61
+ args = build_parser().parse_args(argv)
62
+ if not args.query:
63
+ build_parser().print_help()
64
+ return 2
65
+
66
+ from .research_core import research
67
+
68
+ try:
69
+ result = research(args.query, cli=_cli_dict(args), verbose=True)
70
+ except Exception as exc: # surface a clean message, not a traceback
71
+ print(f"error: {exc}", file=sys.stderr)
72
+ return 1
73
+
74
+ if args.out:
75
+ with open(args.out, "w", encoding="utf-8") as fh:
76
+ fh.write(result.report)
77
+ sidecar = f"{args.out}.evidence.json"
78
+ with open(sidecar, "w", encoding="utf-8") as fh:
79
+ fh.write(result.evidence_json)
80
+ print(f"wrote {args.out} (+ {sidecar})")
81
+ else:
82
+ print(result.report)
83
+ return 0
84
+
85
+
86
+ if __name__ == "__main__": # pragma: no cover
87
+ raise SystemExit(main())
deepcloak/config.py ADDED
@@ -0,0 +1,168 @@
1
+ """Resolve runtime settings from CLI args + environment.
2
+
3
+ Pure module: ``resolve()`` takes plain mappings and returns a frozen ``Settings``.
4
+ No I/O, no imports of the heavy upstreams โ€” so it is trivially testable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+
12
+ __all__ = ["ConfigError", "Settings", "resolve"]
13
+
14
+
15
+ class ConfigError(Exception):
16
+ """Raised when settings cannot be resolved (e.g. no LLM credentials)."""
17
+
18
+
19
+ # Provider auto-detection precedence: first env var present wins.
20
+ _PROVIDER_ENV: list[tuple[str, str]] = [
21
+ ("openai", "OPENAI_API_KEY"),
22
+ ("anthropic", "ANTHROPIC_API_KEY"),
23
+ ("gemini", "GEMINI_API_KEY"),
24
+ ("openrouter", "OPENROUTER_API_KEY"),
25
+ ]
26
+ _PROVIDER_KEY = dict(_PROVIDER_ENV)
27
+ # Providers that run locally and need no API key.
28
+ _KEYLESS_PROVIDERS = {"ollama", "openai-endpoint"}
29
+
30
+ _VALID_STEALTH = {"auto", "always", "off"}
31
+ _VALID_DEPTH = {"quick", "detailed", "report"}
32
+ _VALID_ENGINE = {"duckduckgo", "searxng", "auto"}
33
+
34
+ _DEFAULT_MODEL = {
35
+ "openai": "gpt-4.1",
36
+ "anthropic": "claude-sonnet-4-6",
37
+ "gemini": "gemini-2.5-pro",
38
+ "openrouter": "openai/gpt-4.1",
39
+ "ollama": "llama3.1",
40
+ "openai-endpoint": None, # local OpenAI-compatible server โ€” model is server-defined
41
+ }
42
+
43
+ # Our provider name -> the value local-deep-research expects in LDR_LLM_PROVIDER.
44
+ _LDR_PROVIDER = {
45
+ "openai": "openai",
46
+ "anthropic": "anthropic",
47
+ "gemini": "gemini",
48
+ "openrouter": "openai_endpoint",
49
+ "ollama": "ollama",
50
+ "openai-endpoint": "openai_endpoint",
51
+ }
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Settings:
56
+ provider: str
57
+ model: str | None
58
+ api_key: str | None
59
+ search_engine: str
60
+ stealth_mode: str
61
+ depth: str
62
+ respect_robots: bool
63
+ out: str | None
64
+ proxy: str | None
65
+ searxng_url: str | None
66
+ base_url: str | None = None # for provider "openai-endpoint" (local OpenAI-compatible)
67
+
68
+ def to_ldr_env(self) -> dict[str, str]:
69
+ """Map resolved settings to the LDR_* environment variables LDR reads."""
70
+ env: dict[str, str] = {
71
+ "LDR_LLM_PROVIDER": _LDR_PROVIDER[self.provider],
72
+ # DeepCloak's whole point is reading full pages (and Bypassing walls to
73
+ # do it), so we always fetch full content โ€” never snippet-only.
74
+ "LDR_SEARCH_SNIPPETS_ONLY": "false",
75
+ }
76
+ if self.model:
77
+ env["LDR_LLM_MODEL"] = self.model
78
+ if self.provider == "openai-endpoint":
79
+ if self.base_url:
80
+ env["LDR_LLM_OPENAI_ENDPOINT_URL"] = self.base_url
81
+ # llama.cpp / vLLM ignore the key, but LDR requires a non-empty one.
82
+ env["LDR_LLM_OPENAI_ENDPOINT_API_KEY"] = self.api_key or "local"
83
+ # Pass the credential through under its standard name (langchain reads these).
84
+ if self.api_key and self.provider in _PROVIDER_KEY:
85
+ env[_PROVIDER_KEY[self.provider]] = self.api_key
86
+ if self.search_engine == "searxng" and self.searxng_url:
87
+ env["LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_INSTANCE_URL"] = self.searxng_url
88
+ return env
89
+
90
+ def to_ldr_overrides(self) -> dict:
91
+ """Settings as an LDR settings-snapshot override dict (the supported API path).
92
+
93
+ Critically sets ``search.snippets_only=False`` so the research loop fetches
94
+ full pages โ€” which is what routes through the stealth shim and Bypasses walls.
95
+ """
96
+ o: dict[str, object] = {
97
+ "llm.provider": _LDR_PROVIDER[self.provider],
98
+ "search.snippets_only": False,
99
+ "search.tool": self.search_engine,
100
+ }
101
+ if self.model:
102
+ o["llm.model"] = self.model
103
+ if self.provider == "openai-endpoint":
104
+ if self.base_url:
105
+ o["llm.openai_endpoint.url"] = self.base_url
106
+ o["llm.openai_endpoint.api_key"] = self.api_key or "local"
107
+ elif self.api_key and self.provider in _PROVIDER_KEY:
108
+ o[f"llm.{self.provider}.api_key"] = self.api_key
109
+ if self.search_engine == "searxng" and self.searxng_url:
110
+ o["search.engine.web.searxng.default_params.instance_url"] = self.searxng_url
111
+ return o
112
+
113
+
114
+ def _resolve_provider(cli: Mapping, env: Mapping) -> str:
115
+ provider = cli.get("provider")
116
+ if provider:
117
+ return provider
118
+ for name, key in _PROVIDER_ENV:
119
+ if env.get(key):
120
+ return name
121
+ wanted = ", ".join(key for _, key in _PROVIDER_ENV)
122
+ raise ConfigError(
123
+ "No LLM credentials found. Set one of: "
124
+ f"{wanted} (or pass --provider ollama for a local model)."
125
+ )
126
+
127
+
128
+ def _validate(value: str, valid: set[str], label: str) -> str:
129
+ if value not in valid:
130
+ raise ConfigError(f"Invalid {label}: {value!r}. Choose one of {sorted(valid)}.")
131
+ return value
132
+
133
+
134
+ def resolve(cli: Mapping, env: Mapping) -> Settings:
135
+ """Resolve effective settings. Precedence: CLI flags > environment."""
136
+ provider = _resolve_provider(cli, env)
137
+ api_key = None
138
+ if provider not in _KEYLESS_PROVIDERS:
139
+ api_key = env.get(_PROVIDER_KEY.get(provider, ""))
140
+
141
+ model = cli.get("model") or env.get("DEEPCLOAK_MODEL") or _DEFAULT_MODEL.get(provider)
142
+ search_engine = _validate(cli.get("engine") or "duckduckgo", _VALID_ENGINE, "search engine")
143
+ stealth_mode = _validate(cli.get("stealth") or "auto", _VALID_STEALTH, "stealth mode")
144
+ depth = _validate(cli.get("depth") or "detailed", _VALID_DEPTH, "depth")
145
+ searxng_url = cli.get("searxng_url") or env.get(
146
+ "LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_INSTANCE_URL"
147
+ )
148
+
149
+ base_url = cli.get("base_url") or env.get("LDR_LLM_OPENAI_ENDPOINT_URL")
150
+ if provider == "openai-endpoint" and not base_url:
151
+ raise ConfigError(
152
+ "provider 'openai-endpoint' needs --base-url (e.g. http://localhost:8080/v1) "
153
+ "or LDR_LLM_OPENAI_ENDPOINT_URL."
154
+ )
155
+
156
+ return Settings(
157
+ provider=provider,
158
+ model=model,
159
+ api_key=api_key,
160
+ search_engine=search_engine,
161
+ stealth_mode=stealth_mode,
162
+ depth=depth,
163
+ respect_robots=bool(cli.get("respect_robots", False)),
164
+ out=cli.get("out"),
165
+ proxy=cli.get("proxy"),
166
+ searxng_url=searxng_url,
167
+ base_url=base_url,
168
+ )
deepcloak/evidence.py ADDED
@@ -0,0 +1,67 @@
1
+ """Evidence Records โ€” the per-URL facts captured during a research run.
2
+
3
+ Slice 3 defines the record and a collector. Slice 4 adds the report badge and
4
+ the JSON sidecar rendering on top of the same data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import asdict, dataclass, field
11
+
12
+ __all__ = ["EvidenceRecord", "EvidenceLog"]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class EvidenceRecord:
17
+ url: str
18
+ bot_wall: str | None # detected Bot Wall kind, or None
19
+ escalated: bool # did we switch plain -> Stealth Fetch?
20
+ bypassed: bool # did the Stealth Fetch succeed after Escalation?
21
+ plain_status: int | None
22
+ elapsed_ms: int
23
+ signal: str | None = None # human-readable reason
24
+
25
+ def as_dict(self) -> dict:
26
+ return asdict(self)
27
+
28
+
29
+ @dataclass
30
+ class EvidenceLog:
31
+ records: list[EvidenceRecord] = field(default_factory=list)
32
+
33
+ def add(self, record: EvidenceRecord) -> None:
34
+ self.records.append(record)
35
+
36
+ def summary(self) -> dict:
37
+ walls: dict[str, int] = {}
38
+ escalated = bypassed = 0
39
+ for r in self.records:
40
+ escalated += int(r.escalated)
41
+ bypassed += int(r.bypassed)
42
+ if r.bot_wall:
43
+ walls[r.bot_wall] = walls.get(r.bot_wall, 0) + 1
44
+ return {
45
+ "total": len(self.records),
46
+ "escalated": escalated,
47
+ "bypassed": bypassed,
48
+ "walls": walls,
49
+ }
50
+
51
+ def badge(self) -> str:
52
+ """Markdown section appended to the report. Empty when nothing was bypassed."""
53
+ s = self.summary()
54
+ if not s["bypassed"]:
55
+ return ""
56
+ plural = "s" if s["bypassed"] != 1 else ""
57
+ lines = [f"## ๐Ÿ›ก๏ธ Bypassed {s['bypassed']} bot-walled source{plural}"]
58
+ if s["walls"]:
59
+ parts = ", ".join(f"{k} ร—{v}" for k, v in sorted(s["walls"].items()))
60
+ lines += ["", f"Bot Walls encountered โ€” {parts}."]
61
+ return "\n".join(lines)
62
+
63
+ def to_json(self, indent: int = 2) -> str:
64
+ return json.dumps(
65
+ {"summary": self.summary(), "records": [r.as_dict() for r in self.records]},
66
+ indent=indent,
67
+ )
@@ -0,0 +1,109 @@
1
+ """The Escalation policy: plain fetch first, Stealth Fetch on a detected Bot Wall.
2
+
3
+ Deep module. ``fetch()`` takes injectable ``plain_fetch`` / ``stealth_fetch``
4
+ callables so the policy is fully testable with fakes โ€” no network, no browser.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from .bot_wall_detector import BotWall, from_response
15
+ from .evidence import EvidenceRecord
16
+
17
+ __all__ = ["FetchResult", "fetch"]
18
+
19
+ PlainFetch = Callable[[str], Any]
20
+ StealthFetch = Callable[[str], str]
21
+ Detector = Callable[[Any], "BotWall | None"]
22
+ RobotsOk = Callable[[str], bool]
23
+
24
+
25
+ @dataclass
26
+ class FetchResult:
27
+ content: str | None
28
+ evidence: EvidenceRecord
29
+
30
+
31
+ def fetch(
32
+ url: str,
33
+ *,
34
+ mode: str = "auto",
35
+ plain_fetch: PlainFetch,
36
+ stealth_fetch: StealthFetch,
37
+ detector: Detector = from_response,
38
+ respect_robots: bool = False,
39
+ robots_ok: RobotsOk | None = None,
40
+ ) -> FetchResult:
41
+ start = time.monotonic()
42
+
43
+ def ms() -> int:
44
+ return int((time.monotonic() - start) * 1000)
45
+
46
+ def record(**kw) -> EvidenceRecord:
47
+ kw.setdefault("plain_status", None)
48
+ return EvidenceRecord(url=url, elapsed_ms=ms(), **kw)
49
+
50
+ if respect_robots and robots_ok is not None and not robots_ok(url):
51
+ return FetchResult(
52
+ None,
53
+ record(bot_wall=None, escalated=False, bypassed=False,
54
+ signal="skipped: robots.txt disallow"),
55
+ )
56
+
57
+ if mode == "always":
58
+ try:
59
+ return FetchResult(
60
+ stealth_fetch(url),
61
+ record(bot_wall="forced", escalated=True, bypassed=True,
62
+ signal="stealth fetch (mode=always)"),
63
+ )
64
+ except Exception as exc:
65
+ return FetchResult(
66
+ None,
67
+ record(bot_wall="forced", escalated=True, bypassed=False,
68
+ signal=f"stealth failed: {exc}"),
69
+ )
70
+
71
+ # mode "auto" or "off": try plain first.
72
+ plain_status: int | None = None
73
+ plain_content: str | None = None
74
+ wall: BotWall | None = None
75
+ try:
76
+ resp = plain_fetch(url)
77
+ plain_status = getattr(resp, "status_code", None)
78
+ plain_content = getattr(resp, "text", None)
79
+ wall = detector(resp)
80
+ except Exception as exc:
81
+ wall = BotWall("blocked", f"plain fetch failed: {exc}")
82
+
83
+ if mode == "off":
84
+ return FetchResult(
85
+ plain_content,
86
+ record(bot_wall=wall.kind if wall else None, escalated=False, bypassed=False,
87
+ plain_status=plain_status, signal=wall.signal if wall else "plain ok"),
88
+ )
89
+
90
+ # mode "auto"
91
+ if wall is None:
92
+ return FetchResult(
93
+ plain_content,
94
+ record(bot_wall=None, escalated=False, bypassed=False,
95
+ plain_status=plain_status, signal="plain ok, no wall"),
96
+ )
97
+
98
+ try:
99
+ return FetchResult(
100
+ stealth_fetch(url),
101
+ record(bot_wall=wall.kind, escalated=True, bypassed=True, plain_status=plain_status,
102
+ signal=f"bypassed {wall.kind}: {wall.signal}"),
103
+ )
104
+ except Exception as exc:
105
+ return FetchResult(
106
+ plain_content,
107
+ record(bot_wall=wall.kind, escalated=True, bypassed=False, plain_status=plain_status,
108
+ signal=f"escalation failed: {exc}"),
109
+ )
deepcloak/ldr_shim.py ADDED
@@ -0,0 +1,115 @@
1
+ """Install the Stealth Fetch into local-deep-research (ADR-0001).
2
+
3
+ We override the narrowest seam โ€” ``AutoHTMLDownloader._fetch_html``, the method
4
+ that returns a page's raw HTML โ€” so every HTML fetch in LDR's pipeline runs
5
+ through ``fetch_router`` (plain โ†’ Escalate โ†’ Stealth Fetch) and records an
6
+ Evidence Record. LDR's own text extraction (justext) still runs on the HTML we
7
+ return, so we reuse it rather than reimplementing it.
8
+
9
+ The seam is an internal LDR symbol, so the LDR version is pinned (==) and the
10
+ seam must be re-verified on upgrade.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from functools import partial
16
+ from typing import Any
17
+ from urllib.parse import urlparse
18
+ from urllib.robotparser import RobotFileParser
19
+
20
+ from .evidence import EvidenceLog
21
+ from .fetch_router import fetch
22
+ from .stealth_downloader import plain_get, stealth_get
23
+
24
+ __all__ = ["install", "uninstall", "default_robots_ok", "make_fetch_html"]
25
+
26
+
27
+ def default_robots_ok(url: str) -> bool:
28
+ """Best-effort robots.txt check; on any error, allow (fail-open)."""
29
+ try:
30
+ parts = urlparse(url)
31
+ rp = RobotFileParser()
32
+ rp.set_url(f"{parts.scheme}://{parts.netloc}/robots.txt")
33
+ rp.read()
34
+ return rp.can_fetch("*", url)
35
+ except Exception:
36
+ return True
37
+
38
+
39
+ def _load_ldr_downloader() -> type:
40
+ from local_deep_research.research_library.downloaders.playwright_html import ( # type: ignore
41
+ AutoHTMLDownloader,
42
+ )
43
+
44
+ return AutoHTMLDownloader
45
+
46
+
47
+ def make_fetch_html(
48
+ *, mode, respect_robots, evidence_log, plain_fetch, stealth_fetch, robots_ok, on_event=None
49
+ ):
50
+ # LDR may call _fetch_html more than once per URL (download / download_with_result).
51
+ # Cache per run so we Stealth Fetch once and record exactly one Evidence Record.
52
+ cache: dict[str, str | None] = {}
53
+
54
+ def _fetch_html(self, url: str) -> str | None:
55
+ if url in cache:
56
+ return cache[url]
57
+ result = fetch(
58
+ url,
59
+ mode=mode,
60
+ plain_fetch=plain_fetch,
61
+ stealth_fetch=stealth_fetch,
62
+ respect_robots=respect_robots,
63
+ robots_ok=robots_ok,
64
+ )
65
+ evidence_log.add(result.evidence)
66
+ if on_event is not None:
67
+ on_event(result.evidence)
68
+ cache[url] = result.content
69
+ return result.content
70
+
71
+ return _fetch_html
72
+
73
+
74
+ def install(
75
+ *,
76
+ evidence_log: EvidenceLog,
77
+ mode: str = "auto",
78
+ respect_robots: bool = False,
79
+ proxy: str | None = None,
80
+ target_cls: type | None = None,
81
+ plain_fetch: Any = plain_get,
82
+ stealth_fetch: Any = None,
83
+ robots_ok: Any = None,
84
+ on_event: Any = None,
85
+ ) -> type:
86
+ """Patch LDR's HTML downloader to route through DeepCloak. Returns the class."""
87
+ cls = target_cls or _load_ldr_downloader()
88
+ if stealth_fetch is None:
89
+ stealth_fetch = partial(stealth_get, proxy=proxy)
90
+ if respect_robots and robots_ok is None:
91
+ robots_ok = default_robots_ok
92
+
93
+ if not hasattr(cls, "_deepcloak_orig_fetch_html"):
94
+ cls._deepcloak_orig_fetch_html = getattr(cls, "_fetch_html", None)
95
+ cls._fetch_html = make_fetch_html(
96
+ mode=mode,
97
+ respect_robots=respect_robots,
98
+ evidence_log=evidence_log,
99
+ plain_fetch=plain_fetch,
100
+ stealth_fetch=stealth_fetch,
101
+ robots_ok=robots_ok,
102
+ on_event=on_event,
103
+ )
104
+ return cls
105
+
106
+
107
+ def uninstall(cls: type) -> None:
108
+ """Restore the original ``_fetch_html`` (used in tests / teardown)."""
109
+ orig = getattr(cls, "_deepcloak_orig_fetch_html", None)
110
+ if orig is not None:
111
+ cls._fetch_html = orig
112
+ elif hasattr(cls, "_fetch_html"):
113
+ delattr(cls, "_fetch_html")
114
+ if hasattr(cls, "_deepcloak_orig_fetch_html"):
115
+ delattr(cls, "_deepcloak_orig_fetch_html")
@@ -0,0 +1,54 @@
1
+ """DeepCloak MCP server.
2
+
3
+ Its own entry point (not LDR's ldr-mcp) so the in-process stealth shim applies
4
+ and every research call produces a Bypass + Evidence. Tool bodies are plain
5
+ functions so they're testable without the MCP runtime installed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import itertools
11
+
12
+ __all__ = ["tool_deep_research", "tool_quick_summary", "tool_get_evidence", "build_server", "serve"]
13
+
14
+ _RUNS: dict[str, str] = {}
15
+ _COUNTER = itertools.count(1)
16
+
17
+
18
+ def tool_deep_research(query: str, depth: str = "detailed") -> str:
19
+ """Run a Deep Research with stealth fetch and return the cited report."""
20
+ from .research_core import research
21
+
22
+ result = research(query, cli={"depth": depth})
23
+ run_id = str(next(_COUNTER))
24
+ _RUNS[run_id] = result.evidence_json
25
+ _RUNS["last"] = result.evidence_json
26
+ return result.report
27
+
28
+
29
+ def tool_quick_summary(query: str) -> str:
30
+ """Fast, shallow answer for a query."""
31
+ return tool_deep_research(query, depth="quick")
32
+
33
+
34
+ def tool_get_evidence(run_id: str = "last") -> str:
35
+ """Return the Evidence Records (JSON) of a prior run; 'last' for the latest."""
36
+ return _RUNS.get(run_id, "{}")
37
+
38
+
39
+ def build_server():
40
+ """Build the FastMCP server. Requires the optional `mcp` dependency."""
41
+ try:
42
+ from mcp.server.fastmcp import FastMCP
43
+ except ImportError as exc: # pragma: no cover
44
+ raise RuntimeError("The MCP server needs `pip install deepcloak[mcp]`.") from exc
45
+
46
+ server = FastMCP("deepcloak")
47
+ server.tool(name="deep_research")(tool_deep_research)
48
+ server.tool(name="quick_summary")(tool_quick_summary)
49
+ server.tool(name="get_evidence")(tool_get_evidence)
50
+ return server
51
+
52
+
53
+ def serve() -> None: # pragma: no cover - exercised live, not in unit tests
54
+ build_server().run()
deepcloak/progress.py ADDED
@@ -0,0 +1,36 @@
1
+ """Human-readable live progress for a research run.
2
+
3
+ ``format_event`` turns one Evidence Record into a single status line; the CLI
4
+ streams these to stderr as they happen so a real run is legible.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ from .evidence import EvidenceRecord
12
+
13
+ __all__ = ["format_event", "stderr_printer"]
14
+
15
+
16
+ def _host(url: str) -> str:
17
+ try:
18
+ return url.split("/")[2] if "://" in url else url
19
+ except Exception:
20
+ return url
21
+
22
+
23
+ def format_event(rec: EvidenceRecord) -> str:
24
+ host = _host(rec.url)
25
+ ms = f"{rec.elapsed_ms} ms"
26
+ if rec.bypassed:
27
+ return f" ๐Ÿ›ก๏ธ {host} ๐Ÿงฑ {rec.bot_wall} โ†’ โœ… bypassed {ms}"
28
+ if rec.escalated:
29
+ return f" โš ๏ธ {host} ๐Ÿงฑ {rec.bot_wall} โ†’ escalation failed {ms}"
30
+ if rec.bot_wall:
31
+ return f" โ€ข {host} ๐Ÿงฑ {rec.bot_wall} (kept plain) {ms}"
32
+ return f" โœ“ {host} open page {ms}"
33
+
34
+
35
+ def stderr_printer(rec: EvidenceRecord) -> None:
36
+ print(format_event(rec), file=sys.stderr, flush=True)
@@ -0,0 +1,141 @@
1
+ """The single entry point every surface (CLI, MCP, skill) goes through.
2
+
3
+ It resolves settings, applies the LDR environment, runs the research loop, and
4
+ attaches Evidence Records. In slice 1 this is the plain path; the stealth shim
5
+ is installed here from slice 3 onward.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ from . import ldr_shim
16
+ from .config import Settings, resolve
17
+ from .evidence import EvidenceLog
18
+
19
+ __all__ = ["Result", "research"]
20
+
21
+ # Maps our depth to the LDR api.research_functions callable name.
22
+ _LDR_FUNCTION = {
23
+ "quick": "quick_summary",
24
+ "detailed": "detailed_research",
25
+ "report": "generate_report",
26
+ }
27
+
28
+
29
+ @dataclass
30
+ class Result:
31
+ report: str
32
+ settings: Settings
33
+ evidence: list[dict[str, Any]] = field(default_factory=list)
34
+ evidence_json: str = "{}"
35
+
36
+
37
+ def _run_ldr(
38
+ query: str,
39
+ settings: Settings,
40
+ evidence_log: Any = None,
41
+ on_event: Any = None,
42
+ rf: Any | None = None,
43
+ ) -> str:
44
+ """Call local-deep-research's programmatic API. Imported lazily so the
45
+ package (and its pure modules) load without the heavy upstream installed.
46
+ ``rf`` may be injected in tests."""
47
+ fn_kwargs: dict[str, Any] = {}
48
+ if rf is None:
49
+ from local_deep_research.api import research_functions as rf # type: ignore
50
+
51
+ overrides = settings.to_ldr_overrides()
52
+
53
+ # Real integration: hand LDR a StealthRetriever so it synthesises over
54
+ # full pages we fetched through the stealth path (Bypassing walls),
55
+ # instead of search snippets. This is what makes a research run actually
56
+ # read bot-walled sources.
57
+ if settings.searxng_url:
58
+ try:
59
+ from .retriever import build_stealth_retriever
60
+
61
+ fn_kwargs["retrievers"] = {
62
+ "stealth": build_stealth_retriever(
63
+ searxng_url=settings.searxng_url,
64
+ mode=settings.stealth_mode,
65
+ evidence_log=evidence_log,
66
+ on_event=on_event,
67
+ respect_robots=settings.respect_robots,
68
+ proxy=settings.proxy,
69
+ )
70
+ }
71
+ overrides["search.tool"] = "stealth"
72
+ except Exception:
73
+ pass
74
+
75
+ try:
76
+ from local_deep_research.api.settings_utils import ( # type: ignore
77
+ create_settings_snapshot,
78
+ )
79
+
80
+ fn_kwargs["settings_snapshot"] = create_settings_snapshot(overrides=overrides)
81
+ except Exception:
82
+ pass
83
+
84
+ fn = getattr(rf, _LDR_FUNCTION[settings.depth])
85
+ result = fn(query, **fn_kwargs)
86
+ # LDR functions return either a string or a dict with a summary/report field.
87
+ if isinstance(result, Mapping):
88
+ return str(result.get("summary") or result.get("report") or result)
89
+ return str(result)
90
+
91
+
92
+ def research(
93
+ query: str,
94
+ cli: Mapping | None = None,
95
+ env: Mapping | None = None,
96
+ verbose: bool = False,
97
+ ) -> Result:
98
+ """Run a Deep Research and return the report plus Evidence Records.
99
+
100
+ ``verbose=True`` streams live progress to stderr (used by the CLI)."""
101
+ import sys
102
+
103
+ settings = resolve(cli or {}, env if env is not None else os.environ)
104
+ os.environ.update(settings.to_ldr_env())
105
+
106
+ on_event = None
107
+ if verbose:
108
+ from .progress import stderr_printer as on_event
109
+
110
+ print(f"๐Ÿ”Ž researching: {query}", file=sys.stderr, flush=True)
111
+
112
+ evidence_log = EvidenceLog()
113
+ try:
114
+ ldr_shim.install(
115
+ evidence_log=evidence_log,
116
+ mode=settings.stealth_mode,
117
+ respect_robots=settings.respect_robots,
118
+ proxy=settings.proxy,
119
+ on_event=on_event,
120
+ )
121
+ except Exception:
122
+ # LDR not importable yet / seam moved โ€” proceed without stealth (degraded).
123
+ pass
124
+
125
+ report = _run_ldr(query, settings, evidence_log=evidence_log, on_event=on_event)
126
+ badge = evidence_log.badge()
127
+ if badge:
128
+ report = f"{report}\n\n{badge}"
129
+ if verbose:
130
+ s = evidence_log.summary()
131
+ print(
132
+ f"โœ… done โ€” {s['total']} sources, {s['bypassed']} bot wall(s) bypassed",
133
+ file=sys.stderr,
134
+ flush=True,
135
+ )
136
+ return Result(
137
+ report=report,
138
+ settings=settings,
139
+ evidence=[r.as_dict() for r in evidence_log.records],
140
+ evidence_json=evidence_log.to_json(),
141
+ )
deepcloak/retriever.py ADDED
@@ -0,0 +1,118 @@
1
+ """StealthRetriever โ€” the real integration seam.
2
+
3
+ A LangChain retriever that DeepCloak hands to local-deep-research's
4
+ ``retrievers=`` API. For each search hit it fetches the *full page* through the
5
+ stealth ``fetch_router`` (plain โ†’ Escalate โ†’ Stealth Fetch), so the research
6
+ loop synthesises over Bypassed content โ€” not snippets. Every fetch is recorded
7
+ as an Evidence Record.
8
+
9
+ LangChain is only imported here (it ships with local-deep-research), so the
10
+ pure modules and CI stay free of it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from .fetch_router import fetch
18
+ from .stealth_downloader import plain_get, stealth_get
19
+
20
+ __all__ = ["build_stealth_retriever", "searxng_search"]
21
+
22
+
23
+ def searxng_search(base_url: str, query: str, max_results: int = 8) -> list[dict]:
24
+ """Query a SearXNG instance and return [{url, title}] hits."""
25
+ import requests
26
+
27
+ r = requests.get(
28
+ f"{base_url.rstrip('/')}/search",
29
+ params={"q": query, "format": "json"},
30
+ timeout=20,
31
+ headers={"User-Agent": "deepcloak"},
32
+ )
33
+ r.raise_for_status()
34
+ out: list[dict] = []
35
+ for item in (r.json().get("results") or [])[:max_results]:
36
+ url = item.get("url")
37
+ if url:
38
+ out.append({"url": url, "title": item.get("title", "")})
39
+ return out
40
+
41
+
42
+ def _cap_content(text: str, max_chars: int | None) -> str:
43
+ """Cap a document's text so many Bypassed pages still fit a model's context.
44
+
45
+ A small local model (e.g. a 16k-context llama-server) overflows when the
46
+ retriever feeds full pages into the synthesis prompt, so the report step
47
+ never runs. ``max_chars`` of ``None``/``0`` disables the cap.
48
+ """
49
+ if max_chars and len(text) > max_chars:
50
+ return text[:max_chars]
51
+ return text
52
+
53
+
54
+ def _extract_text(html: str) -> str:
55
+ """Best-effort readable-text extraction (reuses LDR's extractor if present)."""
56
+ try:
57
+ from local_deep_research.research_library.downloaders.extraction import ( # type: ignore
58
+ extract_content,
59
+ )
60
+
61
+ text = extract_content(html, language="English", min_length=100)
62
+ if text:
63
+ return text
64
+ except Exception:
65
+ pass
66
+ return html
67
+
68
+
69
+ def build_stealth_retriever(
70
+ *,
71
+ searxng_url: str,
72
+ mode: str = "auto",
73
+ max_results: int = 8,
74
+ max_chars: int = 2000,
75
+ evidence_log: Any = None,
76
+ on_event: Any = None,
77
+ respect_robots: bool = False,
78
+ robots_ok: Any = None,
79
+ proxy: str | None = None,
80
+ ):
81
+ """Construct a LangChain BaseRetriever backed by the stealth fetch path."""
82
+ from functools import partial
83
+
84
+ from langchain_core.retrievers import BaseRetriever, Document # type: ignore
85
+
86
+ stealth_fetch = partial(stealth_get, proxy=proxy)
87
+
88
+ class StealthRetriever(BaseRetriever):
89
+ model_config = {"arbitrary_types_allowed": True}
90
+
91
+ def _get_relevant_documents(self, query: str, *, run_manager=None): # noqa: D401
92
+ docs = []
93
+ for hit in searxng_search(searxng_url, query, max_results):
94
+ url = hit["url"]
95
+ result = fetch(
96
+ url,
97
+ mode=mode,
98
+ plain_fetch=plain_get,
99
+ stealth_fetch=stealth_fetch,
100
+ respect_robots=respect_robots,
101
+ robots_ok=robots_ok,
102
+ )
103
+ if evidence_log is not None:
104
+ evidence_log.add(result.evidence)
105
+ if on_event is not None:
106
+ on_event(result.evidence)
107
+ if result.content:
108
+ docs.append(
109
+ Document(
110
+ page_content=_cap_content(
111
+ _extract_text(result.content), max_chars
112
+ ),
113
+ metadata={"url": url, "title": hit.get("title", "")},
114
+ )
115
+ )
116
+ return docs
117
+
118
+ return StealthRetriever()
deepcloak/setup.py ADDED
@@ -0,0 +1,77 @@
1
+ """`deepcloak setup` โ€” download the stealth browser and check the environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from collections.abc import Mapping
9
+
10
+ __all__ = ["run_setup", "check_env", "install_browser"]
11
+
12
+ _KEY_VARS = ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY")
13
+
14
+
15
+ def check_env(env: Mapping[str, str] | None = None) -> list[str]:
16
+ """Return a list of warnings about the environment (empty == all good)."""
17
+ env = os.environ if env is None else env
18
+ warnings: list[str] = []
19
+ if not any(env.get(k) for k in _KEY_VARS):
20
+ warnings.append(
21
+ "No LLM API key found โ€” set one of "
22
+ + ", ".join(_KEY_VARS)
23
+ + " (or use --provider ollama for a local model)."
24
+ )
25
+ return warnings
26
+
27
+
28
+ def _playwright_install() -> bool:
29
+ try:
30
+ subprocess.run(
31
+ [sys.executable, "-m", "playwright", "install", "chromium"],
32
+ check=True,
33
+ capture_output=True,
34
+ )
35
+ return True
36
+ except Exception:
37
+ return False
38
+
39
+
40
+ _UNSET = object()
41
+
42
+
43
+ def install_browser(cb: object = _UNSET) -> tuple[bool, str]:
44
+ """Best-effort: ensure the stealth browser binary is available.
45
+
46
+ ``cb`` is the cloakbrowser module; pass ``None`` to simulate it missing.
47
+ """
48
+ if cb is _UNSET:
49
+ try:
50
+ import cloakbrowser as cb # type: ignore
51
+ except ImportError:
52
+ cb = None
53
+ if cb is None:
54
+ return False, "cloakbrowser is not installed โ€” run `pip install deepcloak`."
55
+
56
+ # cloakbrowser manages its own stealth Chromium via ensure_binary()/download().
57
+ for fname in ("ensure_binary", "download", "install"):
58
+ fn = getattr(cb, fname, None)
59
+ if callable(fn):
60
+ try:
61
+ path = fn()
62
+ where = f" ({path})" if isinstance(path, str) else ""
63
+ return True, f"Stealth browser ready via cloakbrowser.{fname}(){where}."
64
+ except Exception:
65
+ continue
66
+ if _playwright_install():
67
+ return True, "Stealth browser (Chromium) installed via Playwright."
68
+ return True, "cloakbrowser present; could not confirm the browser download โ€” try manually."
69
+
70
+
71
+ def run_setup(cb: object = _UNSET) -> int:
72
+ ok, msg = install_browser(cb)
73
+ print(msg)
74
+ for warning in check_env():
75
+ print(f"warning: {warning}")
76
+ print("DeepCloak is ready." if ok else "Setup incomplete โ€” see the message above.")
77
+ return 0 if ok else 1
@@ -0,0 +1,47 @@
1
+ """Concrete plain and stealth fetchers used by the shim.
2
+
3
+ - ``plain_get`` is an ordinary HTTP GET (cheap, tried first).
4
+ - ``stealth_get`` drives CloakBrowser's stealth Chromium to Bypass a Bot Wall.
5
+
6
+ Both lazy-import their heavy dependency so the package imports without it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ __all__ = ["plain_get", "stealth_get"]
14
+
15
+ _UA = (
16
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
17
+ "(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
18
+ )
19
+
20
+
21
+ def plain_get(url: str, timeout: int = 20) -> Any:
22
+ """Plain HTTP GET, returning a requests.Response."""
23
+ try:
24
+ import requests
25
+ except ImportError as exc: # pragma: no cover - requests is a hard dep
26
+ raise RuntimeError("requests not installed") from exc
27
+ return requests.get(url, timeout=timeout, headers={"User-Agent": _UA})
28
+
29
+
30
+ def stealth_get(url: str, proxy: str | None = None, wait_ms: int = 3000) -> str:
31
+ """Fetch a page through CloakBrowser's stealth Chromium and return its HTML."""
32
+ try:
33
+ import cloakbrowser
34
+ except ImportError as exc:
35
+ raise RuntimeError(
36
+ "cloakbrowser is not installed or the stealth browser is missing โ€” "
37
+ "run `deepcloak setup`."
38
+ ) from exc
39
+
40
+ kwargs: dict[str, Any] = {}
41
+ if proxy:
42
+ kwargs["proxy"] = proxy
43
+ with cloakbrowser.launch_context(**kwargs) as ctx:
44
+ page = ctx.new_page()
45
+ page.goto(url, wait_until="domcontentloaded", timeout=60000)
46
+ page.wait_for_timeout(wait_ms)
47
+ return page.content()
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: deepcloak
3
+ Version: 0.1.0
4
+ Summary: Local-first deep research agent that reads the whole web โ€” even pages behind Cloudflare, Datadome, Turnstile & reCAPTCHA.
5
+ Project-URL: Homepage, https://github.com/Mrbaeksang/deepcloak
6
+ Project-URL: Repository, https://github.com/Mrbaeksang/deepcloak
7
+ Project-URL: Author, https://baeksang.dev
8
+ Author-email: Mrbaeksang <contact@baeksang.dev>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ License-File: NOTICE
12
+ Keywords: ai-agent,anti-bot,cloudflare,deep-research,llm,local-first,mcp,rag,stealth,web-scraping
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: cloakbrowser>=0.3.27
15
+ Requires-Dist: local-deep-research==1.6.11
16
+ Requires-Dist: requests>=2.31
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Requires-Dist: ruff>=0.6; extra == 'dev'
20
+ Provides-Extra: mcp
21
+ Requires-Dist: mcp>=1.0; extra == 'mcp'
22
+ Description-Content-Type: text/markdown
23
+
24
+ <div align="center">
25
+
26
+ <h1>๐Ÿ›ก๏ธ DeepCloak</h1>
27
+
28
+ ### The deep-research agent that reads the pages others can't.
29
+
30
+ **Cloudflare ยท Datadome ยท Turnstile ยท reCAPTCHA โ€” it walks straight through them.**
31
+
32
+ [![CI](https://github.com/Mrbaeksang/deepcloak/actions/workflows/ci.yml/badge.svg)](https://github.com/Mrbaeksang/deepcloak/actions/workflows/ci.yml)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-a855f7.svg)](LICENSE)
34
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](pyproject.toml)
35
+ [![MCP native](https://img.shields.io/badge/MCP-native-22d3ee.svg)](#-use-it-from-an-ai-agent)
36
+ [![PRs welcome](https://img.shields.io/badge/PRs-welcome-34d399.svg)](CONTRIBUTING.md)
37
+ [![GitHub stars](https://img.shields.io/github/stars/Mrbaeksang/deepcloak?style=social)](https://github.com/Mrbaeksang/deepcloak/stargazers)
38
+ [![Watch on YouTube](https://img.shields.io/badge/โ–ถ_demo-YouTube-FF0000.svg)](https://youtu.be/p5ompjDLzaI)
39
+
40
+ **English** ยท [ํ•œ๊ตญ์–ด](README.ko.md) ยท [็ฎ€ไฝ“ไธญๆ–‡](README.zh-CN.md)
41
+
42
+ ### [โ–ถ Live demo โ€” deepcloak.vercel.app](https://deepcloak.vercel.app) ยท [โ–ถ Watch on YouTube](https://youtu.be/p5ompjDLzaI)
43
+
44
+ [![DeepCloak running: it detects a Cloudflare Turnstile, escalates, and bypasses it โ€” then writes a cited report](docs/media/demo.gif)](https://youtu.be/p5ompjDLzaI)
45
+
46
+ </div>
47
+
48
+ ---
49
+
50
+ ## The problem
51
+
52
+ You ask a research tool a question. Half the best sources sit behind a **Bot Wall** โ€” Cloudflare, Datadome, a Turnstile, a reCAPTCHA. Every other tool gets a `403`, silently drops those pages, and hands you a thinner report. **You never even learn what it missed.**
53
+
54
+ ## What DeepCloak does
55
+
56
+ When a plain fetch hits a Bot Wall, DeepCloak **Escalates** that one URL to a **Stealth Fetch** and **Bypasses** the wall โ€” recovering the content other agents abandon. Then it tells you, at the bottom of every report, exactly how many walls it broke through.
57
+
58
+ It's a thin, local-first orchestrator over two great projects: [`local-deep-research`](https://github.com/LearningCircuit/local-deep-research) (the research loop) and [`CloakBrowser`](https://github.com/CloakHQ/CloakBrowser) (the stealth browser). Use it as a **CLI**, an **MCP server**, or a **Claude skill**. MIT.
59
+
60
+ ## ๐ŸŒ‘ Why we built this
61
+
62
+ The open web is quietly closing. More of the best writing now sits behind a bot check, and AI research agents โ€” the tools we increasingly trust to read the web *for us* โ€” go blind at exactly those doors, without ever saying so. A report that silently skips every walled source isn't neutral; it's wrong in a way you can't see.
63
+
64
+ DeepCloak's stance is simple: **your agent should be able to read what a person with a browser can read** โ€” and it should be honest about how it got there. So it Bypasses the wall when it has to, keeps everything local (no query or page leaves your machine), and prints an Evidence Record of every wall it crossed. Capability *and* transparency, MIT-licensed, no lock-in.
65
+
66
+ ## โœจ Why it's different
67
+
68
+ | | Plain deep research | **DeepCloak** |
69
+ | --- | :---: | :---: |
70
+ | Reads the open web | โœ… | โœ… |
71
+ | Reads Cloudflare / Datadome / Turnstile / reCAPTCHA pages | โŒ *dropped silently* | โœ… **Bypassed** |
72
+ | Tells you which sources were walled | โŒ | โœ… Evidence Record |
73
+ | Local-first (no API key required) | โœ… | โœ… |
74
+ | Fast on open pages | โ€” | โœ… *plain-first, stealth only when needed* |
75
+
76
+ > **Verified live โ€” not mocked.** The clip above is an **unedited screen recording** (captured with `ffmpeg`, no compositing) of a real `deepcloak` run against a **local LLM (Qwen) + SearXNG โ€” no API key**. It Escalates on each Bot Wall and **Bypasses 8 Cloudflare/Turnstile walls** in one pass, then writes a cited report. Full clip: [`docs/media/demo-real.mp4`](docs/media/demo-real.mp4); a raw asciinema session is also kept at [`docs/media/demo.cast`](docs/media/demo.cast). Wall counts vary per run (8โ€“20) because the open web does.
77
+
78
+ ## ๐Ÿš€ Quickstart
79
+
80
+ ```bash
81
+ pip install deepcloak
82
+ deepcloak setup # one-time: downloads the stealth browser
83
+ export OPENAI_API_KEY=... # or ANTHROPIC_API_KEY / GEMINI_API_KEY โ€” or --provider ollama
84
+ deepcloak "How does Cloudflare Turnstile detect bots?" --depth detailed --out report.md
85
+ ```
86
+
87
+ You get a cited `report.md` ending with a `๐Ÿ›ก๏ธ Bypassed N bot-walled sources` section, plus a `report.md.evidence.json` sidecar.
88
+
89
+ ## ๐Ÿง  How it works
90
+
91
+ ```
92
+ search (DuckDuckGo, no setup) โ”€โ–ถ candidate URLs
93
+ โ”‚
94
+ โ–ผ for each page:
95
+ plain fetch โ”€โ–ถ Bot Wall detected? โ”€โ”€noโ”€โ”€โ–ถ use it (fast)
96
+ โ”‚ yes
97
+ โ–ผ
98
+ Escalate โ”€โ–ถ Stealth Fetch (CloakBrowser) โ”€โ–ถ Bypass
99
+ โ”‚
100
+ โ–ผ
101
+ research loop (local-deep-research) โ”€โ–ถ cited report + Evidence Records
102
+ ```
103
+
104
+ Stealth is heavy, so DeepCloak tries a cheap plain fetch first and only launches the stealth browser when it actually detects a Bot Wall (`--stealth auto`, the default). Use `--depth detailed`/`report` to fetch full pages where Bypasses happen.
105
+
106
+ ## ๐Ÿค– Connect it to your agent (MCP)
107
+
108
+ DeepCloak runs as a stdio **MCP server** exposing `deep_research(query, depth)`, `quick_summary(query)`, and `get_evidence(run_id)`.
109
+
110
+ **Claude Code** โ€” add to your project's `.mcp.json` (an example ships in this repo):
111
+
112
+ ```json
113
+ { "mcpServers": { "deepcloak": { "command": "deepcloak", "args": ["mcp"] } } }
114
+ ```
115
+
116
+ **Codex** โ€” add to `~/.codex/config.toml`:
117
+
118
+ ```toml
119
+ [mcp_servers.deepcloak]
120
+ command = "deepcloak"
121
+ args = ["mcp"]
122
+ ```
123
+
124
+ Then your agent can call `deep_research` and read bot-walled sources directly. Prefer a slash-style skill? Drop [`skill/SKILL.md`](skill/SKILL.md) into `~/.claude/skills/deepcloak/`.
125
+
126
+ ## โš™๏ธ Configuration
127
+
128
+ | Flag | Default | Notes |
129
+ | --- | --- | --- |
130
+ | `--depth` | `detailed` | `quick` / `detailed` / `report` |
131
+ | `--engine` | `duckduckgo` | `searxng` / `auto` |
132
+ | `--stealth` | `auto` | `always` / `off` |
133
+ | `--provider` / `--model` | auto-detected | `OPENAI` โ†’ `ANTHROPIC` โ†’ `GEMINI`, or `ollama` |
134
+ | `--respect-robots` | off | honor robots.txt |
135
+ | `--proxy` | โ€” | SOCKS5 for the Stealth Fetch |
136
+
137
+ ## โš ๏ธ Responsible use
138
+
139
+ 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.
140
+
141
+ ## ๐Ÿ› ๏ธ Built on
142
+
143
+ [`local-deep-research`](https://github.com/LearningCircuit/local-deep-research) (MIT) + [`CloakBrowser`](https://github.com/CloakHQ/CloakBrowser) (MIT), via pip โ€” no vendored code. Domain glossary in [CONTEXT.md](CONTEXT.md); design decisions in [docs/adr/](docs/adr/); contributing guide in [CONTRIBUTING.md](CONTRIBUTING.md).
144
+
145
+ ## ๐Ÿ“„ License
146
+
147
+ MIT โ€” see [LICENSE](LICENSE) and [NOTICE](NOTICE).
148
+
149
+ <div align="center">
150
+
151
+ **If DeepCloak read a page your last tool gave up on, drop a โญ โ€” it helps others find it.**
152
+
153
+ Built by **[Mrbaeksang](https://baeksang.dev)** ยท [baeksang.dev](https://baeksang.dev) ยท [contact@baeksang.dev](mailto:contact@baeksang.dev)
154
+
155
+ [![Star History Chart](https://api.star-history.com/svg?repos=Mrbaeksang/deepcloak&type=Date)](https://star-history.com/#Mrbaeksang/deepcloak&Date)
156
+
157
+ </div>
@@ -0,0 +1,19 @@
1
+ deepcloak/__init__.py,sha256=98KQ1ijIK4a4lXI_-ATlcOBosgpYgFKrHMbd0ounEd8,98
2
+ deepcloak/bot_wall_detector.py,sha256=TPR-ME_UZkixTzJ6eM_yB5_8RBWD_WTkV4mEkqqGDiI,2610
3
+ deepcloak/cli.py,sha256=dP35Fjc1rG7OjMxCrYh25NqTzJs40TsDnwhZiOfCvXw,3141
4
+ deepcloak/config.py,sha256=t4tfmej-g85oPZ3DQ2Tux88-G_7BoT6j1SRkBtYClfk,6288
5
+ deepcloak/evidence.py,sha256=9AkA--Jd5yKr8p_S2e8qRebZevgvaz2w39tdfoWEWDI,2156
6
+ deepcloak/fetch_router.py,sha256=eIohEJI_rIWbMfKYJ9geGWzvIDt8AsJHj_fphEDAY80,3431
7
+ deepcloak/ldr_shim.py,sha256=hY10rM3iWUA1hfG2lPED-rMYy61WIE__5EuMdQF_wMo,3774
8
+ deepcloak/mcp_server.py,sha256=9H-A1xFQrtoZ-flvu2gLvn6fVweBsyH35k9xHuEpfXY,1782
9
+ deepcloak/progress.py,sha256=VJhz6xND3H7cJpSG0fxDaJZbX_-tP8ofaiKYSNiaNqg,1034
10
+ deepcloak/research_core.py,sha256=Q3uRAyiAOuTcCuzbNVnQ_RRIhmLo3jG0HtsiQWDeVC8,4521
11
+ deepcloak/retriever.py,sha256=s-dTGREI5kwYZlCKJaENulUHwPTAHbX4xRb3ALspXNE,3984
12
+ deepcloak/setup.py,sha256=y68jryW4BOhh8ktpOw6xhYUOFyhu6arzvPQGj6B70AM,2522
13
+ deepcloak/stealth_downloader.py,sha256=7r36ZBPsD-YqgzIe3-sInx-PFbHpsW0D_BQNE3cRt1s,1571
14
+ deepcloak-0.1.0.dist-info/METADATA,sha256=vGuhfPXWD_Pwy1eX7locdvSgH9UGLuuJIpeiS2I5KHg,8234
15
+ deepcloak-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
16
+ deepcloak-0.1.0.dist-info/entry_points.txt,sha256=x1i7EWsSQXO463HmNKmzJbWhB6Q5t9hNtBtazdl7y5Q,49
17
+ deepcloak-0.1.0.dist-info/licenses/LICENSE,sha256=8m17jZTiuCB6Xv_5GVsK6SM86zpyFX31eoXXghTI03c,1067
18
+ deepcloak-0.1.0.dist-info/licenses/NOTICE,sha256=WzWf4iJunAFVZ15H5nNxhqcYuy85ng0qH_Jsaayw24M,897
19
+ deepcloak-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ deepcloak = deepcloak.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mrbaeksang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ DeepCloak
2
+ Copyright (c) 2026 Mrbaeksang
3
+
4
+ This product depends on, and is built around, the following open-source projects.
5
+ DeepCloak does not vendor their code; it depends on them via pip.
6
+
7
+ -------------------------------------------------------------------------------
8
+ local-deep-research
9
+ Homepage: https://github.com/LearningCircuit/local-deep-research
10
+ License: MIT
11
+ Role: the iterative multi-source Deep Research loop.
12
+
13
+ -------------------------------------------------------------------------------
14
+ CloakBrowser
15
+ Homepage: https://github.com/CloakHQ/CloakBrowser
16
+ License: MIT
17
+ Role: the stealth Chromium that performs the Stealth Fetch / Bypass.
18
+
19
+ -------------------------------------------------------------------------------
20
+ Both are MIT-licensed; their copyright and permission notices apply to their
21
+ respective code. See each project's repository for full license text.