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 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
+ ]