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 +6 -0
- jevcrawl/cli.py +159 -0
- jevcrawl/config.py +63 -0
- jevcrawl/crawl/__init__.py +8 -0
- jevcrawl/crawl/errors.py +22 -0
- jevcrawl/crawl/frontier.py +98 -0
- jevcrawl/crawl/loop.py +452 -0
- jevcrawl/crawl/map.py +33 -0
- jevcrawl/crawl/pack.py +52 -0
- jevcrawl/crawl/scrape.py +74 -0
- jevcrawl/crawl/sitemap.py +123 -0
- jevcrawl/crawl/stop.py +33 -0
- jevcrawl/decide/__init__.py +1 -0
- jevcrawl/decide/cache.py +57 -0
- jevcrawl/decide/client.py +209 -0
- jevcrawl/decide/cost.py +38 -0
- jevcrawl/decide/decider.py +228 -0
- jevcrawl/decide/packing.py +67 -0
- jevcrawl/decide/questions.py +126 -0
- jevcrawl/doctor.py +38 -0
- jevcrawl/fetch/__init__.py +1 -0
- jevcrawl/fetch/http.py +120 -0
- jevcrawl/fetch/jsdetect.py +37 -0
- jevcrawl/log.py +30 -0
- jevcrawl/mcp_server.py +180 -0
- jevcrawl/models.py +134 -0
- jevcrawl/segment/__init__.py +1 -0
- jevcrawl/segment/blocks.py +540 -0
- jevcrawl/segment/boilerplate.py +70 -0
- jevcrawl/segment/lang.py +22 -0
- jevcrawl/segment/links.py +294 -0
- jevcrawl-0.1.0.dist-info/METADATA +271 -0
- jevcrawl-0.1.0.dist-info/RECORD +36 -0
- jevcrawl-0.1.0.dist-info/WHEEL +4 -0
- jevcrawl-0.1.0.dist-info/entry_points.txt +2 -0
- jevcrawl-0.1.0.dist-info/licenses/LICENSE +202 -0
jevcrawl/__init__.py
ADDED
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"]
|
jevcrawl/crawl/errors.py
ADDED
|
@@ -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
|