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/__init__.py +85 -0
- webless/_engines.py +604 -0
- webless/_fetch.py +145 -0
- webless/_html.py +489 -0
- webless/_http.py +281 -0
- webless/_search.py +90 -0
- webless/_sync.py +45 -0
- webless/cli.py +121 -0
- webless/py.typed +0 -0
- webless-0.1.0.dist-info/METADATA +241 -0
- webless-0.1.0.dist-info/RECORD +14 -0
- webless-0.1.0.dist-info/WHEEL +4 -0
- webless-0.1.0.dist-info/entry_points.txt +3 -0
- webless-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|