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 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())