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.
Files changed (65) hide show
  1. searchify-0.3.0/LICENSE +21 -0
  2. searchify-0.3.0/PKG-INFO +488 -0
  3. searchify-0.3.0/README.md +446 -0
  4. searchify-0.3.0/pyproject.toml +54 -0
  5. searchify-0.3.0/setup.cfg +4 -0
  6. searchify-0.3.0/src/searchify/__init__.py +29 -0
  7. searchify-0.3.0/src/searchify/__main__.py +5 -0
  8. searchify-0.3.0/src/searchify/cli.py +352 -0
  9. searchify-0.3.0/src/searchify/config.py +55 -0
  10. searchify-0.3.0/src/searchify/extract/__init__.py +59 -0
  11. searchify-0.3.0/src/searchify/extract/camoufox_worker.py +64 -0
  12. searchify-0.3.0/src/searchify/extract/config.py +62 -0
  13. searchify-0.3.0/src/searchify/extract/egress.py +137 -0
  14. searchify-0.3.0/src/searchify/extract/htmltext.py +972 -0
  15. searchify-0.3.0/src/searchify/extract/netlimits.py +225 -0
  16. searchify-0.3.0/src/searchify/extract/pages.py +162 -0
  17. searchify-0.3.0/src/searchify/extract/pdf.py +423 -0
  18. searchify-0.3.0/src/searchify/extract/reader.py +748 -0
  19. searchify-0.3.0/src/searchify/extract/render.py +363 -0
  20. searchify-0.3.0/src/searchify/extract/stealth.py +125 -0
  21. searchify-0.3.0/src/searchify/extract/stealth_proxy.py +252 -0
  22. searchify-0.3.0/src/searchify/extract/structured.py +115 -0
  23. searchify-0.3.0/src/searchify/interview.py +154 -0
  24. searchify-0.3.0/src/searchify/limits.py +139 -0
  25. searchify-0.3.0/src/searchify/mcp_server.py +129 -0
  26. searchify-0.3.0/src/searchify/passages.py +154 -0
  27. searchify-0.3.0/src/searchify/profile.py +140 -0
  28. searchify-0.3.0/src/searchify/reasons.py +114 -0
  29. searchify-0.3.0/src/searchify/rerank.py +65 -0
  30. searchify-0.3.0/src/searchify/search/__init__.py +113 -0
  31. searchify-0.3.0/src/searchify/search/_common.py +74 -0
  32. searchify-0.3.0/src/searchify/search/cloud.py +134 -0
  33. searchify-0.3.0/src/searchify/search/duckduckgo.py +93 -0
  34. searchify-0.3.0/src/searchify/search/regions.py +129 -0
  35. searchify-0.3.0/src/searchify/search/searxng.py +89 -0
  36. searchify-0.3.0/src/searchify/service.py +137 -0
  37. searchify-0.3.0/src/searchify.egg-info/PKG-INFO +488 -0
  38. searchify-0.3.0/src/searchify.egg-info/SOURCES.txt +63 -0
  39. searchify-0.3.0/src/searchify.egg-info/dependency_links.txt +1 -0
  40. searchify-0.3.0/src/searchify.egg-info/entry_points.txt +3 -0
  41. searchify-0.3.0/src/searchify.egg-info/requires.txt +33 -0
  42. searchify-0.3.0/src/searchify.egg-info/top_level.txt +1 -0
  43. searchify-0.3.0/tests/test_chain_default.py +78 -0
  44. searchify-0.3.0/tests/test_cli.py +108 -0
  45. searchify-0.3.0/tests/test_extract.py +22 -0
  46. searchify-0.3.0/tests/test_fehler.py +319 -0
  47. searchify-0.3.0/tests/test_fetch.py +88 -0
  48. searchify-0.3.0/tests/test_fetch_limits.py +331 -0
  49. searchify-0.3.0/tests/test_fetch_reasons.py +92 -0
  50. searchify-0.3.0/tests/test_leiter.py +209 -0
  51. searchify-0.3.0/tests/test_leiter_api.py +195 -0
  52. searchify-0.3.0/tests/test_leiter_cli.py +329 -0
  53. searchify-0.3.0/tests/test_leiter_golden.py +35 -0
  54. searchify-0.3.0/tests/test_mcp_server.py +79 -0
  55. searchify-0.3.0/tests/test_passages.py +144 -0
  56. searchify-0.3.0/tests/test_pdf.py +585 -0
  57. searchify-0.3.0/tests/test_profile.py +128 -0
  58. searchify-0.3.0/tests/test_region.py +196 -0
  59. searchify-0.3.0/tests/test_search_backends.py +72 -0
  60. searchify-0.3.0/tests/test_service.py +79 -0
  61. searchify-0.3.0/tests/test_structured.py +191 -0
  62. searchify-0.3.0/tests/test_structured_and_failures.py +150 -0
  63. searchify-0.3.0/tests/test_structured_optin.py +64 -0
  64. searchify-0.3.0/tests/test_tavily.py +99 -0
  65. searchify-0.3.0/tests/test_ticker.py +31 -0
@@ -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.
@@ -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 -->