jevcrawl 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.
jevcrawl/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """jevcrawl: crawl websites for AI agents, with Jev making every crawl decision."""
2
+
3
+ __version__ = "0.1.0"
4
+ REPO_URL = "https://github.com/RayenMlayeh/jevcrawl"
5
+
6
+ __all__ = ["REPO_URL", "__version__"]
jevcrawl/cli.py ADDED
@@ -0,0 +1,159 @@
1
+ """Command line entry point: ``jevcrawl`` (MCP server on stdio) and ``jevcrawl <command>``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ from typing import Any
10
+
11
+ from jevcrawl import __version__
12
+ from jevcrawl.config import Config, load_config
13
+ from jevcrawl.crawl import CrawlError, crawl, map_site, scrape
14
+ from jevcrawl.crawl.errors import AUTH_HELP
15
+ from jevcrawl.log import configure_logging
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ """The argument parser. With no command the MCP server starts."""
20
+ parser = argparse.ArgumentParser(
21
+ prog="jevcrawl",
22
+ description="Crawl websites for AI agents; Jev makes every crawl decision. "
23
+ "With no command, run the MCP server on stdio.",
24
+ )
25
+ parser.add_argument("--version", action="version", version=f"jevcrawl {__version__}")
26
+ parser.add_argument("-v", "--verbose", action="count", default=0, help="log to stderr")
27
+ sub = parser.add_subparsers(dest="command")
28
+
29
+ def common(p: argparse.ArgumentParser) -> None:
30
+ p.add_argument("--json", action="store_true", help="print the full result as JSON")
31
+
32
+ p = sub.add_parser("crawl", help="crawl from URL toward a goal")
33
+ p.add_argument("url")
34
+ p.add_argument("--goal", required=True, help="a specific question to answer")
35
+ p.add_argument("--max-pages", type=int, default=30)
36
+ p.add_argument("--max-seconds", type=float, default=120)
37
+ p.add_argument("--return-tokens", type=int, default=4000)
38
+ p.add_argument("--max-usd", type=float, default=0.05, help="Jev spend limit (max_decision_usd)")
39
+ common(p)
40
+
41
+ p = sub.add_parser("scrape", help="fetch one page, optionally pruned to a goal")
42
+ p.add_argument("url")
43
+ p.add_argument("--goal")
44
+ p.add_argument("--max-usd", type=float, default=0.05)
45
+ common(p)
46
+
47
+ p = sub.add_parser("map", help="rank the URLs of a site for a goal")
48
+ p.add_argument("url")
49
+ p.add_argument("--goal", required=True)
50
+ p.add_argument("--limit", type=int, default=50)
51
+ p.add_argument("--max-pages", type=int, default=10)
52
+ p.add_argument("--max-usd", type=float, default=0.05)
53
+ common(p)
54
+
55
+ sub.add_parser("doctor", help="check the API key and the connection to Jev")
56
+ return parser
57
+
58
+
59
+ async def _run(args: argparse.Namespace, config: Config) -> dict[str, Any]:
60
+ if args.command == "crawl":
61
+ result = await crawl(
62
+ args.goal,
63
+ args.url,
64
+ config,
65
+ max_pages=args.max_pages,
66
+ max_seconds=args.max_seconds,
67
+ return_tokens=args.return_tokens,
68
+ max_decision_usd=args.max_usd,
69
+ )
70
+ return result.to_dict()
71
+ if args.command == "scrape":
72
+ return (await scrape(args.url, config, args.goal, max_decision_usd=args.max_usd)).to_dict()
73
+ return await map_site(
74
+ args.url,
75
+ args.goal,
76
+ config,
77
+ limit=args.limit,
78
+ max_pages=args.max_pages,
79
+ max_decision_usd=args.max_usd,
80
+ )
81
+
82
+
83
+ def format_result(command: str, data: dict[str, Any]) -> str:
84
+ """Human-readable rendering of a result."""
85
+ lines: list[str] = []
86
+ if command == "map":
87
+ for item in data["urls"]:
88
+ lines.append(f"{item['priority']:.3f} {item['url']} ({item['anchor'] or '-'})")
89
+ if not data["urls"]:
90
+ lines.append("(no promising links found)")
91
+ else:
92
+ for title, key in (("kept", "blocks"), ("uncertain", "uncertain")):
93
+ for block in data[key]:
94
+ score = " - " if block["relevance"] is None else f"{block['relevance']:.2f}"
95
+ where = f"{block['url']} [{block['char_start']}:{block['char_end']}]"
96
+ heading = f" ({block['heading_path']})" if block["heading_path"] else ""
97
+ lines.append(f"[{title} {score}] {where}{heading}")
98
+ lines.append(" " + block["text"].replace("\n", "\n "))
99
+ if not (data["blocks"] or data["uncertain"]):
100
+ lines.append("(no relevant blocks found)")
101
+ s = data["stats"]
102
+ lines.append("")
103
+ summary = f"pages={s['pages_fetched']} stopped={s['stopped_because']}"
104
+ if "tokens_raw" in s:
105
+ summary += f" tokens={s['tokens_raw']:,}->{s['tokens_returned']:,}"
106
+ summary += f" jev_calls={s['jev_calls']} cost=${s['jev_cost_usd']:.4f}"
107
+ if "elapsed_seconds" in s:
108
+ summary += f" time={s['elapsed_seconds']:.1f}s"
109
+ if s["degraded"]:
110
+ summary += f" degraded ({s['degraded_reason']})"
111
+ lines.append(summary)
112
+ if s["needs_js_urls"]:
113
+ lines.append(f"needs JavaScript (not rendered): {', '.join(s['needs_js_urls'])}")
114
+ return "\n".join(lines)
115
+
116
+
117
+ def _utf8_streams() -> None:
118
+ """Page text is arbitrary Unicode; never let a legacy console encoding crash the output."""
119
+ for stream in (sys.stdout, sys.stderr):
120
+ reconfigure = getattr(stream, "reconfigure", None)
121
+ if reconfigure is not None:
122
+ reconfigure(encoding="utf-8", errors="replace")
123
+
124
+
125
+ def main(argv: list[str] | None = None) -> int:
126
+ """Console script ``jevcrawl``."""
127
+ _utf8_streams()
128
+ args = build_parser().parse_args(argv)
129
+ configure_logging({0: "WARNING", 1: "INFO"}.get(args.verbose, "DEBUG"))
130
+ if args.command is None:
131
+ from jevcrawl.mcp_server import main as serve
132
+
133
+ serve()
134
+ return 0
135
+ if args.command == "doctor":
136
+ from jevcrawl.doctor import run_doctor
137
+
138
+ return asyncio.run(run_doctor(load_config()))
139
+ try:
140
+ data = asyncio.run(_run(args, load_config()))
141
+ if data["stats"]["stopped_because"] == "auth_error":
142
+ raise CrawlError("auth_error", AUTH_HELP)
143
+ except CrawlError as exc:
144
+ error = exc.to_dict()
145
+ if args.json:
146
+ print(json.dumps(error, indent=2))
147
+ else:
148
+ print(f"error: {error['message']}", file=sys.stderr)
149
+ return 1
150
+ print(
151
+ json.dumps(data, indent=2, ensure_ascii=False)
152
+ if args.json
153
+ else format_result(args.command, data)
154
+ )
155
+ return 0
156
+
157
+
158
+ if __name__ == "__main__":
159
+ raise SystemExit(main())
jevcrawl/config.py ADDED
@@ -0,0 +1,63 @@
1
+ """Settings. Four environment variables; everything else is a sensible default."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ DEFAULT_CACHE_DIR = Path("~/.jevcrawl/cache").expanduser()
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Thresholds:
15
+ """Decision thresholds, calibrated on real Jev scores (see README, "How it works")."""
16
+
17
+ keep: float = 0.35 # keep a block at or above this (recall first)
18
+ uncertain_low: float = 0.20 # blocks in [uncertain_low, keep) are returned as "uncertain"
19
+ stop: float = 0.85 # stop when "do the kept blocks answer the goal?" reaches this
20
+ link_floor: float = 0.10 # links below this priority wait in a reserve
21
+ hub_weight: float = 0.6 # weight of "leads to a section about the goal" in link priority
22
+ # How much a page's best block score scales the links found on it. Small on purpose: on
23
+ # listing pages the items are links, not answers, so their block scores are near 0.
24
+ page_weight: float = 0.2
25
+ depth_decay: float = 0.9 # per click from the start page
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Config:
30
+ """Resolved settings for one run."""
31
+
32
+ api_key: str | None = None
33
+ base_url: str = "https://api.typesafe.ai" # any Jev-compatible endpoint works
34
+ model: str = "jev-1.13.0" # pinned: thresholds are calibrated per model version
35
+ price_per_mtok: float = 0.042 # USD per million Jev input tokens; output is free
36
+ cache_dir: Path = DEFAULT_CACHE_DIR
37
+ same_domain: bool = True # stay on the start host and its subdomains
38
+ links_per_page: int = 100 # links per page that get a Jev question
39
+ block_tokens: int = 300 # target size of a text block
40
+ max_kept_per_page: int = 10
41
+ fetch_concurrency: int = 4 # pages fetched at the same time
42
+ use_sitemap: bool = True # offer sitemap URLs that share words with the goal as links
43
+ sitemap_links: int = 60
44
+ keep_layout_tags: bool = False # treat nav/aside/footer text as content
45
+ thresholds: Thresholds = field(default_factory=Thresholds)
46
+
47
+
48
+ def load_config(env: Mapping[str, str] | None = None) -> Config:
49
+ """Settings from ``TYPESAFE_API_KEY``, ``JEVCRAWL_BASE_URL``, ``JEVCRAWL_MODEL`` and
50
+ ``JEVCRAWL_CACHE_DIR``; everything else keeps its default."""
51
+ env = os.environ if env is None else env
52
+
53
+ def get(name: str) -> str | None:
54
+ return env.get(name, "").strip() or None
55
+
56
+ defaults = Config()
57
+ cache_dir = get("JEVCRAWL_CACHE_DIR")
58
+ return Config(
59
+ api_key=get("TYPESAFE_API_KEY"),
60
+ base_url=(get("JEVCRAWL_BASE_URL") or defaults.base_url).rstrip("/"),
61
+ model=get("JEVCRAWL_MODEL") or defaults.model,
62
+ cache_dir=Path(cache_dir).expanduser() if cache_dir else defaults.cache_dir,
63
+ )
@@ -0,0 +1,8 @@
1
+ """Crawl package: frontier, loop, stop conditions, result packing, scrape and map."""
2
+
3
+ from jevcrawl.crawl.errors import CrawlError
4
+ from jevcrawl.crawl.loop import crawl
5
+ from jevcrawl.crawl.map import map_site
6
+ from jevcrawl.crawl.scrape import scrape
7
+
8
+ __all__ = ["CrawlError", "crawl", "map_site", "scrape"]
@@ -0,0 +1,22 @@
1
+ """Expected failures of a crawl, with a stable code and a message that says what to do."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CrawlError(Exception):
7
+ """An expected error. Tools return ``{"error": code, "message": ...}`` instead of raising."""
8
+
9
+ def __init__(self, code: str, message: str) -> None:
10
+ super().__init__(message)
11
+ self.code = code
12
+ self.message = message
13
+
14
+ def to_dict(self) -> dict[str, str]:
15
+ """The error payload returned to agents."""
16
+ return {"error": self.code, "message": self.message}
17
+
18
+
19
+ AUTH_HELP = (
20
+ "The Jev API key is missing or was rejected. Set the TYPESAFE_API_KEY environment variable "
21
+ "(create a key at https://console.typesafe.ai/)."
22
+ )
@@ -0,0 +1,98 @@
1
+ """Best-first frontier: always fetch the link with the highest priority next."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ import itertools
7
+ from dataclasses import dataclass
8
+
9
+ from jevcrawl.config import Thresholds
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class FrontierItem:
14
+ """A queued URL and where it came from."""
15
+
16
+ url: str
17
+ priority: float
18
+ depth: int
19
+ parent_url: str | None = None
20
+ anchor: str = ""
21
+ p_target: float | None = None
22
+ p_hub: float | None = None
23
+
24
+
25
+ def link_priority(
26
+ p_target: float, p_hub: float, page_rel: float, depth: int, profile: Thresholds
27
+ ) -> float:
28
+ """``max(p_target, hub_weight * p_hub) * page_factor * depth_decay ** (depth + 1)``.
29
+
30
+ ``page_factor = (1 - page_weight) + page_weight * page_rel``.
31
+
32
+ ``depth`` is the depth of the page the link was found on; ``page_rel`` is the highest block
33
+ relevance on that page (0 if none).
34
+ """
35
+ best = max(p_target, profile.hub_weight * p_hub)
36
+ page_factor = (1 - profile.page_weight) + profile.page_weight * page_rel
37
+ return best * page_factor * profile.depth_decay ** (depth + 1)
38
+
39
+
40
+ class Frontier:
41
+ """A heap of ``(-priority, seq, item)``. Each URL is queued at most once.
42
+
43
+ :meth:`breadth_first` switches to ``(depth, seq)`` ordering for degraded mode.
44
+ """
45
+
46
+ def __init__(self) -> None:
47
+ self._seq = itertools.count()
48
+ self._heap: list[tuple[float, int, FrontierItem]] = []
49
+ self._queued: set[str] = set()
50
+ self._bfs = False
51
+
52
+ def __len__(self) -> int:
53
+ return len(self._heap)
54
+
55
+ @property
56
+ def queued(self) -> set[str]:
57
+ """URLs currently waiting in the frontier."""
58
+ return self._queued
59
+
60
+ @property
61
+ def is_breadth_first(self) -> bool:
62
+ """True once degraded mode switched the ordering."""
63
+ return self._bfs
64
+
65
+ def push(
66
+ self,
67
+ url: str,
68
+ priority: float,
69
+ depth: int,
70
+ parent_url: str | None = None,
71
+ *,
72
+ anchor: str = "",
73
+ p_target: float | None = None,
74
+ p_hub: float | None = None,
75
+ ) -> bool:
76
+ """Queue ``url``; returns False if it was already queued."""
77
+ if url in self._queued:
78
+ return False
79
+ item = FrontierItem(url, priority, depth, parent_url, anchor, p_target, p_hub)
80
+ heapq.heappush(self._heap, (self._key(item), next(self._seq), item))
81
+ self._queued.add(url)
82
+ return True
83
+
84
+ def pop(self) -> FrontierItem:
85
+ """Remove and return the next item to fetch."""
86
+ _, _, item = heapq.heappop(self._heap)
87
+ self._queued.discard(item.url)
88
+ return item
89
+
90
+ def breadth_first(self) -> None:
91
+ """Re-order the queue by depth (then arrival order); new pushes follow the same rule."""
92
+ self._bfs = True
93
+ old = list(self._heap)
94
+ self._heap = [(self._key(item), seq, item) for _, seq, item in old]
95
+ heapq.heapify(self._heap)
96
+
97
+ def _key(self, item: FrontierItem) -> float:
98
+ return float(item.depth) if self._bfs else -item.priority