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/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""webless: web search and page fetching with no API keys.
|
|
2
|
+
|
|
3
|
+
Two calls. `search` queries several public search engines at once and fuses
|
|
4
|
+
their rankings; `fetch` retrieves a URL and returns its readable content as
|
|
5
|
+
Markdown. Nothing has to be configured first -- there is no account, no key,
|
|
6
|
+
and no per-request quota to manage.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
import asyncio
|
|
10
|
+
from webless import search, fetch
|
|
11
|
+
|
|
12
|
+
async def main():
|
|
13
|
+
result = await search("reciprocal rank fusion")
|
|
14
|
+
for hit in result[:3]:
|
|
15
|
+
print(hit.title, hit.url)
|
|
16
|
+
|
|
17
|
+
page = await fetch(result[0].url)
|
|
18
|
+
print(page.content[:500])
|
|
19
|
+
|
|
20
|
+
asyncio.run(main())
|
|
21
|
+
|
|
22
|
+
The same two calls exist as `search_sync` and `fetch_sync` for code with no
|
|
23
|
+
event loop, and as a `webless` command on the terminal.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from ._engines import (
|
|
27
|
+
ENGINES_BY_NAME,
|
|
28
|
+
GENERAL_ENGINES,
|
|
29
|
+
WIDE_ENGINES,
|
|
30
|
+
BingEngine,
|
|
31
|
+
DuckDuckGoEngine,
|
|
32
|
+
Engine,
|
|
33
|
+
HackerNewsEngine,
|
|
34
|
+
MarginaliaEngine,
|
|
35
|
+
MojeekEngine,
|
|
36
|
+
WikipediaEngine,
|
|
37
|
+
alignment,
|
|
38
|
+
fuse,
|
|
39
|
+
query_terms,
|
|
40
|
+
rank_by_relevance,
|
|
41
|
+
)
|
|
42
|
+
from ._html import html_to_markdown, main_content, page_description, page_title, parse_html
|
|
43
|
+
from ._http import USER_AGENTS, Response, WebError, fetch_url, host_of, normalize_url
|
|
44
|
+
from ._sync import fetch_sync, search_sync
|
|
45
|
+
from ._fetch import Page, fetch, render
|
|
46
|
+
from ._search import SearchHit, SearchResult, search
|
|
47
|
+
|
|
48
|
+
__version__ = "0.1.0"
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"search",
|
|
52
|
+
"fetch",
|
|
53
|
+
"search_sync",
|
|
54
|
+
"fetch_sync",
|
|
55
|
+
"SearchResult",
|
|
56
|
+
"SearchHit",
|
|
57
|
+
"Page",
|
|
58
|
+
"WebError",
|
|
59
|
+
"render",
|
|
60
|
+
"Engine",
|
|
61
|
+
"DuckDuckGoEngine",
|
|
62
|
+
"MojeekEngine",
|
|
63
|
+
"BingEngine",
|
|
64
|
+
"WikipediaEngine",
|
|
65
|
+
"MarginaliaEngine",
|
|
66
|
+
"HackerNewsEngine",
|
|
67
|
+
"GENERAL_ENGINES",
|
|
68
|
+
"WIDE_ENGINES",
|
|
69
|
+
"ENGINES_BY_NAME",
|
|
70
|
+
"fuse",
|
|
71
|
+
"rank_by_relevance",
|
|
72
|
+
"query_terms",
|
|
73
|
+
"alignment",
|
|
74
|
+
"fetch_url",
|
|
75
|
+
"Response",
|
|
76
|
+
"normalize_url",
|
|
77
|
+
"host_of",
|
|
78
|
+
"USER_AGENTS",
|
|
79
|
+
"parse_html",
|
|
80
|
+
"html_to_markdown",
|
|
81
|
+
"main_content",
|
|
82
|
+
"page_title",
|
|
83
|
+
"page_description",
|
|
84
|
+
"__version__",
|
|
85
|
+
]
|
webless/_engines.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""The keyless search back ends and the fusion that merges them.
|
|
2
|
+
|
|
3
|
+
Each engine here is a public endpoint that answers without credentials: three
|
|
4
|
+
scrape a normal results page, three read a public JSON or OpenSearch API. None
|
|
5
|
+
of them is trusted to be up. `search_web` queries them concurrently, keeps
|
|
6
|
+
whatever answers inside the deadline, and fuses the surviving rankings, so an
|
|
7
|
+
engine that is blocked, rate limited, or simply slow costs recall rather than
|
|
8
|
+
the whole call.
|
|
9
|
+
|
|
10
|
+
Fusion is Reciprocal Rank Fusion: a result's score is the sum of ``1 / (k +
|
|
11
|
+
rank)`` over the engines that returned it. Rank is all it needs, which is the
|
|
12
|
+
point -- the engines disagree about scoring and none of them exposes a
|
|
13
|
+
comparable number, but they all produce an ordered list, and a page several
|
|
14
|
+
independent indexes rank highly is a better answer than one page's favourite.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import base64
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from urllib.parse import parse_qs, quote_plus, urlsplit
|
|
25
|
+
|
|
26
|
+
from ._html import parse_html
|
|
27
|
+
from ._http import Response, WebError, dedup_key, fetch_url, host_of, normalize_url
|
|
28
|
+
|
|
29
|
+
RRF_K = 60
|
|
30
|
+
|
|
31
|
+
STOPWORDS = frozenset({
|
|
32
|
+
"a", "an", "and", "are", "as", "at", "be", "but", "by", "can", "do", "does",
|
|
33
|
+
"for", "from", "how", "in", "is", "it", "of", "on", "or", "that", "the",
|
|
34
|
+
"to", "use", "using", "vs", "was", "what", "when", "where", "which", "who",
|
|
35
|
+
"why", "with", "you", "your",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
REDIRECT_PARAMS = {
|
|
39
|
+
"duckduckgo.com": "uddg",
|
|
40
|
+
"lite.duckduckgo.com": "uddg",
|
|
41
|
+
"html.duckduckgo.com": "uddg",
|
|
42
|
+
"google.com": "q",
|
|
43
|
+
"www.google.com": "q",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class SearchHit:
|
|
49
|
+
"""One result, before or after fusion."""
|
|
50
|
+
|
|
51
|
+
title: str
|
|
52
|
+
url: str
|
|
53
|
+
snippet: str = ""
|
|
54
|
+
engines: list[str] = field(default_factory=list)
|
|
55
|
+
score: float = 0.0
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def host(self) -> str:
|
|
59
|
+
"""The hostname the result lives on."""
|
|
60
|
+
return host_of(self.url)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def unwrap_redirect(href: str) -> str:
|
|
64
|
+
"""Return the real target behind an engine's click-tracking redirect."""
|
|
65
|
+
try:
|
|
66
|
+
parts = urlsplit(href if "//" in href else f"https://{href}")
|
|
67
|
+
except ValueError:
|
|
68
|
+
return href
|
|
69
|
+
param = REDIRECT_PARAMS.get((parts.hostname or "").lower())
|
|
70
|
+
if not param:
|
|
71
|
+
return href
|
|
72
|
+
target = parse_qs(parts.query).get(param, [""])[0]
|
|
73
|
+
return target if target.lower().startswith(("http://", "https://")) else href
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def soft_block(engine: str) -> WebError:
|
|
77
|
+
"""The error raised when a results page parses to nothing.
|
|
78
|
+
|
|
79
|
+
An engine that answers 200 with no result rows has almost always served a
|
|
80
|
+
consent wall, a challenge, or its own home page. Reporting that as an
|
|
81
|
+
engine failure is more honest than reporting zero results, and it keeps a
|
|
82
|
+
silently blocked engine from looking like one that simply found nothing.
|
|
83
|
+
"""
|
|
84
|
+
return WebError(f"{engine} returned a page with no results, likely a soft block.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _clean(text: str) -> str:
|
|
88
|
+
"""Collapse whitespace in engine-supplied text."""
|
|
89
|
+
return " ".join(text.split())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _hit(title: str, url: str, snippet: str, engine: str) -> SearchHit | None:
|
|
93
|
+
"""Build a hit, dropping rows that lack a usable title or URL."""
|
|
94
|
+
title, snippet = _clean(title), _clean(snippet)
|
|
95
|
+
url = unwrap_redirect(url.strip())
|
|
96
|
+
if not title or not url.lower().startswith(("http://", "https://")):
|
|
97
|
+
return None
|
|
98
|
+
return SearchHit(title=title, url=normalize_url(url), snippet=snippet, engines=[engine])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Engine:
|
|
102
|
+
"""A single search back end."""
|
|
103
|
+
|
|
104
|
+
name: str
|
|
105
|
+
|
|
106
|
+
async def search(self, query: str, limit: int, timeout: float) -> list[SearchHit]:
|
|
107
|
+
"""Return this engine's ranked results for `query`."""
|
|
108
|
+
raise NotImplementedError
|
|
109
|
+
|
|
110
|
+
async def _get(
|
|
111
|
+
self, url: str, timeout: float, headers: dict[str, str] | None = None
|
|
112
|
+
) -> Response:
|
|
113
|
+
"""Fetch a results page, refusing anything but a plain 200.
|
|
114
|
+
|
|
115
|
+
An engine that has decided a request is a robot often answers 202 with
|
|
116
|
+
a challenge page rather than an error status. That body parses to no
|
|
117
|
+
results, so accepting it would report the engine as working and
|
|
118
|
+
finding nothing.
|
|
119
|
+
"""
|
|
120
|
+
response = await fetch_url(url, timeout=timeout, headers=headers)
|
|
121
|
+
if response.status != 200:
|
|
122
|
+
raise WebError(
|
|
123
|
+
f"{self.name} answered HTTP {response.status}, which is a "
|
|
124
|
+
"challenge page rather than results.",
|
|
125
|
+
status=response.status,
|
|
126
|
+
)
|
|
127
|
+
return response
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class DuckDuckGoEngine(Engine):
|
|
131
|
+
"""DuckDuckGo, via its no-JavaScript endpoints.
|
|
132
|
+
|
|
133
|
+
The lite endpoint is tried first because its markup is small and stable.
|
|
134
|
+
When it answers with something unparseable -- which is what a soft block
|
|
135
|
+
looks like -- the fuller HTML endpoint is tried before giving up.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
name = "duckduckgo"
|
|
139
|
+
|
|
140
|
+
async def search(self, query: str, limit: int, timeout: float) -> list[SearchHit]:
|
|
141
|
+
endpoints = (
|
|
142
|
+
(f"https://lite.duckduckgo.com/lite/?q={quote_plus(query)}", "a.result-link", ".result-snippet"),
|
|
143
|
+
(f"https://html.duckduckgo.com/html/?q={quote_plus(query)}", "a.result__a", ".result__snippet"),
|
|
144
|
+
)
|
|
145
|
+
last_error: Exception | None = None
|
|
146
|
+
for url, link_selector, snippet_selector in endpoints:
|
|
147
|
+
try:
|
|
148
|
+
response = await self._get(url, timeout)
|
|
149
|
+
except WebError as exc:
|
|
150
|
+
last_error = exc
|
|
151
|
+
continue
|
|
152
|
+
hits = self._parse(response.text, link_selector, snippet_selector, limit)
|
|
153
|
+
if hits:
|
|
154
|
+
return hits
|
|
155
|
+
raise last_error or soft_block(self.name)
|
|
156
|
+
|
|
157
|
+
def _parse(
|
|
158
|
+
self, html: str, link_selector: str, snippet_selector: str, limit: int
|
|
159
|
+
) -> list[SearchHit]:
|
|
160
|
+
root = parse_html(html)
|
|
161
|
+
links = root.select(link_selector)
|
|
162
|
+
snippets = root.select(snippet_selector)
|
|
163
|
+
hits: list[SearchHit] = []
|
|
164
|
+
for index, link in enumerate(links[:limit]):
|
|
165
|
+
snippet = snippets[index].text() if index < len(snippets) else ""
|
|
166
|
+
hit = _hit(link.text(), link.get("href"), snippet, self.name)
|
|
167
|
+
if hit is not None:
|
|
168
|
+
hits.append(hit)
|
|
169
|
+
return hits
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class MojeekEngine(Engine):
|
|
173
|
+
"""Mojeek, which runs its own crawl rather than reselling another index.
|
|
174
|
+
|
|
175
|
+
An independent index is worth including precisely because it disagrees:
|
|
176
|
+
when it ranks the same page highly as the majors, fusion has real
|
|
177
|
+
corroboration rather than two views of one crawl.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
name = "mojeek"
|
|
181
|
+
|
|
182
|
+
async def search(self, query: str, limit: int, timeout: float) -> list[SearchHit]:
|
|
183
|
+
url = f"https://www.mojeek.com/search?q={quote_plus(query)}&safe=0"
|
|
184
|
+
response = await self._get(url, timeout)
|
|
185
|
+
root = parse_html(response.text)
|
|
186
|
+
rows = root.select("ul.results-standard li, .results .result")
|
|
187
|
+
hits: list[SearchHit] = []
|
|
188
|
+
for row in rows[:limit]:
|
|
189
|
+
link = row.select_one("a.title") or row.select_one("h2 a") or row.select_one("a")
|
|
190
|
+
if link is None:
|
|
191
|
+
continue
|
|
192
|
+
snippet = row.select_one("p.s") or row.select_one(".description")
|
|
193
|
+
hit = _hit(
|
|
194
|
+
link.text(), link.get("href"), snippet.text() if snippet else "", self.name
|
|
195
|
+
)
|
|
196
|
+
if hit is not None:
|
|
197
|
+
hits.append(hit)
|
|
198
|
+
if not hits:
|
|
199
|
+
raise soft_block(self.name)
|
|
200
|
+
return hits
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class BingEngine(Engine):
|
|
204
|
+
"""Bing's web results page.
|
|
205
|
+
|
|
206
|
+
Result links arrive wrapped in a `bing.com/ck/a` tracker whose real target
|
|
207
|
+
is base64 in the `u` parameter, so they are decoded here; a URL a caller
|
|
208
|
+
cannot fetch is not a result.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
name = "bing"
|
|
212
|
+
|
|
213
|
+
async def search(self, query: str, limit: int, timeout: float) -> list[SearchHit]:
|
|
214
|
+
url = (
|
|
215
|
+
f"https://www.bing.com/search?q={quote_plus(query)}"
|
|
216
|
+
"&mkt=en-US&setlang=en&format=rss"
|
|
217
|
+
)
|
|
218
|
+
response = await self._get(url, timeout)
|
|
219
|
+
hits = self._parse_rss(response.text, limit)
|
|
220
|
+
if hits:
|
|
221
|
+
return hits
|
|
222
|
+
page = await self._get(
|
|
223
|
+
f"https://www.bing.com/search?q={quote_plus(query)}&mkt=en-US&setlang=en",
|
|
224
|
+
timeout,
|
|
225
|
+
)
|
|
226
|
+
hits = self._parse_html(page.text, limit)
|
|
227
|
+
if not hits:
|
|
228
|
+
raise soft_block(self.name)
|
|
229
|
+
return hits
|
|
230
|
+
|
|
231
|
+
def _parse_rss(self, xml: str, limit: int) -> list[SearchHit]:
|
|
232
|
+
"""Parse the RSS view, which Bing serves without anti-bot markup."""
|
|
233
|
+
root = parse_html(xml, xml=True)
|
|
234
|
+
hits: list[SearchHit] = []
|
|
235
|
+
for item in root.select("item")[:limit]:
|
|
236
|
+
title = item.select_one("title")
|
|
237
|
+
link = item.select_one("link")
|
|
238
|
+
description = item.select_one("description")
|
|
239
|
+
href = (link.text() if link is not None else "") or (
|
|
240
|
+
link.get("href") if link is not None else ""
|
|
241
|
+
)
|
|
242
|
+
hit = _hit(
|
|
243
|
+
title.text() if title else "",
|
|
244
|
+
href,
|
|
245
|
+
description.text() if description else "",
|
|
246
|
+
self.name,
|
|
247
|
+
)
|
|
248
|
+
if hit is not None:
|
|
249
|
+
hits.append(hit)
|
|
250
|
+
return hits
|
|
251
|
+
|
|
252
|
+
def _parse_html(self, html: str, limit: int) -> list[SearchHit]:
|
|
253
|
+
root = parse_html(html)
|
|
254
|
+
hits: list[SearchHit] = []
|
|
255
|
+
for row in root.select("li.b_algo")[:limit]:
|
|
256
|
+
link = row.select_one("h2 a")
|
|
257
|
+
if link is None:
|
|
258
|
+
continue
|
|
259
|
+
snippet = (
|
|
260
|
+
row.select_one(".b_lineclamp2")
|
|
261
|
+
or row.select_one(".b_lineclamp3")
|
|
262
|
+
or row.select_one(".b_caption p")
|
|
263
|
+
)
|
|
264
|
+
hit = _hit(
|
|
265
|
+
link.text(),
|
|
266
|
+
decode_bing_url(link.get("href")),
|
|
267
|
+
snippet.text() if snippet else "",
|
|
268
|
+
self.name,
|
|
269
|
+
)
|
|
270
|
+
if hit is not None:
|
|
271
|
+
hits.append(hit)
|
|
272
|
+
return hits
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def decode_bing_url(href: str) -> str:
|
|
276
|
+
"""Decode a `bing.com/ck/a` tracker back into its destination URL."""
|
|
277
|
+
try:
|
|
278
|
+
parts = urlsplit(href)
|
|
279
|
+
except ValueError:
|
|
280
|
+
return href
|
|
281
|
+
if not (parts.hostname or "").endswith("bing.com") or parts.path != "/ck/a":
|
|
282
|
+
return href
|
|
283
|
+
encoded = parse_qs(parts.query).get("u", [""])[0]
|
|
284
|
+
if len(encoded) < 4:
|
|
285
|
+
return href
|
|
286
|
+
body = encoded[2:].replace("-", "+").replace("_", "/")
|
|
287
|
+
body += "=" * ((4 - len(body) % 4) % 4)
|
|
288
|
+
try:
|
|
289
|
+
decoded = base64.b64decode(body).decode("utf-8")
|
|
290
|
+
except (ValueError, UnicodeDecodeError):
|
|
291
|
+
return href
|
|
292
|
+
return decoded if decoded.lower().startswith(("http://", "https://")) else href
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class WikipediaEngine(Engine):
|
|
296
|
+
"""Wikipedia's OpenSearch API, as an encyclopedic anchor.
|
|
297
|
+
|
|
298
|
+
It answers only when the query names a real subject, which is what makes
|
|
299
|
+
it useful in fusion: it breaks ties toward the topic a word denotes rather
|
|
300
|
+
than the company that happens to own the domain.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
name = "wikipedia"
|
|
304
|
+
|
|
305
|
+
async def search(self, query: str, limit: int, timeout: float) -> list[SearchHit]:
|
|
306
|
+
url = (
|
|
307
|
+
"https://en.wikipedia.org/w/api.php?action=opensearch&format=json"
|
|
308
|
+
f"&namespace=0&limit={min(max(limit, 1), 20)}&search={quote_plus(query)}"
|
|
309
|
+
)
|
|
310
|
+
response = await self._get(url, timeout, headers={"Accept": "application/json"})
|
|
311
|
+
try:
|
|
312
|
+
body = json.loads(response.text)
|
|
313
|
+
except json.JSONDecodeError:
|
|
314
|
+
return []
|
|
315
|
+
if not isinstance(body, list) or len(body) < 4:
|
|
316
|
+
return []
|
|
317
|
+
titles, snippets, urls = body[1], body[2], body[3]
|
|
318
|
+
hits: list[SearchHit] = []
|
|
319
|
+
for index, title in enumerate(titles[:limit]):
|
|
320
|
+
if index >= len(urls):
|
|
321
|
+
break
|
|
322
|
+
snippet = snippets[index] if index < len(snippets) else ""
|
|
323
|
+
hit = _hit(str(title), str(urls[index]), str(snippet), self.name)
|
|
324
|
+
if hit is not None:
|
|
325
|
+
hits.append(hit)
|
|
326
|
+
return hits
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class MarginaliaEngine(Engine):
|
|
330
|
+
"""Marginalia, a non-commercial index of the small web.
|
|
331
|
+
|
|
332
|
+
It surfaces long-tail pages the commercial crawlers deprioritise, which is
|
|
333
|
+
where a technical answer often actually lives. Its public tier is reached
|
|
334
|
+
with the literal key ``public`` and rate limits by returning 503.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
name = "marginalia"
|
|
338
|
+
|
|
339
|
+
async def search(self, query: str, limit: int, timeout: float) -> list[SearchHit]:
|
|
340
|
+
url = (
|
|
341
|
+
"https://api2.marginalia-search.com/search"
|
|
342
|
+
f"?query={quote_plus(query)}&count={limit}&dc=3"
|
|
343
|
+
)
|
|
344
|
+
response = await self._get(
|
|
345
|
+
url, timeout, headers={"Accept": "application/json", "API-Key": "public"}
|
|
346
|
+
)
|
|
347
|
+
try:
|
|
348
|
+
body = json.loads(response.text)
|
|
349
|
+
except json.JSONDecodeError:
|
|
350
|
+
return []
|
|
351
|
+
rows = body.get("results") if isinstance(body, dict) else None
|
|
352
|
+
if not isinstance(rows, list):
|
|
353
|
+
return []
|
|
354
|
+
hits: list[SearchHit] = []
|
|
355
|
+
for row in rows[:limit]:
|
|
356
|
+
if not isinstance(row, dict):
|
|
357
|
+
continue
|
|
358
|
+
hit = _hit(
|
|
359
|
+
str(row.get("title", "")),
|
|
360
|
+
str(row.get("url", "")),
|
|
361
|
+
str(row.get("description", "")),
|
|
362
|
+
self.name,
|
|
363
|
+
)
|
|
364
|
+
if hit is not None:
|
|
365
|
+
hits.append(hit)
|
|
366
|
+
return hits
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class HackerNewsEngine(Engine):
|
|
370
|
+
"""The Hacker News search API, for discussion and primary sources.
|
|
371
|
+
|
|
372
|
+
Included because it answers a question the web engines cannot: whether
|
|
373
|
+
practitioners have already argued about this, and what they linked to.
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
name = "hackernews"
|
|
377
|
+
|
|
378
|
+
async def search(self, query: str, limit: int, timeout: float) -> list[SearchHit]:
|
|
379
|
+
url = (
|
|
380
|
+
"https://hn.algolia.com/api/v1/search"
|
|
381
|
+
f"?query={quote_plus(query)}&tags=story&hitsPerPage={limit}"
|
|
382
|
+
)
|
|
383
|
+
response = await self._get(url, timeout, headers={"Accept": "application/json"})
|
|
384
|
+
try:
|
|
385
|
+
body = json.loads(response.text)
|
|
386
|
+
except json.JSONDecodeError:
|
|
387
|
+
return []
|
|
388
|
+
rows = body.get("hits") if isinstance(body, dict) else None
|
|
389
|
+
if not isinstance(rows, list):
|
|
390
|
+
return []
|
|
391
|
+
hits: list[SearchHit] = []
|
|
392
|
+
for row in rows[:limit]:
|
|
393
|
+
if not isinstance(row, dict):
|
|
394
|
+
continue
|
|
395
|
+
story = row.get("objectID", "")
|
|
396
|
+
url_value = row.get("url") or f"https://news.ycombinator.com/item?id={story}"
|
|
397
|
+
points, comments = row.get("points", 0), row.get("num_comments", 0)
|
|
398
|
+
hit = _hit(
|
|
399
|
+
str(row.get("title", "")),
|
|
400
|
+
str(url_value),
|
|
401
|
+
f"Hacker News discussion: {points} points, {comments} comments.",
|
|
402
|
+
self.name,
|
|
403
|
+
)
|
|
404
|
+
if hit is not None:
|
|
405
|
+
hits.append(hit)
|
|
406
|
+
return hits
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
GENERAL_ENGINES: tuple[type[Engine], ...] = (
|
|
410
|
+
DuckDuckGoEngine,
|
|
411
|
+
MojeekEngine,
|
|
412
|
+
BingEngine,
|
|
413
|
+
WikipediaEngine,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
WIDE_ENGINES: tuple[type[Engine], ...] = GENERAL_ENGINES + (
|
|
417
|
+
MarginaliaEngine,
|
|
418
|
+
HackerNewsEngine,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
ENGINES_BY_NAME = {
|
|
422
|
+
cls.name: cls for cls in (*WIDE_ENGINES,)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def fuse(ranked: list[list[SearchHit]], k: int = RRF_K) -> list[SearchHit]:
|
|
427
|
+
"""Merge per-engine rankings into one list by Reciprocal Rank Fusion.
|
|
428
|
+
|
|
429
|
+
Results are keyed on a normalized URL so the same page found by several
|
|
430
|
+
engines becomes one hit crediting all of them, and the engine that ranked
|
|
431
|
+
it best supplies the title and snippet.
|
|
432
|
+
"""
|
|
433
|
+
merged: dict[str, SearchHit] = {}
|
|
434
|
+
best_rank: dict[str, int] = {}
|
|
435
|
+
|
|
436
|
+
for hits in ranked:
|
|
437
|
+
for rank, hit in enumerate(hits, start=1):
|
|
438
|
+
key = dedup_key(hit.url)
|
|
439
|
+
existing = merged.get(key)
|
|
440
|
+
if existing is None:
|
|
441
|
+
merged[key] = SearchHit(
|
|
442
|
+
title=hit.title,
|
|
443
|
+
url=hit.url,
|
|
444
|
+
snippet=hit.snippet,
|
|
445
|
+
engines=list(hit.engines),
|
|
446
|
+
score=1 / (k + rank),
|
|
447
|
+
)
|
|
448
|
+
best_rank[key] = rank
|
|
449
|
+
continue
|
|
450
|
+
existing.score += 1 / (k + rank)
|
|
451
|
+
for engine in hit.engines:
|
|
452
|
+
if engine not in existing.engines:
|
|
453
|
+
existing.engines.append(engine)
|
|
454
|
+
if rank < best_rank[key]:
|
|
455
|
+
best_rank[key] = rank
|
|
456
|
+
existing.title = hit.title
|
|
457
|
+
if hit.snippet:
|
|
458
|
+
existing.snippet = hit.snippet
|
|
459
|
+
elif not existing.snippet and hit.snippet:
|
|
460
|
+
existing.snippet = hit.snippet
|
|
461
|
+
|
|
462
|
+
return sorted(merged.values(), key=lambda h: (-h.score, h.title.lower()))
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def query_terms(query: str) -> list[str]:
|
|
466
|
+
"""The content words of a query, lowercased and de-duplicated."""
|
|
467
|
+
seen: list[str] = []
|
|
468
|
+
for token in re.findall(r"[a-z0-9]+", query.lower()):
|
|
469
|
+
if len(token) < 3 or token in STOPWORDS:
|
|
470
|
+
continue
|
|
471
|
+
if token not in seen:
|
|
472
|
+
seen.append(token)
|
|
473
|
+
return seen
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def alignment(terms: list[str], hit: SearchHit) -> float:
|
|
477
|
+
"""The share of query terms that appear in a hit's title, snippet, or URL."""
|
|
478
|
+
if not terms:
|
|
479
|
+
return 1.0
|
|
480
|
+
haystack = f"{hit.title} {hit.snippet} {hit.url}".lower()
|
|
481
|
+
return sum(1 for term in terms if term in haystack) / len(terms)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def rank_by_relevance(query: str, hits: list[SearchHit]) -> list[SearchHit]:
|
|
485
|
+
"""Reweight fused hits by how well they match the query's own words.
|
|
486
|
+
|
|
487
|
+
Fusion on its own is topic-blind: every engine's first result gets the
|
|
488
|
+
same credit, so a back end that answers a loose query loosely -- a forum
|
|
489
|
+
search matching one word of five -- lands a stranger at the top of the
|
|
490
|
+
list. Scaling each score by the share of query terms the result actually
|
|
491
|
+
mentions costs an on-topic result nothing and pushes those strangers down,
|
|
492
|
+
while leaving cross-engine agreement as the deciding signal among results
|
|
493
|
+
that are all about the right thing.
|
|
494
|
+
"""
|
|
495
|
+
terms = query_terms(query)
|
|
496
|
+
for hit in hits:
|
|
497
|
+
hit.score *= 1 + alignment(terms, hit)
|
|
498
|
+
return sorted(hits, key=lambda h: (-h.score, h.title.lower()))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def apply_domain_filters(
|
|
502
|
+
hits: list[SearchHit],
|
|
503
|
+
allowed: list[str] | None,
|
|
504
|
+
blocked: list[str] | None,
|
|
505
|
+
) -> list[SearchHit]:
|
|
506
|
+
"""Keep only hits whose host passes the allow and block lists.
|
|
507
|
+
|
|
508
|
+
A list entry matches its own domain and every subdomain of it, so
|
|
509
|
+
``python.org`` covers ``docs.python.org``.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
def matches(host: str, domain: str) -> bool:
|
|
513
|
+
domain = domain.strip().lower().removeprefix("www.")
|
|
514
|
+
host = host.removeprefix("www.")
|
|
515
|
+
return bool(domain) and (host == domain or host.endswith(f".{domain}"))
|
|
516
|
+
|
|
517
|
+
result = hits
|
|
518
|
+
if allowed:
|
|
519
|
+
result = [h for h in result if any(matches(h.host, d) for d in allowed)]
|
|
520
|
+
if blocked:
|
|
521
|
+
result = [h for h in result if not any(matches(h.host, d) for d in blocked)]
|
|
522
|
+
return result
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@dataclass(slots=True)
|
|
526
|
+
class SearchReport:
|
|
527
|
+
"""The outcome of one fused search, including what each engine did."""
|
|
528
|
+
|
|
529
|
+
hits: list[SearchHit]
|
|
530
|
+
succeeded: list[str] = field(default_factory=list)
|
|
531
|
+
failed: dict[str, str] = field(default_factory=dict)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
async def search_web(
|
|
535
|
+
query: str,
|
|
536
|
+
*,
|
|
537
|
+
limit: int = 10,
|
|
538
|
+
engines: tuple[type[Engine], ...] = GENERAL_ENGINES,
|
|
539
|
+
timeout: float = 12.0,
|
|
540
|
+
allowed_domains: list[str] | None = None,
|
|
541
|
+
blocked_domains: list[str] | None = None,
|
|
542
|
+
) -> SearchReport:
|
|
543
|
+
"""Query every engine concurrently and return the fused ranking.
|
|
544
|
+
|
|
545
|
+
Each engine is given the same deadline and none can hold up the others: a
|
|
546
|
+
failure is recorded against its name and the rest of the results stand.
|
|
547
|
+
Only when every engine fails does the caller get an empty ranking with the
|
|
548
|
+
reasons attached.
|
|
549
|
+
"""
|
|
550
|
+
instances = [cls() for cls in engines]
|
|
551
|
+
per_engine = max(limit, 10)
|
|
552
|
+
|
|
553
|
+
async def run(engine: Engine) -> tuple[str, list[SearchHit] | str]:
|
|
554
|
+
try:
|
|
555
|
+
hits = await asyncio.wait_for(
|
|
556
|
+
engine.search(query, per_engine, timeout), timeout=timeout + 2
|
|
557
|
+
)
|
|
558
|
+
return engine.name, hits
|
|
559
|
+
except asyncio.TimeoutError:
|
|
560
|
+
return engine.name, f"timed out after {timeout:g}s"
|
|
561
|
+
except WebError as exc:
|
|
562
|
+
return engine.name, str(exc)
|
|
563
|
+
except Exception as exc:
|
|
564
|
+
return engine.name, f"{type(exc).__name__}: {exc}"
|
|
565
|
+
|
|
566
|
+
outcomes = await asyncio.gather(*(run(e) for e in instances))
|
|
567
|
+
|
|
568
|
+
ranked: list[list[SearchHit]] = []
|
|
569
|
+
report = SearchReport(hits=[])
|
|
570
|
+
for name, outcome in outcomes:
|
|
571
|
+
if isinstance(outcome, str):
|
|
572
|
+
report.failed[name] = outcome
|
|
573
|
+
continue
|
|
574
|
+
report.succeeded.append(name)
|
|
575
|
+
if outcome:
|
|
576
|
+
ranked.append(outcome)
|
|
577
|
+
|
|
578
|
+
fused = rank_by_relevance(query, fuse(ranked))
|
|
579
|
+
report.hits = apply_domain_filters(fused, allowed_domains, blocked_domains)[:limit]
|
|
580
|
+
return report
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
__all__ = [
|
|
584
|
+
"Engine",
|
|
585
|
+
"SearchHit",
|
|
586
|
+
"SearchReport",
|
|
587
|
+
"GENERAL_ENGINES",
|
|
588
|
+
"WIDE_ENGINES",
|
|
589
|
+
"ENGINES_BY_NAME",
|
|
590
|
+
"DuckDuckGoEngine",
|
|
591
|
+
"MojeekEngine",
|
|
592
|
+
"BingEngine",
|
|
593
|
+
"WikipediaEngine",
|
|
594
|
+
"MarginaliaEngine",
|
|
595
|
+
"HackerNewsEngine",
|
|
596
|
+
"search_web",
|
|
597
|
+
"fuse",
|
|
598
|
+
"rank_by_relevance",
|
|
599
|
+
"query_terms",
|
|
600
|
+
"alignment",
|
|
601
|
+
"apply_domain_filters",
|
|
602
|
+
"decode_bing_url",
|
|
603
|
+
"unwrap_redirect",
|
|
604
|
+
]
|