webless 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.
webless/_http.py ADDED
@@ -0,0 +1,281 @@
1
+ """The shared HTTP layer behind `search` and `fetch`.
2
+
3
+ Every request this package makes goes through `fetch_url`, which exists to
4
+ make two promises the rest of the package depends on.
5
+
6
+ The first is that no request needs a key. Every search engine reached from
7
+ here is a public endpoint, so the package works on a fresh install with
8
+ nothing configured.
9
+
10
+ The second is that a request is hard to block and never blocks. Hosts that
11
+ refuse a request do it on fingerprint reputation, so a retry after a 403 or a
12
+ 429 presents a different browser user agent rather than repeating the one that
13
+ was just refused; that alone clears most transient blocks. And every call is
14
+ async with a hard timeout, so a slow host delays its own result and nothing
15
+ else -- a search queries its engines concurrently and keeps whatever comes
16
+ back in time.
17
+
18
+ Requests are also guarded against pointing back inward. A URL that resolves to
19
+ a private, loopback, or link-local address is refused before it is sent, on
20
+ the original URL and again on every redirect hop, so a caller cannot be
21
+ steered into the machine's own network.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import ipaddress
28
+ import random
29
+ import socket
30
+ from dataclasses import dataclass, field
31
+ from urllib.parse import urlsplit, urlunsplit
32
+
33
+ import httpx
34
+
35
+ USER_AGENTS = (
36
+ (
37
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
38
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
39
+ ),
40
+ (
41
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
42
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
43
+ ),
44
+ "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
45
+ (
46
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
47
+ "(KHTML, like Gecko) Version/17.4 Safari/605.1.15"
48
+ ),
49
+ )
50
+
51
+ DEFAULT_HEADERS = {
52
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
53
+ "Accept-Language": "en-US,en;q=0.9",
54
+ }
55
+
56
+ BLOCKED_STATUSES = frozenset({401, 403, 429})
57
+ RETRYABLE_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
58
+
59
+ DEFAULT_TIMEOUT_S = 15.0
60
+ MAX_REDIRECTS = 5
61
+ MAX_ATTEMPTS = 3
62
+ MAX_BODY_BYTES = 5 * 1024 * 1024
63
+
64
+
65
+ class WebError(Exception):
66
+ """A request that failed in a way the caller should report, not retry."""
67
+
68
+ def __init__(self, message: str, *, status: int | None = None) -> None:
69
+ super().__init__(message)
70
+ self.status = status
71
+
72
+
73
+ def next_user_agent(previous: str | None = None) -> str:
74
+ """Pick a user agent, guaranteed to differ from `previous` when given.
75
+
76
+ A cold request draws at random so concurrent engines do not all present
77
+ the same fingerprint; a retry walks to the next agent in the pool so the
78
+ host sees a different browser than the one it just refused.
79
+ """
80
+ if previous is None:
81
+ return random.choice(USER_AGENTS)
82
+ try:
83
+ index = USER_AGENTS.index(previous)
84
+ except ValueError:
85
+ return USER_AGENTS[0]
86
+ return USER_AGENTS[(index + 1) % len(USER_AGENTS)]
87
+
88
+
89
+ def normalize_url(url: str) -> str:
90
+ """Return `url` with a scheme, lowercased host, and no fragment."""
91
+ candidate = url.strip()
92
+ if candidate.startswith("//"):
93
+ candidate = f"https:{candidate}"
94
+ if not candidate.lower().startswith(("http://", "https://")):
95
+ candidate = f"https://{candidate}"
96
+ parts = urlsplit(candidate)
97
+ host = parts.hostname or ""
98
+ netloc = host.lower()
99
+ if parts.port:
100
+ netloc = f"{netloc}:{parts.port}"
101
+ if parts.username:
102
+ credentials = parts.username
103
+ if parts.password:
104
+ credentials = f"{credentials}:{parts.password}"
105
+ netloc = f"{credentials}@{netloc}"
106
+ return urlunsplit((parts.scheme.lower(), netloc, parts.path or "/", parts.query, ""))
107
+
108
+
109
+ def dedup_key(url: str) -> str:
110
+ """A comparison key that treats trivial URL variants as one page."""
111
+ parts = urlsplit(normalize_url(url))
112
+ host = (parts.hostname or "").removeprefix("www.")
113
+ path = parts.path.rstrip("/") or "/"
114
+ return f"{host}{path}?{parts.query}" if parts.query else f"{host}{path}"
115
+
116
+
117
+ def host_of(url: str) -> str:
118
+ """The hostname of `url`, or an empty string when it has none."""
119
+ try:
120
+ return urlsplit(normalize_url(url)).hostname or ""
121
+ except ValueError:
122
+ return ""
123
+
124
+
125
+ def _is_public_address(address: str) -> bool:
126
+ """True when `address` is a routable public IP."""
127
+ try:
128
+ ip = ipaddress.ip_address(address)
129
+ except ValueError:
130
+ return False
131
+ return not (
132
+ ip.is_private
133
+ or ip.is_loopback
134
+ or ip.is_link_local
135
+ or ip.is_reserved
136
+ or ip.is_multicast
137
+ or ip.is_unspecified
138
+ )
139
+
140
+
141
+ async def _guard_host(url: str) -> None:
142
+ """Refuse a URL that resolves into private or loopback address space."""
143
+ parts = urlsplit(url)
144
+ if parts.scheme not in {"http", "https"}:
145
+ raise WebError(f"Unsupported URL scheme: {parts.scheme or 'none'}.")
146
+ host = parts.hostname
147
+ if not host:
148
+ raise WebError(f"{url} has no host.")
149
+ try:
150
+ infos = await asyncio.get_running_loop().getaddrinfo(
151
+ host, parts.port or (443 if parts.scheme == "https" else 80),
152
+ proto=socket.IPPROTO_TCP,
153
+ )
154
+ except OSError as exc:
155
+ raise WebError(f"Cannot resolve {host}: {exc}") from exc
156
+ addresses = {info[4][0] for info in infos}
157
+ if not any(_is_public_address(a) for a in addresses):
158
+ raise WebError(
159
+ f"{host} resolves to a private or loopback address; refusing to fetch it."
160
+ )
161
+
162
+
163
+ @dataclass(slots=True)
164
+ class Response:
165
+ """A completed fetch."""
166
+
167
+ url: str
168
+ final_url: str
169
+ status: int
170
+ text: str
171
+ content_type: str
172
+ headers: dict[str, str] = field(default_factory=dict)
173
+
174
+ @property
175
+ def is_html(self) -> bool:
176
+ """True when the body looks like markup rather than data."""
177
+ return "html" in self.content_type or "xml" in self.content_type
178
+
179
+ @property
180
+ def is_json(self) -> bool:
181
+ """True when the server labelled the body as JSON."""
182
+ return "json" in self.content_type
183
+
184
+
185
+ async def fetch_url(
186
+ url: str,
187
+ *,
188
+ headers: dict[str, str] | None = None,
189
+ timeout: float = DEFAULT_TIMEOUT_S,
190
+ attempts: int = MAX_ATTEMPTS,
191
+ abort: asyncio.Event | None = None,
192
+ ) -> Response:
193
+ """Fetch `url` and return its body, rotating fingerprint on a block.
194
+
195
+ Redirects are followed manually so that each hop can be re-guarded against
196
+ private address space. A retryable status or transport error backs off and
197
+ tries again with a fresh user agent, up to `attempts` times; anything else
198
+ is raised as a `WebError` carrying the status.
199
+ """
200
+ target = normalize_url(url)
201
+ user_agent: str | None = None
202
+ last_error: WebError | None = None
203
+
204
+ for attempt in range(attempts):
205
+ if abort is not None and abort.is_set():
206
+ raise WebError("Aborted before the request completed.")
207
+ if attempt:
208
+ await asyncio.sleep(min(0.5 * 2 ** (attempt - 1), 4.0) + random.random() * 0.3)
209
+ user_agent = next_user_agent(user_agent)
210
+ request_headers = {
211
+ **DEFAULT_HEADERS,
212
+ "User-Agent": user_agent,
213
+ **(headers or {}),
214
+ }
215
+ try:
216
+ return await _fetch_once(target, request_headers, timeout)
217
+ except WebError as exc:
218
+ last_error = exc
219
+ if exc.status is None or exc.status not in (
220
+ RETRYABLE_STATUSES | BLOCKED_STATUSES
221
+ ):
222
+ raise
223
+ except (httpx.TransportError, httpx.HTTPError) as exc:
224
+ last_error = WebError(f"Request to {target} failed: {exc}")
225
+
226
+ raise last_error or WebError(f"Request to {target} failed.")
227
+
228
+
229
+ async def _fetch_once(
230
+ url: str, headers: dict[str, str], timeout: float
231
+ ) -> Response:
232
+ """Perform one request, following and re-guarding redirects by hand."""
233
+ current = url
234
+ async with httpx.AsyncClient(
235
+ follow_redirects=False,
236
+ timeout=httpx.Timeout(timeout),
237
+ http2=False,
238
+ ) as client:
239
+ for _ in range(MAX_REDIRECTS + 1):
240
+ await _guard_host(current)
241
+ try:
242
+ response = await client.get(current, headers=headers)
243
+ except httpx.TimeoutException as exc:
244
+ raise WebError(f"{current} timed out after {timeout:g}s.") from exc
245
+
246
+ if response.is_redirect:
247
+ location = response.headers.get("location", "")
248
+ if not location:
249
+ raise WebError(f"{current} redirected without a target.")
250
+ current = normalize_url(str(response.url.join(location)))
251
+ continue
252
+
253
+ if response.status_code >= 400:
254
+ raise WebError(
255
+ f"{current} returned HTTP {response.status_code}.",
256
+ status=response.status_code,
257
+ )
258
+
259
+ body = response.content[:MAX_BODY_BYTES]
260
+ return Response(
261
+ url=url,
262
+ final_url=str(response.url),
263
+ status=response.status_code,
264
+ text=body.decode(response.encoding or "utf-8", errors="replace"),
265
+ content_type=response.headers.get("content-type", "").lower(),
266
+ headers=dict(response.headers),
267
+ )
268
+
269
+ raise WebError(f"{url} exceeded {MAX_REDIRECTS} redirects.")
270
+
271
+
272
+ __all__ = [
273
+ "USER_AGENTS",
274
+ "Response",
275
+ "WebError",
276
+ "fetch_url",
277
+ "next_user_agent",
278
+ "normalize_url",
279
+ "dedup_key",
280
+ "host_of",
281
+ ]
webless/_search.py ADDED
@@ -0,0 +1,90 @@
1
+ """The public search API.
2
+
3
+ One call, `search`, queries several keyless engines at once and returns a
4
+ single fused ranking. The engines are public endpoints, so nothing has to be
5
+ configured before the first call works, and none of them is treated as
6
+ required: an engine that is blocked, rate limited, or slow costs recall and is
7
+ named in `SearchResult.failed`, while the rest of the ranking stands.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+ from ._engines import GENERAL_ENGINES, WIDE_ENGINES, Engine, SearchHit, search_web
15
+
16
+ DEFAULT_LIMIT = 10
17
+ DEFAULT_TIMEOUT_S = 12.0
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class SearchResult:
22
+ """A fused ranking, plus what each engine did to produce it.
23
+
24
+ `failed` is part of the result rather than an exception because a partial
25
+ answer is still an answer: knowing that Mojeek was blocked while
26
+ DuckDuckGo answered lets a caller decide whether to trust the ranking,
27
+ without having to lose it.
28
+ """
29
+
30
+ query: str
31
+ hits: list[SearchHit]
32
+ succeeded: list[str] = field(default_factory=list)
33
+ failed: dict[str, str] = field(default_factory=dict)
34
+
35
+ def __len__(self) -> int:
36
+ return len(self.hits)
37
+
38
+ def __iter__(self):
39
+ return iter(self.hits)
40
+
41
+ def __getitem__(self, index):
42
+ return self.hits[index]
43
+
44
+ @property
45
+ def ok(self) -> bool:
46
+ """Whether at least one engine answered."""
47
+ return bool(self.succeeded)
48
+
49
+
50
+ async def search(
51
+ query: str,
52
+ *,
53
+ limit: int = DEFAULT_LIMIT,
54
+ wide: bool = False,
55
+ engines: tuple[type[Engine], ...] | None = None,
56
+ timeout: float = DEFAULT_TIMEOUT_S,
57
+ allowed_domains: list[str] | None = None,
58
+ blocked_domains: list[str] | None = None,
59
+ ) -> SearchResult:
60
+ """Search the web without an API key.
61
+
62
+ `wide` adds the long-tail engines -- Marginalia's small-web index and
63
+ Hacker News -- to the general set, which helps on niche or discussion
64
+ oriented queries and adds latency on ordinary ones. Pass `engines`
65
+ directly to choose the set yourself.
66
+
67
+ `allowed_domains` and `blocked_domains` filter on host, and an entry
68
+ covers its subdomains, so ``python.org`` keeps ``docs.python.org``.
69
+ """
70
+ if not query.strip():
71
+ raise ValueError("query is empty")
72
+
73
+ chosen = engines if engines is not None else (WIDE_ENGINES if wide else GENERAL_ENGINES)
74
+ report = await search_web(
75
+ query,
76
+ limit=limit,
77
+ engines=chosen,
78
+ timeout=timeout,
79
+ allowed_domains=allowed_domains,
80
+ blocked_domains=blocked_domains,
81
+ )
82
+ return SearchResult(
83
+ query=query,
84
+ hits=report.hits,
85
+ succeeded=report.succeeded,
86
+ failed=report.failed,
87
+ )
88
+
89
+
90
+ __all__ = ["search", "SearchResult", "SearchHit"]
webless/_sync.py ADDED
@@ -0,0 +1,45 @@
1
+ """Blocking wrappers over the async API.
2
+
3
+ The core is async because a search is several concurrent requests and a slow
4
+ engine must not hold up the rest. Plenty of callers -- a script, a notebook
5
+ cell, a Django view -- have no loop to await in, so each entry point gets a
6
+ ``*_sync`` twin that runs one for the duration of the call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from typing import Any
13
+
14
+ from ._fetch import Page, fetch
15
+ from ._search import SearchResult, search
16
+
17
+
18
+ def _run(coro: Any) -> Any:
19
+ """Run `coro` to completion, refusing to do it from inside a loop.
20
+
21
+ `asyncio.run` inside a running loop raises a message about the loop rather
22
+ than about the call the caller made, so the check is done here where the
23
+ right advice -- await the async twin -- can be given.
24
+ """
25
+ try:
26
+ asyncio.get_running_loop()
27
+ except RuntimeError:
28
+ return asyncio.run(coro)
29
+ coro.close()
30
+ raise RuntimeError(
31
+ "a sync call cannot run inside an event loop; await the async version instead"
32
+ )
33
+
34
+
35
+ def search_sync(query: str, **kwargs: Any) -> SearchResult:
36
+ """Blocking `search`. Takes the same keyword arguments."""
37
+ return _run(search(query, **kwargs))
38
+
39
+
40
+ def fetch_sync(url: str, **kwargs: Any) -> Page:
41
+ """Blocking `fetch`. Takes the same keyword arguments."""
42
+ return _run(fetch(url, **kwargs))
43
+
44
+
45
+ __all__ = ["search_sync", "fetch_sync"]
webless/cli.py ADDED
@@ -0,0 +1,121 @@
1
+ """The `webless` command.
2
+
3
+ A thin shell over `search` and `fetch`, so the package is usable from a
4
+ terminal or a pipe without writing a script. Output is human readable by
5
+ default and JSON with ``--json``, which makes it composable with `jq`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import asyncio
12
+ import json
13
+ import sys
14
+ from dataclasses import asdict
15
+
16
+ from . import __version__
17
+ from ._http import WebError
18
+ from ._fetch import fetch
19
+ from ._search import DEFAULT_LIMIT, search
20
+
21
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ """Assemble the argument parser for both subcommands."""
24
+ parser = argparse.ArgumentParser(
25
+ prog="webless",
26
+ description="Search the web and fetch pages, with no API keys.",
27
+ )
28
+ parser.add_argument("--version", action="version", version=f"webless {__version__}")
29
+ sub = parser.add_subparsers(dest="command", required=True)
30
+
31
+ find = sub.add_parser("search", help="search the web")
32
+ find.add_argument("query", nargs="+", help="what to search for")
33
+ find.add_argument("-n", "--limit", type=int, default=DEFAULT_LIMIT)
34
+ find.add_argument("-w", "--wide", action="store_true", help="add the long-tail engines")
35
+ find.add_argument("--allow", action="append", metavar="DOMAIN", default=None)
36
+ find.add_argument("--block", action="append", metavar="DOMAIN", default=None)
37
+ find.add_argument("--timeout", type=float, default=12.0)
38
+ find.add_argument("--json", action="store_true")
39
+
40
+ get = sub.add_parser("fetch", help="fetch a page as Markdown")
41
+ get.add_argument("url")
42
+ get.add_argument("-f", "--format", choices=("markdown", "text", "raw"), default="markdown")
43
+ get.add_argument("--full-page", action="store_true", help="keep navigation and footers")
44
+ get.add_argument("-c", "--max-chars", type=int, default=None)
45
+ get.add_argument("--offset", type=int, default=0)
46
+ get.add_argument("--timeout", type=float, default=None)
47
+ get.add_argument("--json", action="store_true")
48
+ return parser
49
+
50
+
51
+ async def run_search(args: argparse.Namespace) -> int:
52
+ """Print a fused ranking, and report any engine that did not answer."""
53
+ result = await search(
54
+ " ".join(args.query),
55
+ limit=args.limit,
56
+ wide=args.wide,
57
+ timeout=args.timeout,
58
+ allowed_domains=args.allow,
59
+ blocked_domains=args.block,
60
+ )
61
+
62
+ if args.json:
63
+ print(json.dumps(asdict(result), indent=2))
64
+ return 0 if result.ok else 1
65
+
66
+ for index, hit in enumerate(result.hits, start=1):
67
+ print(f"{index}. {hit.title}")
68
+ print(f" {hit.url}")
69
+ if hit.snippet:
70
+ print(f" {hit.snippet}")
71
+ print(f" [{', '.join(hit.engines)}]")
72
+ print()
73
+
74
+ if not result.hits:
75
+ print("no results", file=sys.stderr)
76
+ for name, reason in result.failed.items():
77
+ print(f"{name}: {reason}", file=sys.stderr)
78
+ return 0 if result.ok else 1
79
+
80
+
81
+ async def run_fetch(args: argparse.Namespace) -> int:
82
+ """Print one page's content, or its full record with ``--json``."""
83
+ page = await fetch(
84
+ args.url,
85
+ format=args.format,
86
+ full_page=args.full_page,
87
+ max_chars=args.max_chars,
88
+ offset=args.offset,
89
+ timeout=args.timeout,
90
+ )
91
+
92
+ if args.json:
93
+ print(json.dumps(asdict(page), indent=2))
94
+ return 0
95
+
96
+ if page.redirected:
97
+ print(f"(redirected to {page.final_url})", file=sys.stderr)
98
+ print(page.content)
99
+ if page.truncated:
100
+ print(
101
+ f"[truncated; continue with --offset {args.offset + len(page.content)}]",
102
+ file=sys.stderr,
103
+ )
104
+ return 0
105
+
106
+
107
+ def main(argv: list[str] | None = None) -> int:
108
+ """Entry point. Network failures print a message rather than a traceback."""
109
+ args = build_parser().parse_args(argv)
110
+ runner = run_search if args.command == "search" else run_fetch
111
+ try:
112
+ return asyncio.run(runner(args))
113
+ except (WebError, ValueError) as exc:
114
+ print(f"error: {exc}", file=sys.stderr)
115
+ return 1
116
+ except KeyboardInterrupt:
117
+ return 130
118
+
119
+
120
+ if __name__ == "__main__":
121
+ raise SystemExit(main())
webless/py.typed ADDED
File without changes