pyrecrawl 0.2.1__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.
- pyrecrawl/__init__.py +9 -0
- pyrecrawl/cli.py +382 -0
- pyrecrawl/engines.py +685 -0
- pyrecrawl/server.py +269 -0
- pyrecrawl-0.2.1.dist-info/METADATA +217 -0
- pyrecrawl-0.2.1.dist-info/RECORD +9 -0
- pyrecrawl-0.2.1.dist-info/WHEEL +4 -0
- pyrecrawl-0.2.1.dist-info/entry_points.txt +2 -0
- pyrecrawl-0.2.1.dist-info/licenses/LICENSE +21 -0
pyrecrawl/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""PyreCrawl MCP — LLM-ready scraper/crawler combining Crawl4AI + Scrapling.
|
|
2
|
+
|
|
3
|
+
A self-hosted Firecrawl alternative with a smart auto-fallback ladder:
|
|
4
|
+
1. fast HTTP (Scrapling Fetcher, curl_cffi)
|
|
5
|
+
2. stealth browser (Scrapling StealthyFetcher, Cloudflare bypass)
|
|
6
|
+
3. LLM processing (Crawl4AI AsyncWebCrawler, BM25, deep crawl, structured extraction)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.1"
|
pyrecrawl/cli.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""PyreCrawl CLI — serve the MCP server or register it with AI agents.
|
|
2
|
+
|
|
3
|
+
pyrecrawl serve # stdio MCP server (what agents launch)
|
|
4
|
+
pyrecrawl setup # one-time: install browser engines
|
|
5
|
+
pyrecrawl install [agents...] # write MCP config into detected agents
|
|
6
|
+
pyrecrawl uninstall [agents...] # remove our entry from agent configs
|
|
7
|
+
|
|
8
|
+
Zero dependencies beyond the stdlib (argparse/json/re).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
_env = os.environ.get
|
|
23
|
+
|
|
24
|
+
def _is_dev_install() -> bool:
|
|
25
|
+
"""True when running from a git checkout (editable install) — keep the
|
|
26
|
+
launcher pointed at the local repo so changes apply immediately.
|
|
27
|
+
Priority: env-var > git checkout in CWD > git anywhere in file's ancestors."""
|
|
28
|
+
if _env("PYRECRAWL_DEV"):
|
|
29
|
+
return True
|
|
30
|
+
# User runs `pyrecrawl install` from the project dir → check CWD for .git
|
|
31
|
+
if (Path.cwd() / ".git").exists():
|
|
32
|
+
return True
|
|
33
|
+
# Fallback: walk up from __file__ to find .git
|
|
34
|
+
here = Path(__file__).resolve()
|
|
35
|
+
for p in here.parents:
|
|
36
|
+
if (p / ".git").exists():
|
|
37
|
+
return True
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _launch_cmd() -> list[str]:
|
|
42
|
+
"""How agents should launch the server. Dev = local venv; release = uvx."""
|
|
43
|
+
if _is_dev_install():
|
|
44
|
+
# Walk up from __file__ to find the repo root (has .git)
|
|
45
|
+
here = Path(__file__).resolve()
|
|
46
|
+
for p in here.parents:
|
|
47
|
+
if (p / ".git").exists():
|
|
48
|
+
py = p / ".venv" / "Scripts" / "python.exe"
|
|
49
|
+
if py.exists():
|
|
50
|
+
return [str(py), "-m", "pyrecrawl.server"]
|
|
51
|
+
# fallback: use the python that is running right now
|
|
52
|
+
return [sys.executable, "-m", "pyrecrawl.server"]
|
|
53
|
+
return [sys.executable, "-m", "pyrecrawl.server"]
|
|
54
|
+
return ["uvx", "--from", "pyrecrawl", "pyrecrawl", "serve"]
|
|
55
|
+
|
|
56
|
+
SERVER_NAME = "pyrecrawl"
|
|
57
|
+
# _launch_cmd() returns the right command for dev vs release.
|
|
58
|
+
# On install, we call _launch_cmd() to get the current entry.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _home() -> Path:
|
|
62
|
+
return Path.home()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _claude_desktop_config() -> Path:
|
|
66
|
+
if sys.platform == "win32":
|
|
67
|
+
return _home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json"
|
|
68
|
+
if sys.platform == "darwin":
|
|
69
|
+
return _home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
70
|
+
return _home() / ".config" / "Claude" / "claude_desktop_config.json"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Agent registry: name -> (detect, config_path, writer)
|
|
75
|
+
# A writer receives (path, command, args) and must merge our entry in-place,
|
|
76
|
+
# creating parents as needed. It returns a human-readable status string.
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _write_json_servers(path: Path, command: list[str], args: list[str],
|
|
80
|
+
root_key: str, entry_key: str) -> str:
|
|
81
|
+
"""Generic JSON config writer: {root_key: {entry_key: {command, args}}}."""
|
|
82
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
data: dict[str, Any] = {}
|
|
84
|
+
if path.exists():
|
|
85
|
+
try:
|
|
86
|
+
data = json.loads(path.read_text(encoding="utf-8") or "{}")
|
|
87
|
+
except json.JSONDecodeError as e:
|
|
88
|
+
return f"REFUSED — {path} is not valid JSON ({e}); fix it manually"
|
|
89
|
+
backup = path.with_suffix(path.suffix + ".bak")
|
|
90
|
+
if not backup.exists():
|
|
91
|
+
shutil.copy2(path, backup)
|
|
92
|
+
servers = data.setdefault(root_key, {})
|
|
93
|
+
if not isinstance(servers, dict):
|
|
94
|
+
return f"REFUSED — '{root_key}' in {path} is not an object"
|
|
95
|
+
entry: dict[str, Any] = {"command": command[0], "args": args}
|
|
96
|
+
servers[SERVER_NAME] = entry
|
|
97
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
98
|
+
return f"wrote {path}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _write_vscode(path: Path, command: list[str], args: list[str]) -> str:
|
|
102
|
+
data: dict[str, Any] = {"command": command[0], "args": args, "type": "stdio"}
|
|
103
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
doc: dict[str, Any] = {}
|
|
105
|
+
if path.exists():
|
|
106
|
+
try:
|
|
107
|
+
doc = json.loads(path.read_text(encoding="utf-8") or "{}")
|
|
108
|
+
except json.JSONDecodeError as e:
|
|
109
|
+
return f"REFUSED — {path} is not valid JSON ({e})"
|
|
110
|
+
backup = path.with_suffix(path.suffix + ".bak")
|
|
111
|
+
if not backup.exists():
|
|
112
|
+
shutil.copy2(path, backup)
|
|
113
|
+
doc.setdefault("servers", {})[SERVER_NAME] = data
|
|
114
|
+
path.write_text(json.dumps(doc, indent=4) + "\n", encoding="utf-8")
|
|
115
|
+
return f"wrote {path}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _write_codex(path: Path, command: list[str], args: list[str]) -> str:
|
|
119
|
+
"""Codex uses ~/.codex/config.toml — append/replace a [mcp_servers.pyrecrawl] block."""
|
|
120
|
+
block = (
|
|
121
|
+
f"[mcp_servers.{SERVER_NAME}]\n"
|
|
122
|
+
f'command = "{command[0]}"\n'
|
|
123
|
+
f"args = [{', '.join(json.dumps(a) for a in args)}]\n"
|
|
124
|
+
)
|
|
125
|
+
if path.parent.exists() is False:
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
128
|
+
if text:
|
|
129
|
+
backup = path.with_suffix(path.suffix + ".bak")
|
|
130
|
+
if not backup.exists():
|
|
131
|
+
shutil.copy2(path, backup)
|
|
132
|
+
# drop any previous block of ours
|
|
133
|
+
pattern = re.compile(
|
|
134
|
+
rf"\[mcp_servers\.{SERVER_NAME}\][^\[]*", re.MULTILINE)
|
|
135
|
+
if pattern.search(text):
|
|
136
|
+
text = pattern.sub("", text).rstrip() + "\n\n"
|
|
137
|
+
path.write_text((text.rstrip() + "\n\n" if text.strip() else "") + block, encoding="utf-8")
|
|
138
|
+
return f"wrote {path}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _write_hermes(path: Path, command: list[str], args: list[str]) -> str:
|
|
142
|
+
"""Hermes uses config.yaml with an mcp_servers: map — append a stdio entry."""
|
|
143
|
+
yaml_entry = (
|
|
144
|
+
f" {SERVER_NAME}:\n"
|
|
145
|
+
f" command: {command[0]}\n"
|
|
146
|
+
f" args:\n" + "".join(f" - {a}\n" for a in args) +
|
|
147
|
+
" enabled: true\n"
|
|
148
|
+
)
|
|
149
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
151
|
+
if text:
|
|
152
|
+
backup = path.with_suffix(path.suffix + ".bak")
|
|
153
|
+
if not backup.exists():
|
|
154
|
+
shutil.copy2(path, backup)
|
|
155
|
+
# remove previous entry of ours (2-space indented under mcp_servers)
|
|
156
|
+
text = re.sub(rf"\n {SERVER_NAME}:\n(?: .*\n| .*\n)*", "\n", text)
|
|
157
|
+
if "mcp_servers:" in text:
|
|
158
|
+
# insert right after the mcp_servers: line
|
|
159
|
+
text = text.replace("mcp_servers:", "mcp_servers:\n" + yaml_entry.rstrip("\n"), 1)
|
|
160
|
+
else:
|
|
161
|
+
text = text.rstrip("\n") + "\n\nmcp_servers:\n" + yaml_entry
|
|
162
|
+
else:
|
|
163
|
+
text = "mcp_servers:\n" + yaml_entry
|
|
164
|
+
path.write_text(text, encoding="utf-8")
|
|
165
|
+
return f"wrote {path}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _hermes_config_path() -> Path:
|
|
169
|
+
"""Pick the Hermes config: prefer one with mcp_servers block, then any existing, else default."""
|
|
170
|
+
candidates = [
|
|
171
|
+
_home() / "AppData" / "Local" / "hermes" / "config.yaml",
|
|
172
|
+
_home() / ".hermes" / "config.yaml",
|
|
173
|
+
]
|
|
174
|
+
for p in candidates:
|
|
175
|
+
if p.exists() and "mcp_servers" in p.read_text(encoding="utf-8", errors="ignore"):
|
|
176
|
+
return p
|
|
177
|
+
for p in candidates:
|
|
178
|
+
if p.exists():
|
|
179
|
+
return p
|
|
180
|
+
return candidates[0]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _write_claude_code(cwd: Path, command: list[str], args: list[str]) -> str:
|
|
184
|
+
"""Project-scoped .mcp.json in the current directory."""
|
|
185
|
+
return _write_json_mcp(cwd / ".mcp.json", command, args)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _write_json_mcp(path: Path, command: list[str], args: list[str]) -> str:
|
|
189
|
+
return _write_json_servers(path, command, args, root_key="mcpServers", entry_key=SERVER_NAME)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
AGENTS: dict[str, dict[str, Any]] = {
|
|
193
|
+
"claude-desktop": {
|
|
194
|
+
"detect": lambda: _claude_desktop_config().parent.exists(),
|
|
195
|
+
"path": _claude_desktop_config,
|
|
196
|
+
"write": lambda p, cmd, args: _write_json_mcp(p, cmd, args),
|
|
197
|
+
"hint": "restart Claude Desktop fully (tray → Quit)",
|
|
198
|
+
},
|
|
199
|
+
"claude-code": {
|
|
200
|
+
"detect": lambda: shutil.which("claude") is not None,
|
|
201
|
+
"path": lambda: Path.cwd() / ".mcp.json",
|
|
202
|
+
"write": lambda p, cmd, args: _write_json_mcp(p, cmd, args),
|
|
203
|
+
"hint": "project-scoped; restart the claude session",
|
|
204
|
+
},
|
|
205
|
+
"cursor": {
|
|
206
|
+
"detect": lambda: (_home() / ".cursor").exists(),
|
|
207
|
+
"path": lambda: _home() / ".cursor" / "mcp.json",
|
|
208
|
+
"write": lambda p, cmd, args: _write_json_mcp(p, cmd, args),
|
|
209
|
+
"hint": "Cursor → Settings → MCP to verify",
|
|
210
|
+
},
|
|
211
|
+
"vscode": {
|
|
212
|
+
"detect": lambda: shutil.which("code") is not None,
|
|
213
|
+
"path": lambda: Path.cwd() / ".vscode" / "mcp.json",
|
|
214
|
+
"write": _write_vscode,
|
|
215
|
+
"hint": "project-scoped; Copilot Chat → Install MCP Server",
|
|
216
|
+
},
|
|
217
|
+
"codex": {
|
|
218
|
+
"detect": lambda: (_home() / ".codex").exists() or shutil.which("codex") is not None,
|
|
219
|
+
"path": lambda: _home() / ".codex" / "config.toml",
|
|
220
|
+
"write": _write_codex,
|
|
221
|
+
"hint": "restart codex CLI",
|
|
222
|
+
},
|
|
223
|
+
"opencode": {
|
|
224
|
+
"detect": lambda: (_home() / ".config" / "opencode").exists() or shutil.which("opencode") is not None,
|
|
225
|
+
"path": lambda: _home() / ".config" / "opencode" / "opencode.json",
|
|
226
|
+
"write": lambda p, cmd, args: _write_opencode(p, cmd, args),
|
|
227
|
+
"hint": "restart opencode",
|
|
228
|
+
},
|
|
229
|
+
"hermes": {
|
|
230
|
+
"detect": lambda: (_home() / ".hermes").exists() or (_home() / "AppData" / "Local" / "hermes").exists(),
|
|
231
|
+
"path": _hermes_config_path,
|
|
232
|
+
"write": _write_hermes,
|
|
233
|
+
"hint": "start a NEW Hermes session to pick up the tools",
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _write_opencode(path: Path, command: list[str], args: list[str]) -> str:
|
|
239
|
+
entry = {"type": "local", "command": [command[0], *args], "enabled": True}
|
|
240
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
doc: dict[str, Any] = {}
|
|
242
|
+
if path.exists():
|
|
243
|
+
try:
|
|
244
|
+
doc = json.loads(path.read_text(encoding="utf-8") or "{}")
|
|
245
|
+
except json.JSONDecodeError as e:
|
|
246
|
+
return f"REFUSED — {path} is not valid JSON ({e})"
|
|
247
|
+
backup = path.with_suffix(path.suffix + ".bak")
|
|
248
|
+
if not backup.exists():
|
|
249
|
+
shutil.copy2(path, backup)
|
|
250
|
+
doc.setdefault("mcp", {})[SERVER_NAME] = entry
|
|
251
|
+
path.write_text(json.dumps(doc, indent=2) + "\n", encoding="utf-8")
|
|
252
|
+
return f"wrote {path}"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# Commands
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def cmd_serve(_: argparse.Namespace) -> int:
|
|
260
|
+
from .server import main as serve_main
|
|
261
|
+
serve_main()
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def cmd_setup(_: argparse.Namespace) -> int:
|
|
266
|
+
"""One-time browser engine install (playwright chromium + scrapling)."""
|
|
267
|
+
steps = [
|
|
268
|
+
[sys.executable, "-m", "playwright", "install", "chromium"],
|
|
269
|
+
["scrapling", "install"],
|
|
270
|
+
]
|
|
271
|
+
for s in steps:
|
|
272
|
+
print(f"$ {' '.join(s)}")
|
|
273
|
+
rc = subprocess.call(s)
|
|
274
|
+
if rc != 0:
|
|
275
|
+
print(f" step failed (exit {rc}) — fix and re-run `pyrecrawl setup`", file=sys.stderr)
|
|
276
|
+
return rc
|
|
277
|
+
print("Engines ready.")
|
|
278
|
+
return 0
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _resolve_targets(names: list[str]) -> list[str]:
|
|
282
|
+
if not names or names == ["all"]:
|
|
283
|
+
detected = [n for n, a in AGENTS.items() if a["detect"]()]
|
|
284
|
+
return detected or list(AGENTS)
|
|
285
|
+
unknown = [n for n in names if n not in AGENTS]
|
|
286
|
+
if unknown:
|
|
287
|
+
print(f"Unknown agent(s): {unknown}\nAvailable: {', '.join(AGENTS)}", file=sys.stderr)
|
|
288
|
+
sys.exit(2)
|
|
289
|
+
return names
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def cmd_install(ns: argparse.Namespace) -> int:
|
|
293
|
+
targets = _resolve_targets(ns.agents)
|
|
294
|
+
if not targets:
|
|
295
|
+
print("No agents detected. Pass names explicitly, e.g. `pyrecrawl install claude-desktop`.")
|
|
296
|
+
return 1
|
|
297
|
+
print(f"Registering '{SERVER_NAME}' with: {', '.join(targets)}")
|
|
298
|
+
rc = 0
|
|
299
|
+
cmd_args = _launch_cmd()
|
|
300
|
+
for name in targets:
|
|
301
|
+
agent = AGENTS[name]
|
|
302
|
+
path: Path = agent["path"]()
|
|
303
|
+
if ns.dry_run:
|
|
304
|
+
print(f" [{name}] would write -> {path} (launch: {cmd_args[0]} ...)")
|
|
305
|
+
continue
|
|
306
|
+
status = agent["write"](path, [cmd_args[0]], cmd_args[1:])
|
|
307
|
+
print(f" [{name}] {status}")
|
|
308
|
+
print(f" {agent['hint']}")
|
|
309
|
+
return rc
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def cmd_uninstall(ns: argparse.Namespace) -> int:
|
|
313
|
+
targets = _resolve_targets(ns.agents)
|
|
314
|
+
for name in targets:
|
|
315
|
+
path: Path = AGENTS[name]["path"]()
|
|
316
|
+
if not path.exists():
|
|
317
|
+
print(f" [{name}] nothing to remove ({path} missing)")
|
|
318
|
+
continue
|
|
319
|
+
text = path.read_text(encoding="utf-8")
|
|
320
|
+
if path.suffix == ".json":
|
|
321
|
+
try:
|
|
322
|
+
doc = json.loads(text)
|
|
323
|
+
except json.JSONDecodeError:
|
|
324
|
+
print(f" [{name}] REFUSED — invalid JSON at {path}")
|
|
325
|
+
continue
|
|
326
|
+
changed = False
|
|
327
|
+
for key in ("mcpServers", "servers", "mcp"):
|
|
328
|
+
if isinstance(doc.get(key), dict) and SERVER_NAME in doc[key]:
|
|
329
|
+
del doc[key][SERVER_NAME]
|
|
330
|
+
changed = True
|
|
331
|
+
if changed:
|
|
332
|
+
path.write_text(json.dumps(doc, indent=2) + "\n", encoding="utf-8")
|
|
333
|
+
print(f" [{name}] removed from {path}")
|
|
334
|
+
else:
|
|
335
|
+
print(f" [{name}] not present in {path}")
|
|
336
|
+
elif path.suffix == ".toml":
|
|
337
|
+
new = re.sub(rf"\[mcp_servers\.{SERVER_NAME}\][^\[]*", "", text)
|
|
338
|
+
path.write_text(new, encoding="utf-8")
|
|
339
|
+
print(f" [{name}] removed from {path}")
|
|
340
|
+
elif path.suffix in (".yaml", ".yml"):
|
|
341
|
+
new = re.sub(rf"\n {SERVER_NAME}:\n(?: .*\n| .*\n)*", "\n", text)
|
|
342
|
+
path.write_text(new, encoding="utf-8")
|
|
343
|
+
print(f" [{name}] removed from {path}")
|
|
344
|
+
return 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
348
|
+
from . import __version__
|
|
349
|
+
p = argparse.ArgumentParser(prog="pyrecrawl", description="PyreCrawl MCP server")
|
|
350
|
+
p.add_argument("--version", action="version", version=f"pyrecrawl {__version__}")
|
|
351
|
+
sub = p.add_subparsers(dest="command")
|
|
352
|
+
|
|
353
|
+
sp = sub.add_parser("serve", help="run the stdio MCP server (default)")
|
|
354
|
+
sp.set_defaults(fn=cmd_serve)
|
|
355
|
+
|
|
356
|
+
st = sub.add_parser("setup", help="one-time browser engine install (playwright + scrapling)")
|
|
357
|
+
st.set_defaults(fn=cmd_setup)
|
|
358
|
+
|
|
359
|
+
ins = sub.add_parser("install", help="register the MCP server with your AI agents")
|
|
360
|
+
ins.add_argument("agents", nargs="*", help=f"{'all'} or any of: {', '.join(AGENTS)}")
|
|
361
|
+
ins.add_argument("--dry-run", action="store_true", help="show what would be written")
|
|
362
|
+
ins.set_defaults(fn=cmd_install)
|
|
363
|
+
|
|
364
|
+
un = sub.add_parser("uninstall", help="remove our entry from agent configs")
|
|
365
|
+
un.add_argument("agents", nargs="*", help="same names as `install`")
|
|
366
|
+
un.set_defaults(fn=cmd_uninstall)
|
|
367
|
+
return p
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def main(argv: list[str] | None = None) -> int:
|
|
371
|
+
parser = build_parser()
|
|
372
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
373
|
+
if not argv or argv[0].startswith("-") and argv[0] not in ("-h", "--help", "--version"):
|
|
374
|
+
argv = ["serve", *argv] # bare `pyrecrawl` = serve (MCP clients may call it directly)
|
|
375
|
+
ns = parser.parse_args(argv)
|
|
376
|
+
if getattr(ns, "fn", None) is None:
|
|
377
|
+
ns = parser.parse_args(["serve", *argv])
|
|
378
|
+
return ns.fn(ns)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
sys.exit(main())
|