lean-browser-use 0.0.0-stage → 0.1.0
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.
- package/CHANGELOG.md +21 -0
- package/CREDITS.md +18 -0
- package/LICENSE +21 -0
- package/README.md +159 -2
- package/agents/aun-browser-runner.md +48 -0
- package/bin/lean-browser-use.mjs +3 -0
- package/briefs/browser-read.md +63 -0
- package/briefs/helper-read.md +63 -0
- package/docs/PROVENANCE.md +32 -0
- package/docs/benchmark.md +91 -0
- package/docs/guards.md +54 -0
- package/docs/how-it-works.md +27 -0
- package/docs/install.md +78 -0
- package/docs/measure.md +53 -0
- package/docs/reader.md +39 -0
- package/docs/search.md +16 -0
- package/hooks/batch-nudge.mjs +14 -0
- package/hooks/batch-nudge.sh +8 -0
- package/hooks/common.mjs +71 -0
- package/hooks/hooks.settings.json +58 -0
- package/hooks/pick-guard.mjs +27 -0
- package/hooks/pick-guard.sh +8 -0
- package/hooks/retry-guard.mjs +69 -0
- package/hooks/retry-guard.sh +8 -0
- package/llms.txt +40 -0
- package/package.json +61 -4
- package/proof/benchmark.json +166 -0
- package/proof/results.json +241 -0
- package/reader/README.md +48 -0
- package/reader/crawl_helper.py +256 -0
- package/reader/reader +14 -0
- package/reader/reader_base.py +106 -0
- package/reader/reader_compile.py +106 -0
- package/reader/reader_crawl.py +67 -0
- package/reader/reader_extract.py +261 -0
- package/reader/reader_fetch.py +148 -0
- package/reader/reader_runtime.py +199 -0
- package/reader/reader_test_harness.py +173 -0
- package/reader/reader_test_support.py +214 -0
- package/reader/specs/claude-pricing.json +223 -0
- package/reader/specs/npm-package.json +67 -0
- package/reader/test_reader.py +345 -0
- package/reader/test_reader_1.py +309 -0
- package/reader/test_reader_2.py +348 -0
- package/reader/test_reader_3.py +254 -0
- package/reader/test_reader_contract.py +57 -0
- package/reader/testdata/claude-pricing.html +4 -0
- package/reader/testdata/compile_stub.py +24 -0
- package/reader/testdata/npm-react.html +4 -0
- package/reader/testdata/npm-schema.json +14 -0
- package/reader/testdata/npm-zod.html +4 -0
- package/reader/testdata/pricing-schema.json +24 -0
- package/searxng/README.md +29 -0
- package/searxng/compose.yml +21 -0
- package/searxng/core-config/settings.yml +26 -0
- package/searxng/deploy.sh +36 -0
- package/searxng/searx +73 -0
- package/searxng/test_searx.py +107 -0
- package/searxng/test_searx_offline.py +67 -0
- package/skills/lean-browser-use/SKILL.md +35 -0
- package/skills/lean-browser-use/references/cleanup.md +17 -0
- package/skills/lean-browser-use/references/cost-model.md +11 -0
- package/skills/lean-browser-use/references/draft-offline.md +9 -0
- package/skills/lean-browser-use/references/failure-modes.md +16 -0
- package/skills/lean-browser-use/references/logged-in-run.md +23 -0
- package/skills/lean-browser-use/references/packet-principle.md +15 -0
- package/skills/lean-browser-use/references/route-by-job.md +18 -0
- package/skills/lean-browser-use/references/runner-rules.md +27 -0
- package/skills/lean-browser-use/references/verify.md +9 -0
- package/src/cli.mjs +42 -0
- package/src/doctor.mjs +60 -0
- package/src/install.mjs +116 -0
- package/src/measure.mjs +64 -0
- package/src/paths.mjs +51 -0
- package/src/uninstall.mjs +68 -0
- package/src/util.mjs +35 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Small unittest fixture adapter for the retained regression functions."""
|
|
2
|
+
from contextlib import ExitStack, redirect_stdout, redirect_stderr
|
|
3
|
+
import inspect
|
|
4
|
+
import io
|
|
5
|
+
import itertools
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
import unittest
|
|
14
|
+
from unittest import mock
|
|
15
|
+
|
|
16
|
+
fixtures = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fixture(fn=None, autouse=False):
|
|
20
|
+
def decorate(function):
|
|
21
|
+
fixtures[function.__name__] = (function, autouse)
|
|
22
|
+
return function
|
|
23
|
+
return decorate(fn) if fn else decorate
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Mark:
|
|
27
|
+
def parametrize(self, names, values):
|
|
28
|
+
def decorate(function):
|
|
29
|
+
existing = getattr(function, '_parameters', [])
|
|
30
|
+
function._parameters = existing + [(names.replace(' ', '').split(','), values)]
|
|
31
|
+
return function
|
|
32
|
+
return decorate
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
mark = Mark()
|
|
36
|
+
skip = lambda reason: (_ for _ in ()).throw(unittest.SkipTest(reason))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class raises:
|
|
40
|
+
def __init__(self, exception, match=None):
|
|
41
|
+
self.exception, self.match = exception, match
|
|
42
|
+
|
|
43
|
+
def __enter__(self):
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __exit__(self, kind, value, tb):
|
|
47
|
+
if kind is None:
|
|
48
|
+
raise AssertionError('Expected exception')
|
|
49
|
+
if not isinstance(value, self.exception):
|
|
50
|
+
return False
|
|
51
|
+
if self.match and not re.search(self.match, str(value)):
|
|
52
|
+
raise AssertionError('Exception text did not match')
|
|
53
|
+
self.value = value
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MonkeyPatch:
|
|
58
|
+
def __init__(self, stack):
|
|
59
|
+
self.stack = stack
|
|
60
|
+
|
|
61
|
+
def setenv(self, name, value):
|
|
62
|
+
self.stack.enter_context(mock.patch.dict(os.environ, {name: str(value)}))
|
|
63
|
+
|
|
64
|
+
def delenv(self, name, raising=True):
|
|
65
|
+
if raising and name not in os.environ:
|
|
66
|
+
raise KeyError(name)
|
|
67
|
+
self.stack.enter_context(mock.patch.dict(os.environ))
|
|
68
|
+
os.environ.pop(name, None)
|
|
69
|
+
|
|
70
|
+
def setattr(self, obj, name, value):
|
|
71
|
+
original = getattr(obj, name)
|
|
72
|
+
# The command facade re-exports functions from focused reader modules.
|
|
73
|
+
# Patch their imported bindings too, keeping the original injection tests.
|
|
74
|
+
if getattr(obj, '__name__', '') == 'page_reader':
|
|
75
|
+
for module_name, module in list(sys.modules.items()):
|
|
76
|
+
if module_name in ('reader_fetch', 'reader_crawl', 'reader_extract', 'reader_compile', 'reader_runtime') and getattr(module, name, object()) is original:
|
|
77
|
+
self.stack.enter_context(mock.patch.object(module, name, value))
|
|
78
|
+
self.stack.enter_context(mock.patch.object(obj, name, value))
|
|
79
|
+
|
|
80
|
+
def setitem(self, mapping, key, value):
|
|
81
|
+
self.stack.enter_context(mock.patch.dict(mapping, {key: value}))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Capture:
|
|
85
|
+
def __init__(self, stack):
|
|
86
|
+
self.out, self.err = io.StringIO(), io.StringIO()
|
|
87
|
+
stack.enter_context(redirect_stdout(self.out))
|
|
88
|
+
stack.enter_context(redirect_stderr(self.err))
|
|
89
|
+
|
|
90
|
+
def readouterr(self):
|
|
91
|
+
result = SimpleNamespace(out=self.out.getvalue(), err=self.err.getvalue())
|
|
92
|
+
for stream in (self.out, self.err):
|
|
93
|
+
stream.seek(0)
|
|
94
|
+
stream.truncate()
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def crawler_available():
|
|
99
|
+
try:
|
|
100
|
+
import importlib.util
|
|
101
|
+
return importlib.util.find_spec('crawl4ai') is not None
|
|
102
|
+
except (ImportError, ValueError):
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def local_sockets_available():
|
|
107
|
+
try:
|
|
108
|
+
with socket.socket() as sock:
|
|
109
|
+
sock.bind(('127.0.0.1', 0))
|
|
110
|
+
return True
|
|
111
|
+
except PermissionError:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RegressionCase(unittest.TestCase):
|
|
116
|
+
def __init__(self, function, parameters, label):
|
|
117
|
+
super().__init__('runTest')
|
|
118
|
+
self.function, self.parameters, self.label = function, parameters, label
|
|
119
|
+
|
|
120
|
+
def id(self):
|
|
121
|
+
return self.label
|
|
122
|
+
|
|
123
|
+
def __str__(self):
|
|
124
|
+
return self.label
|
|
125
|
+
|
|
126
|
+
def runTest(self):
|
|
127
|
+
name = self.function.__name__
|
|
128
|
+
needs_crawler = name.startswith('test_browser_') or name in (
|
|
129
|
+
'test_real_crawl4ai_pricing_snapshot', 'test_compile_css_refinement_carries_failing_rows',
|
|
130
|
+
'test_css_hostile_html_extracts_without_browser_or_requests')
|
|
131
|
+
if needs_crawler and not crawler_available():
|
|
132
|
+
self.skipTest('optional crawl4ai/browser dependency is absent; offline contracts still run')
|
|
133
|
+
needs_socket = name.startswith('test_proxy_') or 'local_http' in inspect.signature(self.function).parameters
|
|
134
|
+
if needs_socket and not local_sockets_available():
|
|
135
|
+
self.skipTest('sandbox blocks local sockets; public-address unit checks still run')
|
|
136
|
+
with ExitStack() as stack:
|
|
137
|
+
directory = stack.enter_context(tempfile.TemporaryDirectory(prefix='lbu-reader-test-'))
|
|
138
|
+
cache = {'tmp_path': Path(directory), 'monkeypatch': MonkeyPatch(stack), 'capsys': Capture(stack)}
|
|
139
|
+
cache.update(self.parameters)
|
|
140
|
+
|
|
141
|
+
def resolve(key):
|
|
142
|
+
if key in cache:
|
|
143
|
+
return cache[key]
|
|
144
|
+
factory = fixtures[key][0]
|
|
145
|
+
value = factory(**{arg: resolve(arg) for arg in inspect.signature(factory).parameters})
|
|
146
|
+
if inspect.isgenerator(value):
|
|
147
|
+
generator = value
|
|
148
|
+
value = next(generator)
|
|
149
|
+
stack.callback(lambda: next(generator, None))
|
|
150
|
+
cache[key] = value
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
for key, (_, automatic) in fixtures.items():
|
|
154
|
+
if automatic:
|
|
155
|
+
resolve(key)
|
|
156
|
+
self.function(**{arg: resolve(arg) for arg in inspect.signature(self.function).parameters})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def suite(namespace):
|
|
160
|
+
cases = []
|
|
161
|
+
for name, function in namespace.items():
|
|
162
|
+
if not name.startswith('test_') or not callable(function):
|
|
163
|
+
continue
|
|
164
|
+
parameters = [{}]
|
|
165
|
+
for names, values in getattr(function, '_parameters', []):
|
|
166
|
+
expanded = []
|
|
167
|
+
for previous, value in itertools.product(parameters, values):
|
|
168
|
+
values_tuple = value if len(names) > 1 else (value,)
|
|
169
|
+
expanded.append({**previous, **dict(zip(names, values_tuple))})
|
|
170
|
+
parameters = expanded
|
|
171
|
+
for index, values in enumerate(parameters):
|
|
172
|
+
cases.append(RegressionCase(function, values, f'{name}[{index}]'))
|
|
173
|
+
return unittest.TestSuite(cases)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Offline reader regression checks using the standard library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
import importlib.machinery
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
import io
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
import runpy
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
import shlex
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
import socket
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
import subprocess
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
import sys
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
import threading
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
from unittest import mock
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
from urllib.error import HTTPError
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
from urllib.request import Request
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
import reader_test_harness as checks
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
ROOT = Path(__file__).resolve().parent
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
DATA = ROOT / "testdata"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
REAL_SOCKET = socket.socket
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
REAL_GETADDRINFO = socket.getaddrinfo
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
loader = importlib.machinery.SourceFileLoader("page_reader", str(ROOT / "reader"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
module_spec = importlib.util.spec_from_loader(loader.name, loader)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
reader = importlib.util.module_from_spec(module_spec)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
loader.exec_module(reader)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
helper = runpy.run_path(str(ROOT / "crawl_helper.py"))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@checks.fixture(autouse=True)
|
|
89
|
+
def offline(monkeypatch, tmp_path):
|
|
90
|
+
monkeypatch.setenv("READER_SPECS_DIR", str(tmp_path / "specs"))
|
|
91
|
+
monkeypatch.setenv("READER_STATE_DIR", str(tmp_path / "state"))
|
|
92
|
+
monkeypatch.setenv("READER_COMPILE_CMD", shlex.join([sys.executable, str(DATA / "compile_stub.py")]))
|
|
93
|
+
monkeypatch.setenv("STUB_CALL_LOG", str(tmp_path / "calls.jsonl"))
|
|
94
|
+
monkeypatch.setenv("STUB_MODE", "npm")
|
|
95
|
+
# Any accidental live network access fails the offline suite.
|
|
96
|
+
monkeypatch.setattr(socket, "getaddrinfo", mock.Mock(side_effect=AssertionError("offline DNS")))
|
|
97
|
+
monkeypatch.setattr(socket, "socket", mock.Mock(side_effect=AssertionError("offline socket")))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def calls():
|
|
101
|
+
path = Path(os.environ["STUB_CALL_LOG"])
|
|
102
|
+
return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def html(package="react"):
|
|
106
|
+
return (DATA / f"npm-{package}.html").read_text()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def compile_npm():
|
|
110
|
+
return reader.compile_spec("npm-package", "https://www.npmjs.com/package/{name}",
|
|
111
|
+
{"name": "react"}, "package name, latest version, weekly downloads, license, repository URL",
|
|
112
|
+
{"name": "react", "latest_version": "19.2.0", "weekly_downloads": 42000000,
|
|
113
|
+
"license": "MIT", "repository_url": "git+https://github.com/facebook/react.git"}, html())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
PUBLIC_IP = ".".join(["93", "184", "216", "34"])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def public_address(ip=PUBLIC_IP):
|
|
120
|
+
return (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (ip, 443))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@checks.fixture
|
|
124
|
+
def anchor_compile(monkeypatch, tmp_path):
|
|
125
|
+
schema = {"type": "embedded_json", "source": "__NEXT_DATA__", "rows": "models",
|
|
126
|
+
"paths": {"model": "model", "input": "input", "output": "output"},
|
|
127
|
+
"required": ["model", "input", "output"]}
|
|
128
|
+
command = tmp_path / "anchor_compile.py"
|
|
129
|
+
command.write_text('''import json, os, sys
|
|
130
|
+
payload = json.loads(sys.stdin.read().split("INPUT JSON:\\n", 1)[1])
|
|
131
|
+
with open(os.environ["STUB_CALL_LOG"], "a") as stream:
|
|
132
|
+
stream.write(json.dumps(payload) + "\\n")
|
|
133
|
+
schema = json.loads(sys.argv[1])
|
|
134
|
+
if not payload.get("validation_failures") or os.environ["STUB_MODE"] == "anchor-broken":
|
|
135
|
+
schema["transforms"] = {"model": {"regex": r"(Claude .* [0-9]+)"}}
|
|
136
|
+
print(json.dumps(schema))
|
|
137
|
+
''')
|
|
138
|
+
monkeypatch.setenv("READER_COMPILE_CMD", shlex.join([sys.executable, str(command), json.dumps(schema)]))
|
|
139
|
+
rows = [{"model": "Claude Fable 5.1", "input": 10, "output": 50},
|
|
140
|
+
{"model": "Claude Sonnet 5.5", "input": 2, "output": 10}]
|
|
141
|
+
page = '<script id="__NEXT_DATA__">' + json.dumps({"models": rows}) + '</script>'
|
|
142
|
+
monkeypatch.setattr(reader, "fetch", lambda url, **kwargs: (page, "urllib"))
|
|
143
|
+
return rows
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@checks.fixture
|
|
147
|
+
def local_http(monkeypatch):
|
|
148
|
+
# Only these integration tests open sockets, all against local servers.
|
|
149
|
+
monkeypatch.setattr(socket, "socket", REAL_SOCKET)
|
|
150
|
+
monkeypatch.setattr(socket, "getaddrinfo", REAL_GETADDRINFO)
|
|
151
|
+
pages = {}
|
|
152
|
+
requests = []
|
|
153
|
+
|
|
154
|
+
class Handler(BaseHTTPRequestHandler):
|
|
155
|
+
def do_GET(self):
|
|
156
|
+
requests.append((f"http://127.0.0.1:{self.server.server_address[1]}", self.path))
|
|
157
|
+
status, headers, body = pages.get(self.path, (200, {}, "<p>private</p>"))
|
|
158
|
+
self.send_response(status)
|
|
159
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
160
|
+
for key, value in headers.items():
|
|
161
|
+
self.send_header(key, value)
|
|
162
|
+
self.send_header("Content-Length", str(len(body.encode())))
|
|
163
|
+
self.end_headers()
|
|
164
|
+
self.wfile.write(body.encode())
|
|
165
|
+
|
|
166
|
+
def log_message(self, *args):
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
allowed = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
170
|
+
port = allowed.server_address[1]
|
|
171
|
+
denied = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
172
|
+
servers = [allowed, denied]
|
|
173
|
+
threads = [threading.Thread(target=server.serve_forever, daemon=True) for server in servers]
|
|
174
|
+
for thread in threads:
|
|
175
|
+
thread.start()
|
|
176
|
+
origin = f"http://127.0.0.1:{port}"
|
|
177
|
+
denied_origin = f"http://127.0.0.1:{denied.server_address[1]}"
|
|
178
|
+
monkeypatch.setenv("READER_TEST_ALLOW_ORIGIN", origin)
|
|
179
|
+
monkeypatch.setenv("INSTABOX_HEADLESS", "1")
|
|
180
|
+
try:
|
|
181
|
+
yield origin, denied_origin, pages, requests
|
|
182
|
+
finally:
|
|
183
|
+
for server in servers:
|
|
184
|
+
server.shutdown()
|
|
185
|
+
server.server_close()
|
|
186
|
+
for thread in threads:
|
|
187
|
+
thread.join(timeout=5)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@checks.fixture
|
|
191
|
+
def crwl_fetch_stub(monkeypatch, tmp_path):
|
|
192
|
+
log = tmp_path / "crwl-calls.jsonl"
|
|
193
|
+
executable = tmp_path / "crawl-python"
|
|
194
|
+
executable.write_text(f'''#!{sys.executable}
|
|
195
|
+
import json
|
|
196
|
+
import os
|
|
197
|
+
from pathlib import Path
|
|
198
|
+
import sys
|
|
199
|
+
assert sys.argv[2:] == ["fetch"]
|
|
200
|
+
request = json.load(sys.stdin)
|
|
201
|
+
with open(os.environ["CRWL_FETCH_LOG"], "a") as stream:
|
|
202
|
+
stream.write(json.dumps(request) + "\\n")
|
|
203
|
+
package = request["url"].rsplit("/", 1)[-1]
|
|
204
|
+
page = (Path({str(DATA)!r}) / ("npm-" + package + ".html")).read_text()
|
|
205
|
+
print("crwl: browser ready")
|
|
206
|
+
print(json.dumps({{"html": page, "success": True, "status_code": 200, "url": request["url"]}}))
|
|
207
|
+
''')
|
|
208
|
+
executable.chmod(0o755)
|
|
209
|
+
monkeypatch.setenv("READER_CRAWL4AI_PYTHON", str(executable))
|
|
210
|
+
monkeypatch.setattr(reader, "checked_address", lambda url: public_address())
|
|
211
|
+
monkeypatch.setenv("CRWL_FETCH_LOG", str(log))
|
|
212
|
+
return log
|
|
213
|
+
|
|
214
|
+
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "css",
|
|
3
|
+
"schema": {
|
|
4
|
+
"name": "claude_pricing",
|
|
5
|
+
"baseSelector": "#content-container table:has(colgroup[span=\"3\"]) > tbody > tr:has(td[rowspan]):not(:has(button[aria-label*=\"Retired\"]))",
|
|
6
|
+
"fields": [
|
|
7
|
+
{
|
|
8
|
+
"name": "model",
|
|
9
|
+
"selector": "td:nth-child(1)",
|
|
10
|
+
"type": "text"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"name": "input",
|
|
14
|
+
"selector": "td:nth-child(2)",
|
|
15
|
+
"type": "text"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "output",
|
|
19
|
+
"selector": "td:nth-child(3)",
|
|
20
|
+
"type": "text"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "cache_write_5m",
|
|
24
|
+
"selector": "td:nth-child(4)",
|
|
25
|
+
"type": "text"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "cache_write_1h",
|
|
29
|
+
"selector": "td:nth-child(5)",
|
|
30
|
+
"type": "text"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "cache_read",
|
|
34
|
+
"selector": "td:nth-child(6)",
|
|
35
|
+
"type": "text"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"required": [
|
|
40
|
+
"model",
|
|
41
|
+
"input",
|
|
42
|
+
"output"
|
|
43
|
+
],
|
|
44
|
+
"transforms": {
|
|
45
|
+
"model": {
|
|
46
|
+
"regex": "(Claude\\s+\\w+\\s+\\d+(?:\\.\\d+)?)"
|
|
47
|
+
},
|
|
48
|
+
"input": {
|
|
49
|
+
"regex": "\\$([\\d.]+)",
|
|
50
|
+
"number": true
|
|
51
|
+
},
|
|
52
|
+
"output": {
|
|
53
|
+
"regex": "\\$([\\d.]+)",
|
|
54
|
+
"number": true
|
|
55
|
+
},
|
|
56
|
+
"cache_write_5m": {
|
|
57
|
+
"regex": "\\$([\\d.]+)",
|
|
58
|
+
"number": true
|
|
59
|
+
},
|
|
60
|
+
"cache_write_1h": {
|
|
61
|
+
"regex": "\\$([\\d.]+)",
|
|
62
|
+
"number": true
|
|
63
|
+
},
|
|
64
|
+
"cache_read": {
|
|
65
|
+
"regex": "\\$([\\d.]+)",
|
|
66
|
+
"number": true
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"example": {
|
|
70
|
+
"model": "Claude Sonnet 5.5",
|
|
71
|
+
"input": 2,
|
|
72
|
+
"output": 10,
|
|
73
|
+
"cache_write_5m": 2.5,
|
|
74
|
+
"cache_write_1h": 4,
|
|
75
|
+
"cache_read": 0.1
|
|
76
|
+
},
|
|
77
|
+
"name": "claude-pricing",
|
|
78
|
+
"url_template": "https://platform.claude.com/docs/en/about-claude/pricing",
|
|
79
|
+
"params": {},
|
|
80
|
+
"want": "One row for each current Claude model with model name, numeric input and output USD per million tokens, cache write 5m and 1h and cache read prices where shown. Exclude retired models and use baseline context pricing.",
|
|
81
|
+
"sample_output": [
|
|
82
|
+
{
|
|
83
|
+
"model": "Claude Fable 5.1",
|
|
84
|
+
"input": 10.0,
|
|
85
|
+
"output": 50.0,
|
|
86
|
+
"cache_write_5m": 12.5,
|
|
87
|
+
"cache_write_1h": 20.0,
|
|
88
|
+
"cache_read": 0.25
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"model": "Claude Opus 5.5",
|
|
92
|
+
"input": 4.0,
|
|
93
|
+
"output": 20.0,
|
|
94
|
+
"cache_write_5m": 5.0,
|
|
95
|
+
"cache_write_1h": 8.0,
|
|
96
|
+
"cache_read": 0.2
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"model": "Claude Sonnet 5.5",
|
|
100
|
+
"input": 2.0,
|
|
101
|
+
"output": 10.0,
|
|
102
|
+
"cache_write_5m": 2.5,
|
|
103
|
+
"cache_write_1h": 4.0,
|
|
104
|
+
"cache_read": 0.1
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"model": "Claude Haiku 5.5",
|
|
108
|
+
"input": 0.1,
|
|
109
|
+
"output": 0.5,
|
|
110
|
+
"cache_write_5m": 0.125,
|
|
111
|
+
"cache_write_1h": 0.2,
|
|
112
|
+
"cache_read": 0.01
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"model": "Claude Mythos 5.1",
|
|
116
|
+
"input": 10.0,
|
|
117
|
+
"output": 50.0,
|
|
118
|
+
"cache_write_5m": 12.5,
|
|
119
|
+
"cache_write_1h": 20.0,
|
|
120
|
+
"cache_read": 0.25
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"model": "Claude Fable 5",
|
|
124
|
+
"input": 10.0,
|
|
125
|
+
"output": 50.0,
|
|
126
|
+
"cache_write_5m": 12.5,
|
|
127
|
+
"cache_write_1h": 20.0,
|
|
128
|
+
"cache_read": 1.0
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"model": "Claude Mythos 5",
|
|
132
|
+
"input": 10.0,
|
|
133
|
+
"output": 50.0,
|
|
134
|
+
"cache_write_5m": 12.5,
|
|
135
|
+
"cache_write_1h": 20.0,
|
|
136
|
+
"cache_read": 1.0
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"model": "Claude Opus 5",
|
|
140
|
+
"input": 5.0,
|
|
141
|
+
"output": 25.0,
|
|
142
|
+
"cache_write_5m": 6.25,
|
|
143
|
+
"cache_write_1h": 10.0,
|
|
144
|
+
"cache_read": 0.5
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"model": "Claude Opus 4.8",
|
|
148
|
+
"input": 5.0,
|
|
149
|
+
"output": 25.0,
|
|
150
|
+
"cache_write_5m": 6.25,
|
|
151
|
+
"cache_write_1h": 10.0,
|
|
152
|
+
"cache_read": 0.5
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"model": "Claude Opus 4.7",
|
|
156
|
+
"input": 5.0,
|
|
157
|
+
"output": 25.0,
|
|
158
|
+
"cache_write_5m": 6.25,
|
|
159
|
+
"cache_write_1h": 10.0,
|
|
160
|
+
"cache_read": 0.5
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"model": "Claude Opus 4.6",
|
|
164
|
+
"input": 5.0,
|
|
165
|
+
"output": 25.0,
|
|
166
|
+
"cache_write_5m": 6.25,
|
|
167
|
+
"cache_write_1h": 10.0,
|
|
168
|
+
"cache_read": 0.5
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"model": "Claude Opus 4.5",
|
|
172
|
+
"input": 5.0,
|
|
173
|
+
"output": 25.0,
|
|
174
|
+
"cache_write_5m": 6.25,
|
|
175
|
+
"cache_write_1h": 10.0,
|
|
176
|
+
"cache_read": 0.5
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"model": "Claude Sonnet 5",
|
|
180
|
+
"input": 2.0,
|
|
181
|
+
"output": 10.0,
|
|
182
|
+
"cache_write_5m": 2.5,
|
|
183
|
+
"cache_write_1h": 4.0,
|
|
184
|
+
"cache_read": 0.2
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"model": "Claude Sonnet 4.6",
|
|
188
|
+
"input": 3.0,
|
|
189
|
+
"output": 15.0,
|
|
190
|
+
"cache_write_5m": 3.75,
|
|
191
|
+
"cache_write_1h": 6.0,
|
|
192
|
+
"cache_read": 0.3
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"model": "Claude Sonnet 4.5",
|
|
196
|
+
"input": 3.0,
|
|
197
|
+
"output": 15.0,
|
|
198
|
+
"cache_write_5m": 3.75,
|
|
199
|
+
"cache_write_1h": 6.0,
|
|
200
|
+
"cache_read": 0.3
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"model": "Claude Haiku 4.5",
|
|
204
|
+
"input": 1.0,
|
|
205
|
+
"output": 5.0,
|
|
206
|
+
"cache_write_5m": 1.25,
|
|
207
|
+
"cache_write_1h": 2.0,
|
|
208
|
+
"cache_read": 0.1
|
|
209
|
+
}
|
|
210
|
+
],
|
|
211
|
+
"compiled_at": "2026-10-23T09:13:35+00:00",
|
|
212
|
+
"compiled_by": "python3 reader/testdata/compile_stub.py",
|
|
213
|
+
"crawl4ai_version": "0.9.2",
|
|
214
|
+
"page_hash": "580850369340da5cce9998a0b9f7f6c1dca3e15fc0280e596a0d1da301669a4c",
|
|
215
|
+
"last_ok": "2026-10-23T09:14:26+00:00",
|
|
216
|
+
"healed_at": null,
|
|
217
|
+
"source_discovery": {
|
|
218
|
+
"overview_url": "https://platform.claude.com/docs/en/models/overview.md",
|
|
219
|
+
"pricing_url": "https://platform.claude.com/docs/en/about-claude/pricing",
|
|
220
|
+
"checked_at": "2026-10-23",
|
|
221
|
+
"note": "Pricing link followed from official overview. Live public page compiled and validated with the documented compile-command fixture, not a live AI lane."
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "embedded_json",
|
|
3
|
+
"source": "__context__",
|
|
4
|
+
"rows": "context",
|
|
5
|
+
"paths": {
|
|
6
|
+
"name": "packument.name",
|
|
7
|
+
"latest_version": "packument.version",
|
|
8
|
+
"weekly_downloads": "downloads.-1.downloads",
|
|
9
|
+
"license": "packument.license",
|
|
10
|
+
"repository_url": "packument.repository"
|
|
11
|
+
},
|
|
12
|
+
"required": [
|
|
13
|
+
"name",
|
|
14
|
+
"latest_version",
|
|
15
|
+
"weekly_downloads",
|
|
16
|
+
"license",
|
|
17
|
+
"repository_url"
|
|
18
|
+
],
|
|
19
|
+
"transforms": {
|
|
20
|
+
"weekly_downloads": "number"
|
|
21
|
+
},
|
|
22
|
+
"example": {
|
|
23
|
+
"name": "react",
|
|
24
|
+
"latest_version": "19.2.0",
|
|
25
|
+
"weekly_downloads": 42000000,
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository_url": "git+https://github.com/facebook/react.git"
|
|
28
|
+
},
|
|
29
|
+
"name": "npm-package",
|
|
30
|
+
"url_template": "https://www.npmjs.com/package/{name}",
|
|
31
|
+
"params": {
|
|
32
|
+
"name": "react"
|
|
33
|
+
},
|
|
34
|
+
"want": "Return package name, latest version, numeric weekly downloads, license, and repository URL.",
|
|
35
|
+
"sample_output": [
|
|
36
|
+
{
|
|
37
|
+
"name": "react",
|
|
38
|
+
"latest_version": "19.2.0",
|
|
39
|
+
"weekly_downloads": 42000000,
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"repository_url": "git+https://github.com/facebook/react.git"
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"compiled_at": "2026-10-23T09:14:06+00:00",
|
|
45
|
+
"compiled_by": "python3 reader/testdata/compile_stub.py",
|
|
46
|
+
"fetch_method": "crwl",
|
|
47
|
+
"crawl4ai_version": "0.9.2",
|
|
48
|
+
"page_hash": "92237ff233695cbf21545d7fddf95cc51981e1f2874e26310c8522e2eb8d126d",
|
|
49
|
+
"last_ok": "2026-10-23T09:14:06+00:00",
|
|
50
|
+
"healed_at": null,
|
|
51
|
+
"verification": {
|
|
52
|
+
"status": "live_zod_snapshot",
|
|
53
|
+
"url": "https://www.npmjs.com/package/zod",
|
|
54
|
+
"verified_at": "2026-10-23T09:45:06+00:00",
|
|
55
|
+
"page_hash": "9c860f2f365e31322abab76710ab3c85694cef021d13b5c8fc1e4ec61c820890",
|
|
56
|
+
"sample_output": [
|
|
57
|
+
{
|
|
58
|
+
"name": "zod",
|
|
59
|
+
"latest_version": "4.6.7",
|
|
60
|
+
"weekly_downloads": 144621339,
|
|
61
|
+
"license": "MIT",
|
|
62
|
+
"repository_url": "https://github.com/colinhacks/zod"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"note": "Paths manually corrected against the public zod HTML fetched with bundled crwl -o all. Original compiled_by and compile sample retained. React live fetch and a real compile lane were not rerun in this fix."
|
|
66
|
+
}
|
|
67
|
+
}
|