searchify 0.3.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.
- searchify-0.3.0/LICENSE +21 -0
- searchify-0.3.0/PKG-INFO +488 -0
- searchify-0.3.0/README.md +446 -0
- searchify-0.3.0/pyproject.toml +54 -0
- searchify-0.3.0/setup.cfg +4 -0
- searchify-0.3.0/src/searchify/__init__.py +29 -0
- searchify-0.3.0/src/searchify/__main__.py +5 -0
- searchify-0.3.0/src/searchify/cli.py +352 -0
- searchify-0.3.0/src/searchify/config.py +55 -0
- searchify-0.3.0/src/searchify/extract/__init__.py +59 -0
- searchify-0.3.0/src/searchify/extract/camoufox_worker.py +64 -0
- searchify-0.3.0/src/searchify/extract/config.py +62 -0
- searchify-0.3.0/src/searchify/extract/egress.py +137 -0
- searchify-0.3.0/src/searchify/extract/htmltext.py +972 -0
- searchify-0.3.0/src/searchify/extract/netlimits.py +225 -0
- searchify-0.3.0/src/searchify/extract/pages.py +162 -0
- searchify-0.3.0/src/searchify/extract/pdf.py +423 -0
- searchify-0.3.0/src/searchify/extract/reader.py +748 -0
- searchify-0.3.0/src/searchify/extract/render.py +363 -0
- searchify-0.3.0/src/searchify/extract/stealth.py +125 -0
- searchify-0.3.0/src/searchify/extract/stealth_proxy.py +252 -0
- searchify-0.3.0/src/searchify/extract/structured.py +115 -0
- searchify-0.3.0/src/searchify/interview.py +154 -0
- searchify-0.3.0/src/searchify/limits.py +139 -0
- searchify-0.3.0/src/searchify/mcp_server.py +129 -0
- searchify-0.3.0/src/searchify/passages.py +154 -0
- searchify-0.3.0/src/searchify/profile.py +140 -0
- searchify-0.3.0/src/searchify/reasons.py +114 -0
- searchify-0.3.0/src/searchify/rerank.py +65 -0
- searchify-0.3.0/src/searchify/search/__init__.py +113 -0
- searchify-0.3.0/src/searchify/search/_common.py +74 -0
- searchify-0.3.0/src/searchify/search/cloud.py +134 -0
- searchify-0.3.0/src/searchify/search/duckduckgo.py +93 -0
- searchify-0.3.0/src/searchify/search/regions.py +129 -0
- searchify-0.3.0/src/searchify/search/searxng.py +89 -0
- searchify-0.3.0/src/searchify/service.py +137 -0
- searchify-0.3.0/src/searchify.egg-info/PKG-INFO +488 -0
- searchify-0.3.0/src/searchify.egg-info/SOURCES.txt +63 -0
- searchify-0.3.0/src/searchify.egg-info/dependency_links.txt +1 -0
- searchify-0.3.0/src/searchify.egg-info/entry_points.txt +3 -0
- searchify-0.3.0/src/searchify.egg-info/requires.txt +33 -0
- searchify-0.3.0/src/searchify.egg-info/top_level.txt +1 -0
- searchify-0.3.0/tests/test_chain_default.py +78 -0
- searchify-0.3.0/tests/test_cli.py +108 -0
- searchify-0.3.0/tests/test_extract.py +22 -0
- searchify-0.3.0/tests/test_fehler.py +319 -0
- searchify-0.3.0/tests/test_fetch.py +88 -0
- searchify-0.3.0/tests/test_fetch_limits.py +331 -0
- searchify-0.3.0/tests/test_fetch_reasons.py +92 -0
- searchify-0.3.0/tests/test_leiter.py +209 -0
- searchify-0.3.0/tests/test_leiter_api.py +195 -0
- searchify-0.3.0/tests/test_leiter_cli.py +329 -0
- searchify-0.3.0/tests/test_leiter_golden.py +35 -0
- searchify-0.3.0/tests/test_mcp_server.py +79 -0
- searchify-0.3.0/tests/test_passages.py +144 -0
- searchify-0.3.0/tests/test_pdf.py +585 -0
- searchify-0.3.0/tests/test_profile.py +128 -0
- searchify-0.3.0/tests/test_region.py +196 -0
- searchify-0.3.0/tests/test_search_backends.py +72 -0
- searchify-0.3.0/tests/test_service.py +79 -0
- searchify-0.3.0/tests/test_structured.py +191 -0
- searchify-0.3.0/tests/test_structured_and_failures.py +150 -0
- searchify-0.3.0/tests/test_structured_optin.py +64 -0
- searchify-0.3.0/tests/test_tavily.py +99 -0
- searchify-0.3.0/tests/test_ticker.py +31 -0
searchify-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 searchify contributors
|
|
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.
|
searchify-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: searchify
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Local-first web search, clean content extraction and fast reranking. No API key required.
|
|
5
|
+
Author: searchify contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sami-mag07/searchify
|
|
8
|
+
Project-URL: Issues, https://github.com/sami-mag07/searchify/issues
|
|
9
|
+
Keywords: search,web-search,scraping,extraction,reranking,searxng,duckduckgo
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
13
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Requires-Dist: httpcore>=1.0
|
|
19
|
+
Requires-Dist: trafilatura>=1.8
|
|
20
|
+
Requires-Dist: lxml>=5.0
|
|
21
|
+
Requires-Dist: resiliparse>=0.14
|
|
22
|
+
Requires-Dist: curl_cffi>=0.7
|
|
23
|
+
Requires-Dist: ddgs>=6.0
|
|
24
|
+
Requires-Dist: typer>=0.12
|
|
25
|
+
Provides-Extra: rerank
|
|
26
|
+
Requires-Dist: sentence-transformers>=3.0; extra == "rerank"
|
|
27
|
+
Provides-Extra: browser
|
|
28
|
+
Requires-Dist: playwright>=1.44; extra == "browser"
|
|
29
|
+
Provides-Extra: stealth
|
|
30
|
+
Requires-Dist: camoufox>=0.4; extra == "stealth"
|
|
31
|
+
Provides-Extra: api
|
|
32
|
+
Requires-Dist: fastapi>=0.110; extra == "api"
|
|
33
|
+
Requires-Dist: uvicorn>=0.29; extra == "api"
|
|
34
|
+
Requires-Dist: pydantic>=2.6; extra == "api"
|
|
35
|
+
Provides-Extra: pdf
|
|
36
|
+
Requires-Dist: pypdfium2<6,>=5.14; extra == "pdf"
|
|
37
|
+
Provides-Extra: mcp
|
|
38
|
+
Requires-Dist: mcp<3,>=2.3; python_version >= "3.10" and extra == "mcp"
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
<p align="center">
|
|
44
|
+
<picture>
|
|
45
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
|
|
46
|
+
<img src="assets/logo-light.svg" alt="searchify" width="380">
|
|
47
|
+
</picture>
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
**Web search for AI agents, running on your own machine.** Ask a question and get back the
|
|
51
|
+
passages that answer it, with their sources. Search, clean page extraction and a fast
|
|
52
|
+
reranker in one small Python package. No API key needed: search runs over DuckDuckGo or
|
|
53
|
+
your own SearXNG instance, paid providers are an optional fallback.
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
$ python examples/answer.py "How much does the NVIDIA DGX Spark cost and how much memory does it have?"
|
|
57
|
+
1. How much does the NVIDIA DGX Spark cost? The Founders Edition launched at $3,999 in
|
|
58
|
+
October 2025 and now lists for $4,699 on NVIDIA's marketplace after a ...
|
|
59
|
+
gpusmith.com
|
|
60
|
+
2. The NVIDIA DGX Spark is a compact desktop AI computer (150mm x 150mm, 1.2kg) powered by
|
|
61
|
+
the NVIDIA GB10 Grace Blackwell Superchip. It delivers 1 petaFLOP of ...
|
|
62
|
+
www.aitooldiscovery.com
|
|
63
|
+
3. The NVIDIA DGX Spark is a palm-sized desktop AI computer built on the GB10 Grace Blackwell
|
|
64
|
+
Superchip, with 128 GB of unified memory, 273 GB/s of bandwidth, and ...
|
|
65
|
+
millionminer.com
|
|
66
|
+
|
|
67
|
+
searched and read 5 pages in 5.8 s, ranked 254 passages in 1.3 s
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A price that changed after launch is exactly what a language model gets wrong from memory.
|
|
71
|
+
Searchify reads the live pages, ranks every paragraph against the question and returns the
|
|
72
|
+
best one per source. The core of [`examples/answer.py`](examples/answer.py):
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from searchify import search, extract, rerank
|
|
76
|
+
|
|
77
|
+
hits = search(question, max_results=8) # keyless web search
|
|
78
|
+
pages = extract([h["url"] for h in hits[:5]]) # clean text of the top pages
|
|
79
|
+
passages = [{"content": p, "url": page["url"]} # one item per paragraph
|
|
80
|
+
for page in pages for p in page["content"].split("\n")
|
|
81
|
+
if len(p.split()) >= 12]
|
|
82
|
+
ranked = rerank(question, passages) # best paragraphs first
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Run it once and the reranker model (about 470 MB) downloads; after that it is cached.
|
|
86
|
+
|
|
87
|
+
## Install
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pip install "searchify[rerank]"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Extra | Adds |
|
|
94
|
+
|---|---|
|
|
95
|
+
| (none) | search and extraction |
|
|
96
|
+
| `rerank` | the cross-encoder reranker (`sentence-transformers`) |
|
|
97
|
+
| `api` | the HTTP service (`fastapi`, `uvicorn`) |
|
|
98
|
+
| `browser` | the browser stages for JavaScript and bot-protected pages (`playwright`) |
|
|
99
|
+
| `stealth` | the optional Camoufox stage |
|
|
100
|
+
| `mcp` | the MCP server for Claude Code, Claude Desktop, Cursor and other hosts (`mcp`, Python 3.10+) |
|
|
101
|
+
| `pdf` | reading PDFs (`pypdfium2`): text with `[Seite N]` page marks, up to 50 pages and 60,000 characters |
|
|
102
|
+
|
|
103
|
+
Then set nothing and search works. Copy `.env.example` to `.env` only if you want a SearXNG
|
|
104
|
+
instance or a cloud fallback.
|
|
105
|
+
|
|
106
|
+
## What you get
|
|
107
|
+
|
|
108
|
+
| Call | What it does |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `search(query)` | Web results as `{title, url, content, engine}`. DuckDuckGo by default, or your own SearXNG instance, with an optional cloud fallback. |
|
|
111
|
+
| `extract(urls)` | The clean article text of each page, without menus, footers or cookie banners. Starts with a fast fetch and only opens a browser when a page needs one. Compared with 0.2.0 a normal page only gains the small field `stage` (which rung of the ladder read it); shop pages also carry `product` (name, price, currency, availability, GTIN, SKU, brand from their JSON-LD). The raw JSON-LD and meta tags stay opt-in: they come only with `structured=True` / `--structured`. With the `pdf` extra, PDFs are read too. |
|
|
112
|
+
| `extract_report(urls)` | Like `extract`, plus `failed` (each page that didn't work, with its `error` text and a fixed `reason` code) and always `status` (`read`, `failed`, `thin`, `reasons`; after a clean run `failed` and `thin` are 0 and `reasons` is empty). |
|
|
113
|
+
| `rerank(query, items)` | Reorders results or passages best-first with a multilingual cross-encoder (`mmarco-mMiniLMv2`). Point `RERANKER_MODEL` at your own model to swap it. |
|
|
114
|
+
| `passages(query)` | The three above in one call: searches, reads the top pages and returns the paragraphs that answer the query, best first, each with its source URL. Keeps what a model has to read small. |
|
|
115
|
+
| `POST /search`, `POST /extract`, `POST /passages` | The same as an HTTP service, see below. |
|
|
116
|
+
| `searchify-mcp` | The same as tools for any MCP host, see below. |
|
|
117
|
+
|
|
118
|
+
## Command line
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
searchify search "Where is the Eiffel Tower?"
|
|
122
|
+
searchify extract "https://en.wikipedia.org/wiki/Eiffel_Tower"
|
|
123
|
+
searchify passages "How tall is the Eiffel Tower?" -k 3 --per-site 1
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Output is JSON by default (`{"results": [...]}`, errors as `{"error": ...}` with exit code 1),
|
|
127
|
+
so agents and scripts can parse it. A search that found nothing because every backend failed
|
|
128
|
+
(a DuckDuckGo rate limit, an unreachable SearXNG) reports that error instead of an empty list. Add `--no-json` for a readable view. `search` takes
|
|
129
|
+
`-n`, `--backend`, `--language`, `--country` (with `--language nl` DuckDuckGo gets `nl-nl`, with `--country BE` `be-nl`; no language means worldwide, `any` too), `--time-range`, `--include-domain` and `--exclude-domain`;
|
|
130
|
+
`extract` takes `--format text|markdown` and several URLs. `passages` takes the search options
|
|
131
|
+
plus `-k` (passages to return), `--per-site`, `--max-chars` and `--rerank/--no-rerank`; it
|
|
132
|
+
reranks by default when the `rerank` extra is installed.
|
|
133
|
+
|
|
134
|
+
A full question in the user's words finds more than keywords: the query goes to the search
|
|
135
|
+
engine as written and then ranks the paragraphs. Each command line call loads the reranker
|
|
136
|
+
again, so for many questions in a row the HTTP service or the MCP server is faster.
|
|
137
|
+
|
|
138
|
+
### Why a page failed
|
|
139
|
+
|
|
140
|
+
Every failed page keeps its human `error` text and gets one `reason`: `blocked` (401/403/451),
|
|
141
|
+
`rate_limited` (429), `not_found` (404/410), `unavailable`, `timeout`, `not_html`, `too_large`,
|
|
142
|
+
`address_blocked` (a private or internal address, refused before any connection),
|
|
143
|
+
`no_main_text`, `extract_failed`, `thin_content`, `challenge`, `pdf_encrypted`, `pdf_no_text`,
|
|
144
|
+
`pdf_too_large`. The command line and `extract_report` always put `reason` on each failed page.
|
|
145
|
+
The HTTP `/extract` lists failed pages with `url` and `error` only and adds `reason` to each of
|
|
146
|
+
them with `detail: true` in the request body; it repeats the counts in `status` and in the
|
|
147
|
+
`X-Searchify-Status` header, built from these codes only. On full success `/extract` adds neither
|
|
148
|
+
`status` nor the header.
|
|
149
|
+
|
|
150
|
+
## MCP server
|
|
151
|
+
|
|
152
|
+
`searchify-mcp` offers three tools to any MCP host: `passages` (start here for questions),
|
|
153
|
+
`search` and `extract`. All of them only read from the web. The time filter offers models
|
|
154
|
+
only `month` and `year`: with `day` or `week` they hide the answer to questions about past
|
|
155
|
+
events.
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
claude mcp add searchify -- uvx --from "searchify[mcp,rerank]" searchify-mcp
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Claude Desktop (`claude_desktop_config.json`) and Cursor (`.cursor/mcp.json`) take the same
|
|
162
|
+
command through [uv](https://docs.astral.sh/uv/), with settings in `env`:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"mcpServers": {
|
|
167
|
+
"searchify": {
|
|
168
|
+
"command": "uvx",
|
|
169
|
+
"args": ["--from", "searchify[mcp,rerank]", "searchify-mcp"],
|
|
170
|
+
"env": {"SEARXNG_URL": "http://localhost:8080"}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Without uv: `pip install "searchify[mcp,rerank]"` and give the full path from
|
|
177
|
+
`which searchify-mcp` as `command`.
|
|
178
|
+
|
|
179
|
+
## How the research agent works
|
|
180
|
+
|
|
181
|
+
The package finds and reads pages. [`skill/`](skill/) turns it into a research agent: drop the
|
|
182
|
+
folder into Claude Code (or any agent with skills) and it researches the way a careful analyst
|
|
183
|
+
does.
|
|
184
|
+
|
|
185
|
+
1. **Every claim needs a checked source.** Each statement in the answer gets a source number
|
|
186
|
+
and a verbatim quote, and `sources.py quote` only accepts the quote if it really appears in
|
|
187
|
+
the page text. Before the answer counts as done, `sources.py verify` fails if any claim has
|
|
188
|
+
no checked quote, and with `--numbers` also if a number in a sentence is not in its quotes.
|
|
189
|
+
An invented fact or a changed figure stops the answer instead of reaching you.
|
|
190
|
+
2. **Sources are kept, not thrown away.** URLs and quotes stay in a ledger file, and every hit
|
|
191
|
+
goes into a shared found-list (`--seen`). You can come back later, re-check every quote
|
|
192
|
+
live (`verify --check-urls`) and continue the research where it stopped.
|
|
193
|
+
3. **Not just your words as typed.** The agent searches the question several ways: other
|
|
194
|
+
languages, synonyms, the provider's and the participant's point of view, a broad and a
|
|
195
|
+
niche term, filters like `site:` or the year. Duplicates are merged, and hits that several
|
|
196
|
+
engines found rank higher. In our own test (9 research tasks, October 2026), three extra niche
|
|
197
|
+
queries raised the share of known good sources in the top 30 from 18% to 23%.
|
|
198
|
+
|
|
199
|
+
The skill's own web search (`skill/references/search.py`) uses Tavily, Exa, Serper and Jina,
|
|
200
|
+
each only if you set its key (all have free tiers). Setup: [`skill/README.md`](skill/README.md).
|
|
201
|
+
|
|
202
|
+
## Benchmark
|
|
203
|
+
|
|
204
|
+
Run `final_2031`, 15 Oct 2026, 146 fresh pages (Wikipedia, GitHub, Hacker News, news,
|
|
205
|
+
bot-protected) against the top open-source scrapers on GitHub. Fresh means no page was seen
|
|
206
|
+
in an earlier run, so nobody serves it from cache. Scrapling is not included in this run.
|
|
207
|
+
|
|
208
|
+
**Quality (F1) per scraper**, higher is better:
|
|
209
|
+
|
|
210
|
+
```
|
|
211
|
+
searchify 84% ████████████████▊ best
|
|
212
|
+
patchright 83% ████████████████▌
|
|
213
|
+
trafilatura 82% ████████████████▍
|
|
214
|
+
firecrawl 73% ██████████████▌
|
|
215
|
+
crawl4ai 66% █████████████▏
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
F1 is the balance of precision and recall of the extracted main text: it drops both when real
|
|
219
|
+
article text is missing and when boilerplate leaks in. Searchify leads on F1 and on the share
|
|
220
|
+
of pages it handles at all, while two tools (firecrawl, crawl4ai) are fast but let a lot of
|
|
221
|
+
boilerplate through, which is why their F1 is low despite a high raw hit rate.
|
|
222
|
+
|
|
223
|
+
**Full table:**
|
|
224
|
+
|
|
225
|
+
| Scraper | Handled | Hit | Clean | F1 | p50 | p95 | RAM |
|
|
226
|
+
|---|---:|---:|---:|---:|---:|---:|---:|
|
|
227
|
+
| **searchify** | **87%** | 91% | 84% | **84%** | 0.47 s | 5.00 s | 3751 MB |
|
|
228
|
+
| own baseline | 85% | 91% | 81% | 82% | 0.24 s | 1.30 s | 4364 MB |
|
|
229
|
+
| patchright | 80% | 88% | 85% | 83% | 1.80 s | 7.01 s | 2196 MB |
|
|
230
|
+
| trafilatura | 79% | 88% | 84% | 82% | 0.23 s | 0.86 s | 195 MB |
|
|
231
|
+
| firecrawl | 84% | 92% | 65% | 73% | 2.68 s | 8.79 s | 3947 MB |
|
|
232
|
+
| crawl4ai | 84% | 92% | 58% | 66% | 1.11 s | 3.44 s | 1995 MB |
|
|
233
|
+
|
|
234
|
+
What the columns mean:
|
|
235
|
+
|
|
236
|
+
- **Handled**: pages that returned usable content at all.
|
|
237
|
+
- **Hit**: pages where the expected text was found.
|
|
238
|
+
- **Clean**: how much of the output is real article text and not navigation or boilerplate.
|
|
239
|
+
- **F1**: the overall quality score (precision and recall combined).
|
|
240
|
+
- **p50 / p95**: median and 95th-percentile response time.
|
|
241
|
+
- **RAM**: peak memory. Searchify's figure includes the reranker that runs inside the same
|
|
242
|
+
service (around 1 to 2 GB); the other tools have no reranker, so their RAM is not directly
|
|
243
|
+
comparable.
|
|
244
|
+
|
|
245
|
+
**Bot-protected pages (the hard set)**, share handled, higher is better:
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
searchify 31% ███████████████▌ best
|
|
249
|
+
own baseline 19% █████████▌
|
|
250
|
+
firecrawl 19% █████████▌
|
|
251
|
+
crawl4ai 15% ███████▌
|
|
252
|
+
patchright 0%
|
|
253
|
+
trafilatura 0%
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Static-only tools (trafilatura) and plain headless browsers (patchright, in this run) get nothing
|
|
257
|
+
on these pages. Searchify gets the most through because its last stage waits out the challenge
|
|
258
|
+
on a real browser behind the outbound proxy.
|
|
259
|
+
|
|
260
|
+
**Versus the previous own baseline:** cleaner output (+3), higher F1 (+2), and 500 to 600 MB less
|
|
261
|
+
RAM. The one regression is p95 (1.3 s to 5.0 s): blocked pages wait up to 6 seconds for the
|
|
262
|
+
Cloudflare challenge to resolve. That affects about 4% of pages and is a deliberate trade, since
|
|
263
|
+
the challenge only clears after roughly 5 seconds.
|
|
264
|
+
|
|
265
|
+
Rules kept during tuning: no captcha solving, no result gaming (never remove real text just to
|
|
266
|
+
raise a number), the safety limits never weakened. The run measured the default ladder below;
|
|
267
|
+
the optional Camoufox stage (`READER_STEALTH=1`) was not part of it.
|
|
268
|
+
|
|
269
|
+
### 0.3.0 against 0.2.0
|
|
270
|
+
|
|
271
|
+
The same fixed test lists, run once with 0.2.0 and once with this version (`bench/feature_bench.py`):
|
|
272
|
+
|
|
273
|
+
<picture>
|
|
274
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sami-mag07/searchify/main/docs/bench/features-dark.svg">
|
|
275
|
+
<img alt="0.2.0 against 0.3.0: normal pages 14/15 to 15/15, failed pages with a reason 0/29 to 23/29, PDFs read 0/20 to 20/20, shop prices 0/29 to 25/29, regional questions 20/40 to 27/40" src="https://raw.githubusercontent.com/sami-mag07/searchify/main/docs/bench/features-light.svg">
|
|
276
|
+
</picture>
|
|
277
|
+
|
|
278
|
+
Normal pages are as fast as before: median 0.131 s with 0.2.0 and 0.128 s with 0.3.0 over five
|
|
279
|
+
alternating runs.
|
|
280
|
+
|
|
281
|
+
**What a model gets to read.** 123 questions in German, English, Spanish and French. Every
|
|
282
|
+
version gets the same full question and the same recorded search hits, so only the processing
|
|
283
|
+
differs. Scores are on the 73 test questions held out while tuning (`bench/record.py`,
|
|
284
|
+
`bench/loop.py`, `bench/viz.py`, charts from `bench/charts.py`).
|
|
285
|
+
|
|
286
|
+
<picture>
|
|
287
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sami-mag07/searchify/main/docs/bench/answers-dark.svg">
|
|
288
|
+
<img alt="Answer in the first 4,800 characters and MRR: 0.2.0 snippets 95% and 0.80, 0.2.0 whole pages 92% and 0.75, 0.3.0 reranked snippets 95% and 0.91, 0.3.0 passages 100% and 0.92" src="https://raw.githubusercontent.com/sami-mag07/searchify/main/docs/bench/answers-light.svg">
|
|
289
|
+
</picture>
|
|
290
|
+
|
|
291
|
+
MRR is 1 when the first passage holds the answer and 0.5 when the second does. Reranking alone
|
|
292
|
+
lifts the snippets from 0.80 to 0.91; passages read from the pages also find the answer for every
|
|
293
|
+
question, where snippets miss about one in twenty.
|
|
294
|
+
|
|
295
|
+
<picture>
|
|
296
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sami-mag07/searchify/main/docs/bench/draws-dark.svg">
|
|
297
|
+
<img alt="Twenty random draws of the questions per version: the 0.2.0 clouds sit between 0.71 and 0.86 on the held-out questions, the 0.3.0 clouds between 0.89 and 0.97" src="https://raw.githubusercontent.com/sami-mag07/searchify/main/docs/bench/draws-light.svg">
|
|
298
|
+
</picture>
|
|
299
|
+
|
|
300
|
+
Each small dot is one random draw of the questions with replacement, the large dot the score over
|
|
301
|
+
all of them. The 0.2.0 and 0.3.0 clouds do not overlap, so the gain is not luck of the question
|
|
302
|
+
set. A full question beats keywords in all four languages, and a tuning loop over 1.7 million
|
|
303
|
+
settings found nothing that beats the defaults on the held-out questions.
|
|
304
|
+
|
|
305
|
+
## How it works
|
|
306
|
+
|
|
307
|
+
```mermaid
|
|
308
|
+
flowchart LR
|
|
309
|
+
Q[Question] --> S[search<br/>DuckDuckGo or SearXNG]
|
|
310
|
+
S --> F[extract<br/>fast fetch first]
|
|
311
|
+
F -->|thin or blocked| B[browser stages<br/>behind a safe proxy]
|
|
312
|
+
F --> X[clean text<br/>resiliparse, trafilatura]
|
|
313
|
+
B --> X
|
|
314
|
+
X --> R[rerank<br/>cross-encoder]
|
|
315
|
+
R --> O[Best passages<br/>with sources]
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Every stage only runs when the previous one was not enough: most pages never open a
|
|
319
|
+
browser, and the browser stages only ever connect to public addresses on the normal web
|
|
320
|
+
ports. Details: [`docs/architecture.md`](docs/architecture.md).
|
|
321
|
+
|
|
322
|
+
## Search backends
|
|
323
|
+
|
|
324
|
+
| Backend | Key needed | Notes |
|
|
325
|
+
|---|---|---|
|
|
326
|
+
| `duckduckgo` | no | Default. Works immediately via the `ddgs` package. |
|
|
327
|
+
| `searxng` | no | Your own instance. Fast and private; set `SEARXNG_URL`. |
|
|
328
|
+
| `serper` | yes | Optional Google fallback. Off unless `SERPER_API_KEY` is set. |
|
|
329
|
+
| `tavily` | yes | Last safety net. Off unless `TAVILY_API_KEY` is set, and then only asked when every other backend found nothing. |
|
|
330
|
+
|
|
331
|
+
Choose the primary with `SEARCH_BACKEND` and a fallback with `SEARCH_FALLBACK`.
|
|
332
|
+
The default chain is local only: DuckDuckGo, then SearXNG if configured.
|
|
333
|
+
|
|
334
|
+
With `TAVILY_API_KEY` set, Tavily becomes the last step in two places: in search when
|
|
335
|
+
every backend came back empty, and in reading when every local stage (static fetch,
|
|
336
|
+
browser, Camoufox) failed on a page. When the local stack works, Tavily is never called.
|
|
337
|
+
|
|
338
|
+
```python
|
|
339
|
+
from searchify import search
|
|
340
|
+
|
|
341
|
+
# keyless, out of the box
|
|
342
|
+
hits = search("who makes solid-state batteries", max_results=10)
|
|
343
|
+
|
|
344
|
+
# your own SearXNG instance
|
|
345
|
+
hits = search("quarterly earnings tsmc", backend="searxng", time_range="month")
|
|
346
|
+
|
|
347
|
+
# restrict to domains
|
|
348
|
+
hits = search("transformer architecture", include_domains=["arxiv.org"])
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## HTTP service
|
|
352
|
+
|
|
353
|
+
Run the service and send it web addresses, one or many at a time:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
uvicorn searchify.service:app
|
|
357
|
+
curl -s http://localhost:8000/search -H 'content-type: application/json' -d '{"query": "solid-state batteries"}'
|
|
358
|
+
curl -s http://localhost:8000/extract -H 'content-type: application/json' \
|
|
359
|
+
-d '{"urls": ["https://en.wikipedia.org/wiki/Web_scraping",
|
|
360
|
+
"https://en.wikipedia.org/wiki/Data_scraping"]}'
|
|
361
|
+
curl -s http://localhost:8000/passages -H 'content-type: application/json' \
|
|
362
|
+
-d '{"query": "How tall is the Eiffel Tower?", "top_k": 3}'
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
The service has no login of its own, so keep it on localhost (uvicorn's default `127.0.0.1`) or
|
|
366
|
+
put it behind your own authentication. Anyone who can reach it can use the Tavily and Serper keys
|
|
367
|
+
set in its environment and choose the search backend. `SEARCHIFY_MAX_STAGE` caps the load ladder
|
|
368
|
+
for every request, whatever the request asks for.
|
|
369
|
+
|
|
370
|
+
In Python:
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
import requests
|
|
374
|
+
|
|
375
|
+
answer = requests.post(
|
|
376
|
+
"http://localhost:8000/extract",
|
|
377
|
+
json={"urls": ["https://en.wikipedia.org/wiki/Web_scraping"]},
|
|
378
|
+
)
|
|
379
|
+
print(answer.json()) # the clean text for each address
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`/extract` takes at most 50 addresses per request; a longer list is refused with status 422.
|
|
383
|
+
It answers `{"results": [...], "failed": [...]}`. Each result has `url`, `title`,
|
|
384
|
+
`published`, `author`, `description`, `content`, `links` and `stage`, plus `structured` only when
|
|
385
|
+
the request asks for it with `"structured": true`:
|
|
386
|
+
|
|
387
|
+
- `structured.json_ld` (only with `"structured": true`) is the list of the page's JSON-LD objects as they are, so a shop page's
|
|
388
|
+
`Product` with `offers.price`, `priceCurrency`, `availability` and `gtin13` comes out without
|
|
389
|
+
any model reading the text; `structured.meta` holds the namespaced meta tags (`og:title`,
|
|
390
|
+
`product:price:amount`, ...).
|
|
391
|
+
- `product` appears only on product pages: `name`, `price`, `priceCurrency`, `availability`, `gtin` (list), `sku`, `brand`, taken from the page's JSON-LD (also inside `@graph`, with offers as list or `AggregateOffer`). `structured` is left out when the page has no JSON-LD and no namespaced meta.
|
|
392
|
+
- `stage` says which rung read the page: `static`, `browser`, `browser-direct`, `stealth` or
|
|
393
|
+
`tavily`.
|
|
394
|
+
- `failed` lists each page that could not be read with its `error` text: `HTTP 403, also after retry`,
|
|
395
|
+
`not HTML: application/pdf`, `timeout`, `address not public or not resolvable`, and so on. The
|
|
396
|
+
fixed `reason` code per entry comes with `"detail": true`.
|
|
397
|
+
|
|
398
|
+
`/search` answers `{"results": [...], "error": ..., "backend": ...}`: `backend` is the one that
|
|
399
|
+
answered, and `error` is the first backend error (null when a query simply has no hits). In
|
|
400
|
+
Python, `search_report()['error']` is the same first backend error.
|
|
401
|
+
|
|
402
|
+
`/search` and `/passages` take a `query` of at most 2,000 characters and at most 100 entries in
|
|
403
|
+
each of `include_domains` and `exclude_domains`; more is refused with status 422. When
|
|
404
|
+
`/passages` fails it answers `{"error": code, "results": []}` with a fixed code (`rate_limited`,
|
|
405
|
+
`timeout`, `unavailable`, ..., or `rerank_unavailable` for `"rerank": true` without the extra),
|
|
406
|
+
never the text of the exception.
|
|
407
|
+
|
|
408
|
+
**How it decides how hard to try (the load ladder, `read_pages`):**
|
|
409
|
+
|
|
410
|
+
1. **Plain fetch** with httpx, falling back to curl_cffi when the site answers 403/429/503.
|
|
411
|
+
The fast path.
|
|
412
|
+
2. A thin result (under 40 words, or under 300 with challenge text) counts as a miss and
|
|
413
|
+
moves on.
|
|
414
|
+
3. **Shared browser**: one shared Chromium, all requests through the safety-checked proxy,
|
|
415
|
+
3 slots, closes after 300 s idle. Reddit only works here.
|
|
416
|
+
4. **Direct browser**: a page in the same browser that loads through the outbound proxy
|
|
417
|
+
(public addresses and the normal web ports only, address pinned), challenge wait 6 s,
|
|
418
|
+
2 slots.
|
|
419
|
+
5. If every stage fails, the plain text is kept. At most 2 pages per request open a browser.
|
|
420
|
+
|
|
421
|
+
**Browser settings** (environment variables):
|
|
422
|
+
|
|
423
|
+
- `READER_RENDER=0` turns all browser stages off (plain fetch only).
|
|
424
|
+
- `RENDER_DIRECT=0` turns off stage 4 only.
|
|
425
|
+
- `RENDER_SLOTS`, `RENDER_DIRECT_SLOTS` set how many pages open a browser at once.
|
|
426
|
+
- `READER_STEALTH=1` with `CAMOUFOX_PYTHON` adds Camoufox as a last stage, at most once per
|
|
427
|
+
request. Off by default.
|
|
428
|
+
|
|
429
|
+
The browser stages need `pip install "searchify[browser]"` and
|
|
430
|
+
`playwright install chromium-headless-shell`.
|
|
431
|
+
|
|
432
|
+
## Limiting the load ladder
|
|
433
|
+
|
|
434
|
+
By default `extract` goes as far as the environment allows. You can cap that per request:
|
|
435
|
+
|
|
436
|
+
- `max_stage`: `plain` (httpx only, no Chrome-TLS fallback), `tls` (plus the Chrome-TLS fallback),
|
|
437
|
+
`browser` (plus the shared and the direct browser), `stealth` (plus Camoufox).
|
|
438
|
+
- `budget`: `low` (each page cut to 4,000 characters, `truncated: true` on a cut page, timeouts
|
|
439
|
+
at 60 %), `normal` (exactly as before), `high` (timeouts doubled). The cache always holds the
|
|
440
|
+
full text; the cut happens after it.
|
|
441
|
+
- `cloud_fallback`: `false` never asks Tavily (it costs credits). Default as before: on if
|
|
442
|
+
`TAVILY_API_KEY` is set.
|
|
443
|
+
|
|
444
|
+
The effective stage is the lowest of what is installed and switched on (`READER_RENDER`,
|
|
445
|
+
`READER_STEALTH`, ...), the operator cap `SEARCHIFY_MAX_STAGE` and the wish of the request. So
|
|
446
|
+
`READER_STEALTH=1` cannot override a request for `plain`. A value in `SEARCHIFY_MAX_STAGE` that is
|
|
447
|
+
not a stage counts as `plain`.
|
|
448
|
+
|
|
449
|
+
Where to set it: the HTTP `/extract` body (`max_stage`, `budget`, `cloud_fallback`), the MCP
|
|
450
|
+
`extract` tool (`max_stage`, `budget`), `searchify extract --max-stage tls --budget low --no-cloud`,
|
|
451
|
+
and `extract_report(urls, max_stage=..., budget=..., cloud_fallback=...)`.
|
|
452
|
+
|
|
453
|
+
**Your profile** (command line and `extract_report` only): the first time you run
|
|
454
|
+
`searchify search`, `extract` or `passages` at a terminal, two questions ask for your default
|
|
455
|
+
stage and budget. Enter keeps today's behaviour, `later` asks again next time, and nothing is
|
|
456
|
+
asked in pipes, with the output going to a file, in CI, agents or with `--no-input`. Answers go to a JSON file in your config folder (macOS
|
|
457
|
+
`~/Library/Application Support/searchify`, Linux `$XDG_CONFIG_HOME` or `~/.config/searchify`,
|
|
458
|
+
Windows `%APPDATA%\searchify`; `SEARCHIFY_CONFIG_DIR` overrides it). `searchify config` asks again,
|
|
459
|
+
`searchify config --show` prints each value and where it comes from, and `--max-stage` and
|
|
460
|
+
`--budget` set a value directly. Order: request, then profile, then default, all below the
|
|
461
|
+
operator cap. The HTTP service and the MCP server never read the profile. `--region` stores a
|
|
462
|
+
search region, but no search reads it yet; profiles with a region from an older version load as
|
|
463
|
+
before.
|
|
464
|
+
|
|
465
|
+
## Configuration
|
|
466
|
+
|
|
467
|
+
Every setting is optional and read from the environment (see `.env.example`):
|
|
468
|
+
`SEARCH_BACKEND`, `SEARCH_FALLBACK`, `SEARXNG_URL`, `SERPER_API_KEY`, `TAVILY_API_KEY`,
|
|
469
|
+
`RERANKER_MODEL`, `FETCH_TIMEOUT`, `SEARCHIFY_USER_AGENT`. No key is ever read from code.
|
|
470
|
+
|
|
471
|
+
## Tests
|
|
472
|
+
|
|
473
|
+
```bash
|
|
474
|
+
pip install -e ".[dev,api,mcp]"
|
|
475
|
+
pytest
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
## Limits
|
|
479
|
+
|
|
480
|
+
- No own web index. Breadth depends on DuckDuckGo or the engines behind your SearXNG.
|
|
481
|
+
- DuckDuckGo rate-limits heavy use. For anything beyond a few queries a minute, run SearXNG.
|
|
482
|
+
- Bot-protected pages: 31% get through (see the benchmark). There is no captcha solving.
|
|
483
|
+
- The first `rerank` call loads the model, which takes a few seconds.
|
|
484
|
+
|
|
485
|
+
## License
|
|
486
|
+
|
|
487
|
+
MIT. See [`LICENSE`](LICENSE).
|
|
488
|
+
<!-- Stand: 02.11.2026 -->
|