groundhog-mcp 0.1.0__tar.gz
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.
- groundhog_mcp-0.1.0/.gitignore +34 -0
- groundhog_mcp-0.1.0/PKG-INFO +19 -0
- groundhog_mcp-0.1.0/pyproject.toml +48 -0
- groundhog_mcp-0.1.0/src/groundhog_mcp/__init__.py +0 -0
- groundhog_mcp-0.1.0/src/groundhog_mcp/__main__.py +11 -0
- groundhog_mcp-0.1.0/src/groundhog_mcp/config.py +36 -0
- groundhog_mcp-0.1.0/src/groundhog_mcp/engine.py +156 -0
- groundhog_mcp-0.1.0/src/groundhog_mcp/extract.py +27 -0
- groundhog_mcp-0.1.0/src/groundhog_mcp/ratelimit.py +28 -0
- groundhog_mcp-0.1.0/src/groundhog_mcp/safety.py +40 -0
- groundhog_mcp-0.1.0/src/groundhog_mcp/server.py +11 -0
- groundhog_mcp-0.1.0/src/groundhog_mcp/tools/__init__.py +0 -0
- groundhog_mcp-0.1.0/src/groundhog_mcp/tools/read_url.py +42 -0
- groundhog_mcp-0.1.0/src/groundhog_mcp/tools/status.py +20 -0
- groundhog_mcp-0.1.0/tests/__init__.py +0 -0
- groundhog_mcp-0.1.0/tests/test_config.py +23 -0
- groundhog_mcp-0.1.0/tests/test_engine_live.py +35 -0
- groundhog_mcp-0.1.0/tests/test_extract.py +40 -0
- groundhog_mcp-0.1.0/tests/test_ratelimit.py +48 -0
- groundhog_mcp-0.1.0/tests/test_read_url.py +9 -0
- groundhog_mcp-0.1.0/tests/test_read_url_live.py +37 -0
- groundhog_mcp-0.1.0/tests/test_safety.py +33 -0
- groundhog_mcp-0.1.0/tests/test_status.py +30 -0
- groundhog_mcp-0.1.0/uv.lock +1441 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
**/node_modules/
|
|
4
|
+
.venv/
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
|
|
8
|
+
# Build / runtime
|
|
9
|
+
chrome-user-data/
|
|
10
|
+
downloads/
|
|
11
|
+
screenshots/
|
|
12
|
+
*.log
|
|
13
|
+
|
|
14
|
+
# Example/test run artifacts
|
|
15
|
+
examples/**/*.png
|
|
16
|
+
package-lock.json
|
|
17
|
+
go.sum
|
|
18
|
+
|
|
19
|
+
# Env / secrets
|
|
20
|
+
.env
|
|
21
|
+
.env.*
|
|
22
|
+
*.pem
|
|
23
|
+
*.key
|
|
24
|
+
|
|
25
|
+
# OS / editor
|
|
26
|
+
.DS_Store
|
|
27
|
+
.idea/
|
|
28
|
+
.vscode/
|
|
29
|
+
*.swp
|
|
30
|
+
|
|
31
|
+
# Internal (never publish)
|
|
32
|
+
.superpowers/
|
|
33
|
+
docs/superpowers/
|
|
34
|
+
.internal/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: groundhog-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stealth-browser grounding tools for AI agents, over MCP
|
|
5
|
+
Project-URL: Homepage, https://github.com/dmytrome/groundhog
|
|
6
|
+
Project-URL: Repository, https://github.com/dmytrome/groundhog
|
|
7
|
+
Project-URL: Issues, https://github.com/dmytrome/groundhog/issues
|
|
8
|
+
Author: dmytrome
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agents,browser,cdp,mcp,scraping,ssrf,stealth
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: mcp<2,>=1.28
|
|
17
|
+
Requires-Dist: playwright>=1.49
|
|
18
|
+
Requires-Dist: tldextract>=5.1
|
|
19
|
+
Requires-Dist: trafilatura>=2.0
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "groundhog-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Stealth-browser grounding tools for AI agents, over MCP"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "dmytrome" }]
|
|
8
|
+
keywords = ["mcp", "stealth", "browser", "cdp", "scraping", "ssrf", "agents"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"mcp>=1.28,<2",
|
|
17
|
+
"playwright>=1.49",
|
|
18
|
+
"trafilatura>=2.0",
|
|
19
|
+
"tldextract>=5.1",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/dmytrome/groundhog"
|
|
24
|
+
Repository = "https://github.com/dmytrome/groundhog"
|
|
25
|
+
Issues = "https://github.com/dmytrome/groundhog/issues"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
groundhog-mcp = "groundhog_mcp.__main__:main"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.14"]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
asyncio_mode = "auto"
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint.isort]
|
|
48
|
+
combine-as-imports = true
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
DEFAULT_USER_AGENT = (
|
|
5
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
6
|
+
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Config:
|
|
12
|
+
cdp_url: str
|
|
13
|
+
min_delay_ms: int
|
|
14
|
+
block_private_ips: bool
|
|
15
|
+
max_tokens: int
|
|
16
|
+
user_agent: str
|
|
17
|
+
auto_start_browser: bool
|
|
18
|
+
compose_file: str | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _bool(value: str | None, default: bool) -> bool:
|
|
22
|
+
if value is None:
|
|
23
|
+
return default
|
|
24
|
+
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config() -> Config:
|
|
28
|
+
return Config(
|
|
29
|
+
cdp_url=os.environ.get("CDP_URL", "http://127.0.0.1:9222"),
|
|
30
|
+
min_delay_ms=int(os.environ.get("GROUNDHOG_MIN_DELAY_MS", "5000")),
|
|
31
|
+
block_private_ips=_bool(os.environ.get("GROUNDHOG_BLOCK_PRIVATE_IPS"), True),
|
|
32
|
+
max_tokens=int(os.environ.get("GROUNDHOG_MAX_TOKENS", "20000")),
|
|
33
|
+
user_agent=os.environ.get("GROUNDHOG_USER_AGENT", DEFAULT_USER_AGENT),
|
|
34
|
+
auto_start_browser=_bool(os.environ.get("GROUNDHOG_AUTO_START_BROWSER"), False),
|
|
35
|
+
compose_file=os.environ.get("GROUNDHOG_COMPOSE_FILE") or None,
|
|
36
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import urllib.request
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import tldextract
|
|
6
|
+
from playwright.async_api import (
|
|
7
|
+
Browser,
|
|
8
|
+
BrowserContext,
|
|
9
|
+
Error as PlaywrightError,
|
|
10
|
+
Playwright,
|
|
11
|
+
async_playwright,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from . import safety
|
|
15
|
+
from .config import Config, load_config
|
|
16
|
+
from .ratelimit import RateLimiter
|
|
17
|
+
|
|
18
|
+
_GOTO_TIMEOUT_MS = 60_000
|
|
19
|
+
_PROBE_TIMEOUT_S = 2.0
|
|
20
|
+
_AUTOSTART_READY_TRIES = 30
|
|
21
|
+
_ERR_DETAIL_CHARS = 300
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BrowserUnavailableError(Exception):
|
|
25
|
+
"""The stealth browser's CDP endpoint could not be reached."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def remediation(cfg: Config) -> str:
|
|
29
|
+
return (
|
|
30
|
+
f"Cannot reach the stealth browser at {cfg.cdp_url}. "
|
|
31
|
+
"Start it with `docker compose up -d` (see the Groundhog README), "
|
|
32
|
+
"or set GROUNDHOG_AUTO_START_BROWSER=true to let Groundhog start it for you."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def check_browser(cdp_url: str, timeout: float = _PROBE_TIMEOUT_S) -> bool:
|
|
37
|
+
"""Return whether the CDP endpoint answers, via its `/json/version` probe."""
|
|
38
|
+
probe_url = cdp_url.rstrip("/") + "/json/version"
|
|
39
|
+
|
|
40
|
+
def _probe() -> bool:
|
|
41
|
+
try:
|
|
42
|
+
with urllib.request.urlopen(probe_url, timeout=timeout) as resp:
|
|
43
|
+
return resp.status == 200
|
|
44
|
+
except OSError:
|
|
45
|
+
return False # refused / DNS / timeout all mean "not reachable"
|
|
46
|
+
|
|
47
|
+
return await asyncio.get_running_loop().run_in_executor(None, _probe)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _start_browser_container(cfg: Config) -> None:
|
|
51
|
+
cmd = ["docker", "compose"]
|
|
52
|
+
if cfg.compose_file:
|
|
53
|
+
cmd += ["-f", cfg.compose_file]
|
|
54
|
+
cmd += ["up", "-d"]
|
|
55
|
+
try:
|
|
56
|
+
proc = await asyncio.create_subprocess_exec(
|
|
57
|
+
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
58
|
+
)
|
|
59
|
+
except FileNotFoundError as exc:
|
|
60
|
+
raise BrowserUnavailableError(
|
|
61
|
+
"GROUNDHOG_AUTO_START_BROWSER is set but Docker was not found on PATH. "
|
|
62
|
+
+ remediation(cfg)
|
|
63
|
+
) from exc
|
|
64
|
+
_, stderr = await proc.communicate()
|
|
65
|
+
if proc.returncode != 0:
|
|
66
|
+
detail = stderr.decode(errors="replace").strip()[:_ERR_DETAIL_CHARS]
|
|
67
|
+
raise BrowserUnavailableError(f"`docker compose up -d` failed: {detail}")
|
|
68
|
+
for _ in range(_AUTOSTART_READY_TRIES):
|
|
69
|
+
if await check_browser(cfg.cdp_url):
|
|
70
|
+
return
|
|
71
|
+
await asyncio.sleep(1)
|
|
72
|
+
raise BrowserUnavailableError(
|
|
73
|
+
f"Browser container started but {cfg.cdp_url} did not become ready in time."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class RenderedPage:
|
|
79
|
+
html: str
|
|
80
|
+
text: str
|
|
81
|
+
final_url: str
|
|
82
|
+
title: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def registrable_domain(url: str) -> str:
|
|
86
|
+
ext = tldextract.extract(url)
|
|
87
|
+
return ext.registered_domain or ext.fqdn or url
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class EngineProvider:
|
|
91
|
+
def __init__(self, cfg: Config):
|
|
92
|
+
self._cfg = cfg
|
|
93
|
+
self._pw: Playwright | None = None
|
|
94
|
+
self._browser: Browser | None = None
|
|
95
|
+
self._context: BrowserContext | None = None
|
|
96
|
+
self._rl = RateLimiter(cfg.min_delay_ms / 1000)
|
|
97
|
+
|
|
98
|
+
async def start(self) -> None:
|
|
99
|
+
self._pw = await async_playwright().start()
|
|
100
|
+
self._browser = await self._connect()
|
|
101
|
+
self._context = await self._browser.new_context(
|
|
102
|
+
user_agent=self._cfg.user_agent,
|
|
103
|
+
viewport={"width": 1920, "height": 1080},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
async def _connect(self) -> Browser:
|
|
107
|
+
try:
|
|
108
|
+
return await self._pw.chromium.connect_over_cdp(self._cfg.cdp_url)
|
|
109
|
+
except PlaywrightError as exc:
|
|
110
|
+
if not self._cfg.auto_start_browser:
|
|
111
|
+
raise BrowserUnavailableError(remediation(self._cfg)) from exc
|
|
112
|
+
await _start_browser_container(self._cfg)
|
|
113
|
+
try:
|
|
114
|
+
return await self._pw.chromium.connect_over_cdp(self._cfg.cdp_url)
|
|
115
|
+
except PlaywrightError as exc:
|
|
116
|
+
raise BrowserUnavailableError(remediation(self._cfg)) from exc
|
|
117
|
+
|
|
118
|
+
async def fetch(self, url: str) -> RenderedPage:
|
|
119
|
+
await safety.check_url(url, self._cfg)
|
|
120
|
+
await self._rl.acquire(registrable_domain(url))
|
|
121
|
+
page = await self._context.new_page()
|
|
122
|
+
try:
|
|
123
|
+
await page.goto(url, wait_until="domcontentloaded", timeout=_GOTO_TIMEOUT_MS)
|
|
124
|
+
# A page can redirect to an internal address the initial check never
|
|
125
|
+
# saw; re-check the final URL so its content is never returned.
|
|
126
|
+
await safety.check_url(page.url, self._cfg)
|
|
127
|
+
return RenderedPage(
|
|
128
|
+
html=await page.content(),
|
|
129
|
+
text=await page.inner_text("body"),
|
|
130
|
+
final_url=page.url,
|
|
131
|
+
title=await page.title(),
|
|
132
|
+
)
|
|
133
|
+
finally:
|
|
134
|
+
await page.close()
|
|
135
|
+
|
|
136
|
+
async def aclose(self) -> None:
|
|
137
|
+
if self._context is not None:
|
|
138
|
+
await self._context.close()
|
|
139
|
+
if self._browser is not None:
|
|
140
|
+
await self._browser.close()
|
|
141
|
+
if self._pw is not None:
|
|
142
|
+
await self._pw.stop()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_provider: EngineProvider | None = None
|
|
146
|
+
_provider_lock = asyncio.Lock()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def get_provider() -> EngineProvider:
|
|
150
|
+
global _provider
|
|
151
|
+
async with _provider_lock:
|
|
152
|
+
if _provider is None:
|
|
153
|
+
provider = EngineProvider(load_config())
|
|
154
|
+
await provider.start()
|
|
155
|
+
_provider = provider
|
|
156
|
+
return _provider
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import trafilatura
|
|
2
|
+
|
|
3
|
+
_CHARS_PER_TOKEN = 4
|
|
4
|
+
_TRUNCATION_MARKER = "\n\n[... truncated]"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def truncate(text: str, max_tokens: int) -> tuple[str, bool]:
|
|
8
|
+
limit = max_tokens * _CHARS_PER_TOKEN
|
|
9
|
+
if len(text) <= limit:
|
|
10
|
+
return text, False
|
|
11
|
+
cut = text.rfind("\n\n", 0, limit)
|
|
12
|
+
if cut <= 0:
|
|
13
|
+
cut = limit
|
|
14
|
+
return text[:cut].rstrip() + _TRUNCATION_MARKER, True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_markdown(html: str, text_fallback: str, url: str, max_tokens: int) -> tuple[str, bool]:
|
|
18
|
+
markdown = trafilatura.extract(
|
|
19
|
+
html,
|
|
20
|
+
url=url,
|
|
21
|
+
output_format="markdown",
|
|
22
|
+
include_links=True,
|
|
23
|
+
include_tables=True,
|
|
24
|
+
)
|
|
25
|
+
if not markdown:
|
|
26
|
+
markdown = text_fallback or ""
|
|
27
|
+
return truncate(markdown, max_tokens)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RateLimiter:
|
|
6
|
+
"""Enforces a minimum delay between acquisitions sharing the same key."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, min_delay: float, *, clock=time.monotonic, sleep=asyncio.sleep):
|
|
9
|
+
self._min_delay = min_delay
|
|
10
|
+
self._clock = clock
|
|
11
|
+
self._sleep = sleep
|
|
12
|
+
self._last: dict[str, float] = {}
|
|
13
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
14
|
+
|
|
15
|
+
def _lock(self, key: str) -> asyncio.Lock:
|
|
16
|
+
lock = self._locks.get(key)
|
|
17
|
+
if lock is None:
|
|
18
|
+
lock = self._locks[key] = asyncio.Lock()
|
|
19
|
+
return lock
|
|
20
|
+
|
|
21
|
+
async def acquire(self, key: str) -> None:
|
|
22
|
+
async with self._lock(key):
|
|
23
|
+
last = self._last.get(key)
|
|
24
|
+
if last is not None:
|
|
25
|
+
wait = self._min_delay - (self._clock() - last)
|
|
26
|
+
if wait > 0:
|
|
27
|
+
await self._sleep(wait)
|
|
28
|
+
self._last[key] = self._clock()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import ipaddress
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
from .config import Config
|
|
6
|
+
|
|
7
|
+
_CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BlockedURLError(Exception):
|
|
11
|
+
"""Raised when a URL is disallowed by the SSRF guard."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_blocked_ip(ip: str) -> bool:
|
|
15
|
+
addr = ipaddress.ip_address(ip)
|
|
16
|
+
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
|
|
17
|
+
addr = addr.ipv4_mapped
|
|
18
|
+
if (addr.is_private or addr.is_loopback or addr.is_link_local
|
|
19
|
+
or addr.is_reserved or addr.is_multicast or addr.is_unspecified):
|
|
20
|
+
return True
|
|
21
|
+
return isinstance(addr, ipaddress.IPv4Address) and addr in _CGNAT
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def check_url(url: str, cfg: Config) -> None:
|
|
25
|
+
parsed = urlparse(url)
|
|
26
|
+
if parsed.scheme not in ("http", "https"):
|
|
27
|
+
raise BlockedURLError(f"scheme not allowed: {parsed.scheme!r}")
|
|
28
|
+
if parsed.username or parsed.password:
|
|
29
|
+
raise BlockedURLError("credentials in URL are not allowed")
|
|
30
|
+
host = parsed.hostname
|
|
31
|
+
if not host:
|
|
32
|
+
raise BlockedURLError("URL has no host")
|
|
33
|
+
if not cfg.block_private_ips:
|
|
34
|
+
return
|
|
35
|
+
loop = asyncio.get_running_loop()
|
|
36
|
+
infos = await loop.getaddrinfo(host, None)
|
|
37
|
+
for info in infos:
|
|
38
|
+
ip = info[4][0]
|
|
39
|
+
if is_blocked_ip(ip):
|
|
40
|
+
raise BlockedURLError(f"blocked address: {host} -> {ip}")
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from datetime import UTC, datetime
|
|
2
|
+
from typing import TypedDict
|
|
3
|
+
|
|
4
|
+
from .. import extract
|
|
5
|
+
from ..config import load_config
|
|
6
|
+
from ..engine import get_provider
|
|
7
|
+
|
|
8
|
+
_FORMATS = ("markdown", "text")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ReadResult(TypedDict):
|
|
12
|
+
markdown: str
|
|
13
|
+
title: str
|
|
14
|
+
url: str
|
|
15
|
+
final_url: str
|
|
16
|
+
fetched_at: str
|
|
17
|
+
truncated: bool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def read_url(url: str, format: str = "markdown", max_tokens: int | None = None) -> ReadResult:
|
|
21
|
+
"""Fetch a web page through the stealth browser and return clean Markdown
|
|
22
|
+
plus provenance (source URL, final URL, title, fetch time). Use this to
|
|
23
|
+
ground answers in live web content, including sites that block plain
|
|
24
|
+
fetchers. `format` may be "markdown" (default) or "text"."""
|
|
25
|
+
if format not in _FORMATS:
|
|
26
|
+
raise ValueError(f"format must be one of {_FORMATS}, got {format!r}")
|
|
27
|
+
cfg = load_config()
|
|
28
|
+
provider = await get_provider()
|
|
29
|
+
page = await provider.fetch(url)
|
|
30
|
+
limit = max_tokens or cfg.max_tokens
|
|
31
|
+
if format == "text":
|
|
32
|
+
body, truncated = extract.truncate(page.text, limit)
|
|
33
|
+
else:
|
|
34
|
+
body, truncated = extract.to_markdown(page.html, page.text, page.final_url, limit)
|
|
35
|
+
return {
|
|
36
|
+
"markdown": body,
|
|
37
|
+
"title": page.title,
|
|
38
|
+
"url": url,
|
|
39
|
+
"final_url": page.final_url,
|
|
40
|
+
"fetched_at": datetime.now(UTC).isoformat(),
|
|
41
|
+
"truncated": truncated,
|
|
42
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import TypedDict
|
|
2
|
+
|
|
3
|
+
from ..config import load_config
|
|
4
|
+
from ..engine import check_browser, remediation
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StatusResult(TypedDict):
|
|
8
|
+
browser_reachable: bool
|
|
9
|
+
cdp_url: str
|
|
10
|
+
hint: str | None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def status() -> StatusResult:
|
|
14
|
+
"""Check whether Groundhog can reach the stealth browser. Call this to
|
|
15
|
+
diagnose setup before fetching: if `browser_reachable` is false, follow
|
|
16
|
+
`hint` to start the browser, then retry."""
|
|
17
|
+
cfg = load_config()
|
|
18
|
+
reachable = await check_browser(cfg.cdp_url)
|
|
19
|
+
hint = None if reachable else remediation(cfg)
|
|
20
|
+
return {"browser_reachable": reachable, "cdp_url": cfg.cdp_url, "hint": hint}
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from groundhog_mcp.config import load_config
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_defaults(monkeypatch):
|
|
5
|
+
for key in ("CDP_URL", "GROUNDHOG_MIN_DELAY_MS", "GROUNDHOG_BLOCK_PRIVATE_IPS",
|
|
6
|
+
"GROUNDHOG_MAX_TOKENS", "GROUNDHOG_USER_AGENT"):
|
|
7
|
+
monkeypatch.delenv(key, raising=False)
|
|
8
|
+
cfg = load_config()
|
|
9
|
+
assert cfg.cdp_url == "http://127.0.0.1:9222"
|
|
10
|
+
assert cfg.min_delay_ms == 5000
|
|
11
|
+
assert cfg.block_private_ips is True
|
|
12
|
+
assert cfg.max_tokens == 20000
|
|
13
|
+
assert "Chrome/149" in cfg.user_agent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_env_overrides(monkeypatch):
|
|
17
|
+
monkeypatch.setenv("CDP_URL", "http://127.0.0.1:9333")
|
|
18
|
+
monkeypatch.setenv("GROUNDHOG_MIN_DELAY_MS", "0")
|
|
19
|
+
monkeypatch.setenv("GROUNDHOG_BLOCK_PRIVATE_IPS", "false")
|
|
20
|
+
cfg = load_config()
|
|
21
|
+
assert cfg.cdp_url == "http://127.0.0.1:9333"
|
|
22
|
+
assert cfg.min_delay_ms == 0
|
|
23
|
+
assert cfg.block_private_ips is False
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from groundhog_mcp.config import load_config
|
|
6
|
+
from groundhog_mcp.engine import EngineProvider
|
|
7
|
+
from groundhog_mcp.safety import BlockedURLError
|
|
8
|
+
|
|
9
|
+
pytestmark = pytest.mark.skipif(
|
|
10
|
+
os.environ.get("RUN_LIVE") != "1",
|
|
11
|
+
reason="requires the engine running; set RUN_LIVE=1 and CDP_URL",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def test_fetch_example_com():
|
|
16
|
+
provider = EngineProvider(load_config())
|
|
17
|
+
await provider.start()
|
|
18
|
+
try:
|
|
19
|
+
page = await provider.fetch("https://example.com/")
|
|
20
|
+
assert "Example Domain" in page.title
|
|
21
|
+
assert "Example Domain" in page.html
|
|
22
|
+
assert page.final_url.startswith("https://example.com")
|
|
23
|
+
assert "Example Domain" in page.text
|
|
24
|
+
finally:
|
|
25
|
+
await provider.aclose()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def test_fetch_blocks_internal():
|
|
29
|
+
provider = EngineProvider(load_config())
|
|
30
|
+
await provider.start()
|
|
31
|
+
try:
|
|
32
|
+
with pytest.raises(BlockedURLError):
|
|
33
|
+
await provider.fetch("http://169.254.169.254/")
|
|
34
|
+
finally:
|
|
35
|
+
await provider.aclose()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from groundhog_mcp.extract import to_markdown, truncate
|
|
2
|
+
|
|
3
|
+
ARTICLE = """
|
|
4
|
+
<html><head><title>T</title></head><body>
|
|
5
|
+
<article><h1>Hello World</h1>
|
|
6
|
+
<p>This is the first paragraph of a real article with enough words to be
|
|
7
|
+
extracted by the content extractor. It mentions grounding for agents.</p>
|
|
8
|
+
<p>This is a second substantial paragraph so the extractor crosses its
|
|
9
|
+
content threshold and returns the main text rather than nothing at all.</p>
|
|
10
|
+
</article></body></html>
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_truncate_under_limit():
|
|
15
|
+
text, cut = truncate("hello", 1000)
|
|
16
|
+
assert text == "hello"
|
|
17
|
+
assert cut is False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_truncate_over_limit_marks_and_cuts():
|
|
21
|
+
text, cut = truncate("para one\n\npara two\n\npara three", 2) # limit=8, no \n\n below it
|
|
22
|
+
assert cut is True
|
|
23
|
+
assert text.endswith("\n\n[... truncated]")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_truncate_cuts_at_paragraph_boundary():
|
|
27
|
+
text, cut = truncate("para one\n\npara two\n\npara three", 3) # limit=12, \n\n at index 8
|
|
28
|
+
assert cut is True
|
|
29
|
+
assert text == "para one\n\n[... truncated]"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_to_markdown_extracts_article():
|
|
33
|
+
md, cut = to_markdown(ARTICLE, "", "https://example.com/a", 10000)
|
|
34
|
+
assert "Hello World" in md
|
|
35
|
+
assert cut is False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_to_markdown_falls_back_to_text():
|
|
39
|
+
md, cut = to_markdown("<html><body></body></html>", "FALLBACK TEXT", "https://x.com", 10000)
|
|
40
|
+
assert "FALLBACK TEXT" in md
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from groundhog_mcp.ratelimit import RateLimiter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def make_clock(start=100.0):
|
|
7
|
+
state = {"t": start}
|
|
8
|
+
return state
|
|
9
|
+
|
|
10
|
+
async def test_same_key_waits_once():
|
|
11
|
+
clock = make_clock()
|
|
12
|
+
sleeps = []
|
|
13
|
+
|
|
14
|
+
async def fake_sleep(d):
|
|
15
|
+
sleeps.append(d)
|
|
16
|
+
clock["t"] += d
|
|
17
|
+
|
|
18
|
+
rl = RateLimiter(5.0, clock=lambda: clock["t"], sleep=fake_sleep)
|
|
19
|
+
await rl.acquire("example.com") # first call: no wait
|
|
20
|
+
await rl.acquire("example.com") # second call: must wait 5s
|
|
21
|
+
assert sleeps == [5.0]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def test_different_keys_do_not_wait():
|
|
25
|
+
clock = make_clock()
|
|
26
|
+
sleeps = []
|
|
27
|
+
|
|
28
|
+
async def fake_sleep(d):
|
|
29
|
+
sleeps.append(d)
|
|
30
|
+
clock["t"] += d
|
|
31
|
+
|
|
32
|
+
rl = RateLimiter(5.0, clock=lambda: clock["t"], sleep=fake_sleep)
|
|
33
|
+
await rl.acquire("a.com")
|
|
34
|
+
await rl.acquire("b.com")
|
|
35
|
+
assert sleeps == []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def test_concurrent_same_key_serialized():
|
|
39
|
+
clock = make_clock()
|
|
40
|
+
sleeps = []
|
|
41
|
+
|
|
42
|
+
async def fake_sleep(d):
|
|
43
|
+
sleeps.append(d)
|
|
44
|
+
clock["t"] += d
|
|
45
|
+
|
|
46
|
+
rl = RateLimiter(5.0, clock=lambda: clock["t"], sleep=fake_sleep)
|
|
47
|
+
await asyncio.gather(rl.acquire("x.com"), rl.acquire("x.com"))
|
|
48
|
+
assert len(sleeps) == 1
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from groundhog_mcp.tools.read_url import read_url
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def test_read_url_rejects_unknown_format():
|
|
7
|
+
# Validation happens at the boundary, before any engine call.
|
|
8
|
+
with pytest.raises(ValueError):
|
|
9
|
+
await read_url("https://example.com/", format="json")
|