webless 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- webless-0.1.0/PKG-INFO +241 -0
- webless-0.1.0/README.md +215 -0
- webless-0.1.0/pyproject.toml +53 -0
- webless-0.1.0/src/webless/__init__.py +85 -0
- webless-0.1.0/src/webless/_engines.py +604 -0
- webless-0.1.0/src/webless/_fetch.py +145 -0
- webless-0.1.0/src/webless/_html.py +489 -0
- webless-0.1.0/src/webless/_http.py +281 -0
- webless-0.1.0/src/webless/_search.py +90 -0
- webless-0.1.0/src/webless/_sync.py +45 -0
- webless-0.1.0/src/webless/cli.py +121 -0
- webless-0.1.0/src/webless/py.typed +0 -0
webless-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ahmed Saqr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
webless-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webless
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Web search and page fetching with no API keys: multi-engine search with rank fusion, and HTML to Markdown
|
|
5
|
+
Keywords: search,web-search,scraping,duckduckgo,markdown,html-to-markdown,no-api-key
|
|
6
|
+
Author: Ahmed Saqr
|
|
7
|
+
Author-email: Ahmed Saqr <ahmedhassansaqr28@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Project-URL: Homepage, https://github.com/ahmedhassan456/webless
|
|
23
|
+
Project-URL: Repository, https://github.com/ahmedhassan456/webless
|
|
24
|
+
Project-URL: Issues, https://github.com/ahmedhassan456/webless/issues
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# webless
|
|
28
|
+
|
|
29
|
+
Web search and page fetching for Python, with **no API keys**.
|
|
30
|
+
|
|
31
|
+
`pip install webless` and the first call works. There is no account to
|
|
32
|
+
create, no key to configure, no quota to track, and no paid tier — every
|
|
33
|
+
back end is a public endpoint.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import asyncio
|
|
37
|
+
from webless import search, fetch
|
|
38
|
+
|
|
39
|
+
async def main():
|
|
40
|
+
result = await search("reciprocal rank fusion")
|
|
41
|
+
for hit in result[:3]:
|
|
42
|
+
print(hit.title, "—", hit.url)
|
|
43
|
+
|
|
44
|
+
page = await fetch(result[0].url)
|
|
45
|
+
print(page.content[:500])
|
|
46
|
+
|
|
47
|
+
asyncio.run(main())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
No event loop of your own? Use `search_sync` and `fetch_sync`, which take the
|
|
51
|
+
same arguments.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install webless
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The only dependency is `httpx`. HTML parsing, content extraction, and Markdown
|
|
60
|
+
rendering are all written against the standard library, so there is no
|
|
61
|
+
`lxml`, no `beautifulsoup4`, and no compiler needed to install.
|
|
62
|
+
|
|
63
|
+
## `search`
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
result = await search(
|
|
67
|
+
"sqlite write-ahead logging",
|
|
68
|
+
limit=10,
|
|
69
|
+
wide=False,
|
|
70
|
+
allowed_domains=["sqlite.org"],
|
|
71
|
+
blocked_domains=["pinterest.com"],
|
|
72
|
+
timeout=12.0,
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`search` queries several engines **concurrently** and fuses their rankings.
|
|
77
|
+
The general set is DuckDuckGo, Mojeek, Bing, and Wikipedia; `wide=True` adds
|
|
78
|
+
Marginalia's small-web index and Hacker News, which helps on niche or
|
|
79
|
+
discussion-oriented queries and costs some latency on ordinary ones.
|
|
80
|
+
|
|
81
|
+
Fusion is Reciprocal Rank Fusion: a result scores `Σ 1 / (60 + rank)` over the
|
|
82
|
+
engines that returned it. Rank is all it needs, which is the point — the
|
|
83
|
+
engines disagree about scoring and none exposes a comparable number, but they
|
|
84
|
+
all produce an ordered list, and a page several independent indexes rank
|
|
85
|
+
highly is a better answer than any one index's favourite. The fused ranking is
|
|
86
|
+
then reweighted by how much of the query actually appears in each result, so
|
|
87
|
+
an engine answering loosely cannot outrank one answering the question.
|
|
88
|
+
|
|
89
|
+
`allowed_domains` and `blocked_domains` filter on host, and an entry covers
|
|
90
|
+
its subdomains: `python.org` keeps `docs.python.org`.
|
|
91
|
+
|
|
92
|
+
### Results
|
|
93
|
+
|
|
94
|
+
`SearchResult` iterates, indexes, and measures like the list of hits it
|
|
95
|
+
carries, and also tells you what happened:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
result.hits # list[SearchHit]
|
|
99
|
+
result.succeeded # ["duckduckgo", "bing"]
|
|
100
|
+
result.failed # {"mojeek": "returned a page with no results, likely a soft block."}
|
|
101
|
+
result.ok # at least one engine answered
|
|
102
|
+
|
|
103
|
+
hit.title, hit.url, hit.snippet, hit.host, hit.engines, hit.score
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**A blocked engine is reported, not raised.** Public endpoints rate-limit,
|
|
107
|
+
soft-block, and serve challenge pages, and they do it differently from
|
|
108
|
+
different IPs. Every engine is given the same deadline and none can hold up
|
|
109
|
+
the others: a failure is recorded against its name and the rest of the ranking
|
|
110
|
+
stands. Only when all of them fail do you get an empty ranking — with the
|
|
111
|
+
reasons attached.
|
|
112
|
+
|
|
113
|
+
A soft block is detected rather than mistaken for an empty index. A results
|
|
114
|
+
page that returns HTTP 200 and parses to zero rows, or answers with a
|
|
115
|
+
challenge status, is reported as that engine failing.
|
|
116
|
+
|
|
117
|
+
## `fetch`
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
page = await fetch(
|
|
121
|
+
"https://docs.python.org/3/library/asyncio.html",
|
|
122
|
+
format="markdown",
|
|
123
|
+
full_page=False,
|
|
124
|
+
max_chars=None,
|
|
125
|
+
offset=0,
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`fetch` returns the page's readable content, not its markup. A page's HTML is
|
|
130
|
+
mostly navigation, scripts, and layout, so the default trims to the article
|
|
131
|
+
body and renders headings, lists, code blocks, tables, and links as Markdown —
|
|
132
|
+
small enough to read or feed to a model, structured enough to still quote from
|
|
133
|
+
and follow links out of. Relative links are absolutised against the final URL.
|
|
134
|
+
|
|
135
|
+
- `format="markdown"` — main content as Markdown (default)
|
|
136
|
+
- `format="text"` — plain text, no markup
|
|
137
|
+
- `format="raw"` — the untouched response body
|
|
138
|
+
- `full_page=True` — keep navigation, sidebars, and footers, for when the part
|
|
139
|
+
you want *is* the chrome (a docs index, a link list)
|
|
140
|
+
- `max_chars` / `offset` — read a long page in sections; `page.truncated` says
|
|
141
|
+
whether anything was left behind
|
|
142
|
+
|
|
143
|
+
JSON responses are returned pretty-printed.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
page.content # the rendered text
|
|
147
|
+
page.title # <og:title>, else <title>
|
|
148
|
+
page.description # meta description
|
|
149
|
+
page.url # what you asked for
|
|
150
|
+
page.final_url # where you ended up
|
|
151
|
+
page.redirected # whether those differ
|
|
152
|
+
page.status, page.content_type, page.host, page.truncated
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Already holding a response from your own client or a cache? `render(response,
|
|
156
|
+
format=..., full_page=...)` does the same conversion with no network call.
|
|
157
|
+
|
|
158
|
+
## Not blocking
|
|
159
|
+
|
|
160
|
+
Two senses, both deliberate.
|
|
161
|
+
|
|
162
|
+
**Nothing blocks anything else.** Every call is async with a hard timeout. A
|
|
163
|
+
search is several concurrent requests, so a slow engine delays its own result
|
|
164
|
+
and nothing more.
|
|
165
|
+
|
|
166
|
+
**Requests are hard to block.** Hosts refuse on fingerprint reputation, so a
|
|
167
|
+
retry after a 403 or 429 presents a *different* browser user agent rather than
|
|
168
|
+
repeating the one just refused — which clears most transient blocks on its
|
|
169
|
+
own. Retries back off exponentially with jitter.
|
|
170
|
+
|
|
171
|
+
## Not reachable inward
|
|
172
|
+
|
|
173
|
+
A URL that resolves to a private, loopback, link-local, or reserved address is
|
|
174
|
+
refused before the request is sent — and again on every redirect hop, since
|
|
175
|
+
redirects are followed by hand for exactly that reason. A URL from a search
|
|
176
|
+
result, a user, or a model cannot be steered into your own network.
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
await fetch("http://169.254.169.254/latest/meta-data/")
|
|
180
|
+
# WebError: ... resolves to a private or loopback address; refusing to fetch it
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Response bodies are capped, and redirect chains are limited to five hops.
|
|
184
|
+
|
|
185
|
+
## Command line
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
webless search "structured concurrency python" -n 5
|
|
189
|
+
webless search "rust async traits" --wide --allow rust-lang.org
|
|
190
|
+
webless fetch https://peps.python.org/pep-3156/ > pep.md
|
|
191
|
+
webless fetch https://api.github.com/repos/python/cpython --json | jq .status
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`--json` on either subcommand prints the full record, so it composes with
|
|
195
|
+
`jq`. `search` exits non-zero when every engine failed.
|
|
196
|
+
|
|
197
|
+
## Engines
|
|
198
|
+
|
|
199
|
+
| engine | how | notes |
|
|
200
|
+
|---|---|---|
|
|
201
|
+
| DuckDuckGo | scrape | lite view, falls back to the html view |
|
|
202
|
+
| Mojeek | scrape | independent index |
|
|
203
|
+
| Bing | RSS, then scrape | tracker URLs decoded |
|
|
204
|
+
| Wikipedia | OpenSearch API | |
|
|
205
|
+
| Marginalia | public API | `wide` only; small-web index |
|
|
206
|
+
| Hacker News | Algolia API | `wide` only; discussion |
|
|
207
|
+
|
|
208
|
+
Pick your own set with `engines=`:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from webless import search, DuckDuckGoEngine, MojeekEngine
|
|
212
|
+
|
|
213
|
+
result = await search("query", engines=(DuckDuckGoEngine, MojeekEngine))
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
A custom engine is any subclass of `Engine` with a `name` and an
|
|
217
|
+
`async search(query, limit, timeout) -> list[SearchHit]`.
|
|
218
|
+
|
|
219
|
+
## Honest caveats
|
|
220
|
+
|
|
221
|
+
These are public endpoints being read by a program, which has consequences
|
|
222
|
+
worth knowing before you build on them:
|
|
223
|
+
|
|
224
|
+
- **Availability varies by IP.** Mojeek soft-blocks some address ranges
|
|
225
|
+
outright; Marginalia's public key is shared and rate-limits. This is why
|
|
226
|
+
failures are per-engine and reported rather than fatal.
|
|
227
|
+
- **Bing occasionally serves results for an unrelated query** — the same
|
|
228
|
+
response carries the right query in its metadata and the wrong results in
|
|
229
|
+
its body. The relevance reweighting demotes them, but on a query where Bing
|
|
230
|
+
is the only engine answering you may see them.
|
|
231
|
+
- **Scrapers track markup.** When an engine changes its results page, its
|
|
232
|
+
parser needs updating; the soft-block detection turns that into a reported
|
|
233
|
+
failure rather than silence.
|
|
234
|
+
|
|
235
|
+
## Requirements
|
|
236
|
+
|
|
237
|
+
Python 3.10+. `httpx` 0.27+.
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT
|
webless-0.1.0/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# webless
|
|
2
|
+
|
|
3
|
+
Web search and page fetching for Python, with **no API keys**.
|
|
4
|
+
|
|
5
|
+
`pip install webless` and the first call works. There is no account to
|
|
6
|
+
create, no key to configure, no quota to track, and no paid tier — every
|
|
7
|
+
back end is a public endpoint.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import asyncio
|
|
11
|
+
from webless import search, fetch
|
|
12
|
+
|
|
13
|
+
async def main():
|
|
14
|
+
result = await search("reciprocal rank fusion")
|
|
15
|
+
for hit in result[:3]:
|
|
16
|
+
print(hit.title, "—", hit.url)
|
|
17
|
+
|
|
18
|
+
page = await fetch(result[0].url)
|
|
19
|
+
print(page.content[:500])
|
|
20
|
+
|
|
21
|
+
asyncio.run(main())
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
No event loop of your own? Use `search_sync` and `fetch_sync`, which take the
|
|
25
|
+
same arguments.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install webless
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The only dependency is `httpx`. HTML parsing, content extraction, and Markdown
|
|
34
|
+
rendering are all written against the standard library, so there is no
|
|
35
|
+
`lxml`, no `beautifulsoup4`, and no compiler needed to install.
|
|
36
|
+
|
|
37
|
+
## `search`
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
result = await search(
|
|
41
|
+
"sqlite write-ahead logging",
|
|
42
|
+
limit=10,
|
|
43
|
+
wide=False,
|
|
44
|
+
allowed_domains=["sqlite.org"],
|
|
45
|
+
blocked_domains=["pinterest.com"],
|
|
46
|
+
timeout=12.0,
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`search` queries several engines **concurrently** and fuses their rankings.
|
|
51
|
+
The general set is DuckDuckGo, Mojeek, Bing, and Wikipedia; `wide=True` adds
|
|
52
|
+
Marginalia's small-web index and Hacker News, which helps on niche or
|
|
53
|
+
discussion-oriented queries and costs some latency on ordinary ones.
|
|
54
|
+
|
|
55
|
+
Fusion is Reciprocal Rank Fusion: a result scores `Σ 1 / (60 + rank)` over the
|
|
56
|
+
engines that returned it. Rank is all it needs, which is the point — the
|
|
57
|
+
engines disagree about scoring and none exposes a comparable number, but they
|
|
58
|
+
all produce an ordered list, and a page several independent indexes rank
|
|
59
|
+
highly is a better answer than any one index's favourite. The fused ranking is
|
|
60
|
+
then reweighted by how much of the query actually appears in each result, so
|
|
61
|
+
an engine answering loosely cannot outrank one answering the question.
|
|
62
|
+
|
|
63
|
+
`allowed_domains` and `blocked_domains` filter on host, and an entry covers
|
|
64
|
+
its subdomains: `python.org` keeps `docs.python.org`.
|
|
65
|
+
|
|
66
|
+
### Results
|
|
67
|
+
|
|
68
|
+
`SearchResult` iterates, indexes, and measures like the list of hits it
|
|
69
|
+
carries, and also tells you what happened:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
result.hits # list[SearchHit]
|
|
73
|
+
result.succeeded # ["duckduckgo", "bing"]
|
|
74
|
+
result.failed # {"mojeek": "returned a page with no results, likely a soft block."}
|
|
75
|
+
result.ok # at least one engine answered
|
|
76
|
+
|
|
77
|
+
hit.title, hit.url, hit.snippet, hit.host, hit.engines, hit.score
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**A blocked engine is reported, not raised.** Public endpoints rate-limit,
|
|
81
|
+
soft-block, and serve challenge pages, and they do it differently from
|
|
82
|
+
different IPs. Every engine is given the same deadline and none can hold up
|
|
83
|
+
the others: a failure is recorded against its name and the rest of the ranking
|
|
84
|
+
stands. Only when all of them fail do you get an empty ranking — with the
|
|
85
|
+
reasons attached.
|
|
86
|
+
|
|
87
|
+
A soft block is detected rather than mistaken for an empty index. A results
|
|
88
|
+
page that returns HTTP 200 and parses to zero rows, or answers with a
|
|
89
|
+
challenge status, is reported as that engine failing.
|
|
90
|
+
|
|
91
|
+
## `fetch`
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
page = await fetch(
|
|
95
|
+
"https://docs.python.org/3/library/asyncio.html",
|
|
96
|
+
format="markdown",
|
|
97
|
+
full_page=False,
|
|
98
|
+
max_chars=None,
|
|
99
|
+
offset=0,
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`fetch` returns the page's readable content, not its markup. A page's HTML is
|
|
104
|
+
mostly navigation, scripts, and layout, so the default trims to the article
|
|
105
|
+
body and renders headings, lists, code blocks, tables, and links as Markdown —
|
|
106
|
+
small enough to read or feed to a model, structured enough to still quote from
|
|
107
|
+
and follow links out of. Relative links are absolutised against the final URL.
|
|
108
|
+
|
|
109
|
+
- `format="markdown"` — main content as Markdown (default)
|
|
110
|
+
- `format="text"` — plain text, no markup
|
|
111
|
+
- `format="raw"` — the untouched response body
|
|
112
|
+
- `full_page=True` — keep navigation, sidebars, and footers, for when the part
|
|
113
|
+
you want *is* the chrome (a docs index, a link list)
|
|
114
|
+
- `max_chars` / `offset` — read a long page in sections; `page.truncated` says
|
|
115
|
+
whether anything was left behind
|
|
116
|
+
|
|
117
|
+
JSON responses are returned pretty-printed.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
page.content # the rendered text
|
|
121
|
+
page.title # <og:title>, else <title>
|
|
122
|
+
page.description # meta description
|
|
123
|
+
page.url # what you asked for
|
|
124
|
+
page.final_url # where you ended up
|
|
125
|
+
page.redirected # whether those differ
|
|
126
|
+
page.status, page.content_type, page.host, page.truncated
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Already holding a response from your own client or a cache? `render(response,
|
|
130
|
+
format=..., full_page=...)` does the same conversion with no network call.
|
|
131
|
+
|
|
132
|
+
## Not blocking
|
|
133
|
+
|
|
134
|
+
Two senses, both deliberate.
|
|
135
|
+
|
|
136
|
+
**Nothing blocks anything else.** Every call is async with a hard timeout. A
|
|
137
|
+
search is several concurrent requests, so a slow engine delays its own result
|
|
138
|
+
and nothing more.
|
|
139
|
+
|
|
140
|
+
**Requests are hard to block.** Hosts refuse on fingerprint reputation, so a
|
|
141
|
+
retry after a 403 or 429 presents a *different* browser user agent rather than
|
|
142
|
+
repeating the one just refused — which clears most transient blocks on its
|
|
143
|
+
own. Retries back off exponentially with jitter.
|
|
144
|
+
|
|
145
|
+
## Not reachable inward
|
|
146
|
+
|
|
147
|
+
A URL that resolves to a private, loopback, link-local, or reserved address is
|
|
148
|
+
refused before the request is sent — and again on every redirect hop, since
|
|
149
|
+
redirects are followed by hand for exactly that reason. A URL from a search
|
|
150
|
+
result, a user, or a model cannot be steered into your own network.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
await fetch("http://169.254.169.254/latest/meta-data/")
|
|
154
|
+
# WebError: ... resolves to a private or loopback address; refusing to fetch it
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Response bodies are capped, and redirect chains are limited to five hops.
|
|
158
|
+
|
|
159
|
+
## Command line
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
webless search "structured concurrency python" -n 5
|
|
163
|
+
webless search "rust async traits" --wide --allow rust-lang.org
|
|
164
|
+
webless fetch https://peps.python.org/pep-3156/ > pep.md
|
|
165
|
+
webless fetch https://api.github.com/repos/python/cpython --json | jq .status
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`--json` on either subcommand prints the full record, so it composes with
|
|
169
|
+
`jq`. `search` exits non-zero when every engine failed.
|
|
170
|
+
|
|
171
|
+
## Engines
|
|
172
|
+
|
|
173
|
+
| engine | how | notes |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| DuckDuckGo | scrape | lite view, falls back to the html view |
|
|
176
|
+
| Mojeek | scrape | independent index |
|
|
177
|
+
| Bing | RSS, then scrape | tracker URLs decoded |
|
|
178
|
+
| Wikipedia | OpenSearch API | |
|
|
179
|
+
| Marginalia | public API | `wide` only; small-web index |
|
|
180
|
+
| Hacker News | Algolia API | `wide` only; discussion |
|
|
181
|
+
|
|
182
|
+
Pick your own set with `engines=`:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from webless import search, DuckDuckGoEngine, MojeekEngine
|
|
186
|
+
|
|
187
|
+
result = await search("query", engines=(DuckDuckGoEngine, MojeekEngine))
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
A custom engine is any subclass of `Engine` with a `name` and an
|
|
191
|
+
`async search(query, limit, timeout) -> list[SearchHit]`.
|
|
192
|
+
|
|
193
|
+
## Honest caveats
|
|
194
|
+
|
|
195
|
+
These are public endpoints being read by a program, which has consequences
|
|
196
|
+
worth knowing before you build on them:
|
|
197
|
+
|
|
198
|
+
- **Availability varies by IP.** Mojeek soft-blocks some address ranges
|
|
199
|
+
outright; Marginalia's public key is shared and rate-limits. This is why
|
|
200
|
+
failures are per-engine and reported rather than fatal.
|
|
201
|
+
- **Bing occasionally serves results for an unrelated query** — the same
|
|
202
|
+
response carries the right query in its metadata and the wrong results in
|
|
203
|
+
its body. The relevance reweighting demotes them, but on a query where Bing
|
|
204
|
+
is the only engine answering you may see them.
|
|
205
|
+
- **Scrapers track markup.** When an engine changes its results page, its
|
|
206
|
+
parser needs updating; the soft-block detection turns that into a reported
|
|
207
|
+
failure rather than silence.
|
|
208
|
+
|
|
209
|
+
## Requirements
|
|
210
|
+
|
|
211
|
+
Python 3.10+. `httpx` 0.27+.
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
MIT
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "webless"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Web search and page fetching with no API keys: multi-engine search with rank fusion, and HTML to Markdown"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Ahmed Saqr", email = "ahmedhassansaqr28@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
keywords = ["search", "web-search", "scraping", "duckduckgo", "markdown", "html-to-markdown", "no-api-key"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
22
|
+
"Topic :: Text Processing :: Markup :: HTML",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"httpx>=0.27",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
webless = "webless.cli:main"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/ahmedhassan456/webless"
|
|
34
|
+
Repository = "https://github.com/ahmedhassan456/webless"
|
|
35
|
+
Issues = "https://github.com/ahmedhassan456/webless/issues"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.11.22,<0.12.0"]
|
|
39
|
+
build-backend = "uv_build"
|
|
40
|
+
|
|
41
|
+
[tool.uv.build-backend]
|
|
42
|
+
module-name = "webless"
|
|
43
|
+
module-root = "src"
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8.0",
|
|
48
|
+
"pytest-asyncio>=0.24",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
asyncio_mode = "auto"
|
|
53
|
+
testpaths = ["tests"]
|
|
@@ -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
|
+
]
|