searchify 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- searchify/__init__.py +29 -0
- searchify/__main__.py +5 -0
- searchify/cli.py +352 -0
- searchify/config.py +55 -0
- searchify/extract/__init__.py +59 -0
- searchify/extract/camoufox_worker.py +64 -0
- searchify/extract/config.py +62 -0
- searchify/extract/egress.py +137 -0
- searchify/extract/htmltext.py +972 -0
- searchify/extract/netlimits.py +225 -0
- searchify/extract/pages.py +162 -0
- searchify/extract/pdf.py +423 -0
- searchify/extract/reader.py +748 -0
- searchify/extract/render.py +363 -0
- searchify/extract/stealth.py +125 -0
- searchify/extract/stealth_proxy.py +252 -0
- searchify/extract/structured.py +115 -0
- searchify/interview.py +154 -0
- searchify/limits.py +139 -0
- searchify/mcp_server.py +129 -0
- searchify/passages.py +154 -0
- searchify/profile.py +140 -0
- searchify/reasons.py +114 -0
- searchify/rerank.py +65 -0
- searchify/search/__init__.py +113 -0
- searchify/search/_common.py +74 -0
- searchify/search/cloud.py +134 -0
- searchify/search/duckduckgo.py +93 -0
- searchify/search/regions.py +129 -0
- searchify/search/searxng.py +89 -0
- searchify/service.py +137 -0
- searchify-0.3.0.dist-info/METADATA +488 -0
- searchify-0.3.0.dist-info/RECORD +37 -0
- searchify-0.3.0.dist-info/WHEEL +5 -0
- searchify-0.3.0.dist-info/entry_points.txt +3 -0
- searchify-0.3.0.dist-info/licenses/LICENSE +21 -0
- searchify-0.3.0.dist-info/top_level.txt +1 -0
searchify/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""searchify: local-first web search, clean content extraction and fast reranking.
|
|
2
|
+
|
|
3
|
+
from searchify import search, extract, rerank
|
|
4
|
+
|
|
5
|
+
hits = search("solid-state batteries", max_results=5) # keyless (DuckDuckGo)
|
|
6
|
+
docs = extract([hits[0]["url"]]) # clean article text
|
|
7
|
+
best = rerank("solid-state batteries", docs, top_k=3) # reorder by relevance
|
|
8
|
+
|
|
9
|
+
from searchify import passages
|
|
10
|
+
|
|
11
|
+
answer = passages("Who won the 2026 Ballon d'Or?") # all three in one call
|
|
12
|
+
|
|
13
|
+
Search is keyless by default. Reranking needs the optional extra
|
|
14
|
+
(``pip install "searchify[rerank]"``). Nothing about any of this requires a paid
|
|
15
|
+
API key; cloud providers are an optional fallback, off unless you set their key.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .extract import extract, extract_document, extract_report, extract_main_text, extract_page, read_pages
|
|
20
|
+
from .passages import passages
|
|
21
|
+
from .rerank import rerank, score
|
|
22
|
+
from .search import SearchBackendError, search
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"search", "extract", "rerank", "passages", "score", "read_pages",
|
|
26
|
+
"extract_report", "extract_document", "extract_main_text", "extract_page", "SearchBackendError",
|
|
27
|
+
]
|
|
28
|
+
__version__ = "0.3.0"
|
|
29
|
+
# Stand: 02.11.2026
|
searchify/__main__.py
ADDED
searchify/cli.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Command line interface: ``searchify search "..."``, ``searchify extract URL`` and
|
|
2
|
+
``searchify passages "..."``.
|
|
3
|
+
|
|
4
|
+
Output is JSON by default, so agents and scripts can parse it. Pass ``--no-json``
|
|
5
|
+
for a human-readable view.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
help="Local-first web search and clean page extraction.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
add_completion=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
JSON_OPT = typer.Option(True, "--json/--no-json", help="JSON output (default) or readable text.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _emit(data: dict) -> None:
|
|
27
|
+
typer.echo(json.dumps(data, ensure_ascii=False, indent=2))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fail(message: str, as_json: bool, **extra) -> None:
|
|
31
|
+
if as_json:
|
|
32
|
+
_emit({"error": message, **extra})
|
|
33
|
+
else:
|
|
34
|
+
typer.secho(f"error: {message}", fg=typer.colors.RED, err=True)
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
from .interview import CI_VARS as _CI_VARS # noqa: E402
|
|
39
|
+
|
|
40
|
+
NO_INPUT_OPT = typer.Option(False, "--no-input", help="Never ask questions (the first-run interview).")
|
|
41
|
+
_SHORT = {"rate_limited": "rate limited", "address_blocked": "address blocked",
|
|
42
|
+
"no_main_text": "no text", "extract_failed": "unreadable", "not_html": "not HTML",
|
|
43
|
+
"too_large": "too large"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ticker_allowed(quiet: bool) -> bool:
|
|
47
|
+
"""A person at a terminal only: stdin and stderr are TTYs, no CI variable, not --quiet.
|
|
48
|
+
Agents and pipes never see the ticker, because their stderr goes to the model or a file."""
|
|
49
|
+
if quiet or any(os.environ.get(v) for v in _CI_VARS):
|
|
50
|
+
return False
|
|
51
|
+
try:
|
|
52
|
+
return sys.stdin.isatty() and sys.stderr.isatty()
|
|
53
|
+
except (AttributeError, ValueError):
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _Ticker:
|
|
58
|
+
"""One line on stderr that overwrites itself: ``reading 7/10 · 5 read · 2 blocked``."""
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
self._lock = threading.Lock()
|
|
62
|
+
self._shown = False
|
|
63
|
+
self._closed = False
|
|
64
|
+
self._last = 0
|
|
65
|
+
|
|
66
|
+
def update(self, done: int, total: int, read: int, reasons: Dict[str, int]) -> None:
|
|
67
|
+
parts = [f"reading {done}/{total}", f"{read} read"]
|
|
68
|
+
parts += [f"{n} {_SHORT.get(code, code)}" for code, n in sorted(reasons.items())]
|
|
69
|
+
with self._lock:
|
|
70
|
+
if self._closed or done < self._last: # after clear(), or an older snapshot
|
|
71
|
+
return
|
|
72
|
+
self._last = done
|
|
73
|
+
sys.stderr.write("\r\x1b[K" + " \u00b7 ".join(parts))
|
|
74
|
+
sys.stderr.flush()
|
|
75
|
+
self._shown = True
|
|
76
|
+
|
|
77
|
+
def clear(self) -> None:
|
|
78
|
+
with self._lock:
|
|
79
|
+
self._closed = True
|
|
80
|
+
if self._shown:
|
|
81
|
+
sys.stderr.write("\r\x1b[K")
|
|
82
|
+
sys.stderr.flush()
|
|
83
|
+
self._shown = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def search(
|
|
88
|
+
query: str = typer.Argument(..., help="What to search for."),
|
|
89
|
+
max_results: int = typer.Option(10, "--max-results", "-n", min=1, max=50),
|
|
90
|
+
backend: Optional[str] = typer.Option(None, help="duckduckgo, searxng, serper or tavily."),
|
|
91
|
+
language: Optional[str] = typer.Option(None, help="Result language, e.g. en, or any for none."),
|
|
92
|
+
country: Optional[str] = typer.Option(None, "--country", help="Country code, e.g. BE (with --language nl: Belgian Dutch)."),
|
|
93
|
+
time_range: Optional[str] = typer.Option(None, "--time-range", help="e.g. day, week, month, year."),
|
|
94
|
+
include_domain: Optional[List[str]] = typer.Option(None, "--include-domain", help="Repeatable."),
|
|
95
|
+
exclude_domain: Optional[List[str]] = typer.Option(None, "--exclude-domain", help="Repeatable."),
|
|
96
|
+
no_input: bool = NO_INPUT_OPT,
|
|
97
|
+
as_json: bool = JSON_OPT,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Search the web and print the hits."""
|
|
100
|
+
from .interview import first_run
|
|
101
|
+
from .search import BACKENDS, search_report
|
|
102
|
+
|
|
103
|
+
first_run(no_input)
|
|
104
|
+
if backend is not None and backend not in BACKENDS:
|
|
105
|
+
_fail(f"unknown backend {backend!r}, use {', '.join(BACKENDS)}", as_json, results=[])
|
|
106
|
+
filters = {
|
|
107
|
+
"language": language,
|
|
108
|
+
"country": country,
|
|
109
|
+
"time_range": time_range,
|
|
110
|
+
"include_domains": include_domain or None,
|
|
111
|
+
"exclude_domains": exclude_domain or None,
|
|
112
|
+
}
|
|
113
|
+
filters = {k: v for k, v in filters.items() if v is not None}
|
|
114
|
+
report = search_report(query, max_results, backend=backend, **filters)
|
|
115
|
+
hits = report["results"]
|
|
116
|
+
if not hits and report["error"]:
|
|
117
|
+
_fail(str(report["error"]), as_json, results=[])
|
|
118
|
+
if as_json:
|
|
119
|
+
_emit({"results": hits})
|
|
120
|
+
return
|
|
121
|
+
if not hits:
|
|
122
|
+
typer.echo("No results.")
|
|
123
|
+
return
|
|
124
|
+
for i, hit in enumerate(hits, 1):
|
|
125
|
+
typer.secho(f"{i}. {hit.get('title', '')}", bold=True)
|
|
126
|
+
typer.echo(f" {hit.get('url', '')}")
|
|
127
|
+
if hit.get("content"):
|
|
128
|
+
typer.echo(f" {hit['content']}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command()
|
|
132
|
+
def extract(
|
|
133
|
+
urls: List[str] = typer.Argument(..., help="One or more page addresses."),
|
|
134
|
+
fmt: str = typer.Option("text", "--format", "-f", help="text or markdown."),
|
|
135
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="No progress line on stderr."),
|
|
136
|
+
max_stage: Optional[str] = typer.Option(
|
|
137
|
+
None, "--max-stage", help="Highest way to load a page: plain, tls, browser or stealth."),
|
|
138
|
+
budget: Optional[str] = typer.Option(
|
|
139
|
+
None, "--budget", help="low (short waits and pages), normal or high (long waits)."),
|
|
140
|
+
no_cloud: bool = typer.Option(False, "--no-cloud", help="Never ask Tavily (it costs credits)."),
|
|
141
|
+
no_input: bool = NO_INPUT_OPT,
|
|
142
|
+
structured: bool = typer.Option(False, "--structured", help="Add the raw JSON-LD and meta tags."),
|
|
143
|
+
as_json: bool = JSON_OPT,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Fetch pages and print their clean article text.
|
|
146
|
+
|
|
147
|
+
Each failed page comes with "error" (text) and "reason" (a fixed code such as
|
|
148
|
+
blocked, rate_limited, timeout). At an interactive terminal a progress line
|
|
149
|
+
shows on stderr; pipes, agents and CI never get one. --max-stage, --budget and
|
|
150
|
+
--no-cloud limit the load of this call; what you leave out comes from your
|
|
151
|
+
profile (`searchify config`), and the operator cap SEARCHIFY_MAX_STAGE wins.
|
|
152
|
+
"""
|
|
153
|
+
if fmt not in ("text", "markdown"):
|
|
154
|
+
_fail(f"unknown format {fmt!r}, use text or markdown", as_json)
|
|
155
|
+
from . import limits as limits_mod
|
|
156
|
+
from . import profile as profile_mod
|
|
157
|
+
from .extract import read_pages_ex
|
|
158
|
+
from .interview import first_run
|
|
159
|
+
from .reasons import code_for
|
|
160
|
+
|
|
161
|
+
first_run(no_input)
|
|
162
|
+
try:
|
|
163
|
+
wanted = limits_mod.explicit(max_stage, budget, False if no_cloud else None, profile_mod.load())
|
|
164
|
+
except ValueError as exc:
|
|
165
|
+
_fail(str(exc), as_json)
|
|
166
|
+
|
|
167
|
+
ticker = _Ticker() if _ticker_allowed(quiet) else None
|
|
168
|
+
try:
|
|
169
|
+
docs, failed, _thin = read_pages_ex(urls, fmt, progress=ticker.update if ticker else None,
|
|
170
|
+
structured=structured, **wanted)
|
|
171
|
+
finally:
|
|
172
|
+
if ticker:
|
|
173
|
+
ticker.clear()
|
|
174
|
+
failed = [{"url": f.get("url"), "error": f.get("error"),
|
|
175
|
+
"reason": f.get("reason") or code_for(f.get("error"))} for f in failed]
|
|
176
|
+
if as_json:
|
|
177
|
+
_emit({"results": docs, "failed": failed})
|
|
178
|
+
else:
|
|
179
|
+
for doc in docs:
|
|
180
|
+
typer.secho(doc.get("title") or doc.get("url", ""), bold=True)
|
|
181
|
+
typer.echo(doc.get("url", ""))
|
|
182
|
+
typer.echo(f"\n{doc.get('content', '')}\n")
|
|
183
|
+
for f in failed:
|
|
184
|
+
typer.secho(f"failed: {f['url']} ({f['error']})", fg=typer.colors.RED, err=True)
|
|
185
|
+
if failed and not docs:
|
|
186
|
+
raise typer.Exit(1)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command()
|
|
190
|
+
def passages(
|
|
191
|
+
query: str = typer.Argument(..., help="A full question works better than keywords."),
|
|
192
|
+
max_results: int = typer.Option(5, "--max-results", "-n", min=1, max=20,
|
|
193
|
+
help="Result pages to read."),
|
|
194
|
+
top_k: int = typer.Option(8, "--top-k", "-k", min=1, max=50, help="Passages to return."),
|
|
195
|
+
per_site: Optional[int] = typer.Option(None, "--per-site", min=1,
|
|
196
|
+
help="At most this many passages from one site."),
|
|
197
|
+
max_chars: int = typer.Option(600, "--max-chars", min=50, help="Trim each passage."),
|
|
198
|
+
rerank: Optional[bool] = typer.Option(
|
|
199
|
+
None, "--rerank/--no-rerank",
|
|
200
|
+
help="Rank by relevance. Default: on if the [rerank] extra is installed."),
|
|
201
|
+
backend: Optional[str] = typer.Option(None, help="duckduckgo, searxng, serper or tavily."),
|
|
202
|
+
language: Optional[str] = typer.Option(None, help="Result language, e.g. en."),
|
|
203
|
+
country: Optional[str] = typer.Option(None, "--country", help="Country code, e.g. BE (with --language nl: Belgian Dutch)."),
|
|
204
|
+
time_range: Optional[str] = typer.Option(None, "--time-range", help="e.g. day, week, month, year."),
|
|
205
|
+
include_domain: Optional[List[str]] = typer.Option(None, "--include-domain", help="Repeatable."),
|
|
206
|
+
exclude_domain: Optional[List[str]] = typer.Option(None, "--exclude-domain", help="Repeatable."),
|
|
207
|
+
no_input: bool = NO_INPUT_OPT,
|
|
208
|
+
as_json: bool = JSON_OPT,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Search, read the top pages and print their best paragraphs.
|
|
211
|
+
|
|
212
|
+
The pages are read within your profile's stage and budget (`searchify config`),
|
|
213
|
+
below the operator cap SEARCHIFY_MAX_STAGE.
|
|
214
|
+
"""
|
|
215
|
+
from . import limits as limits_mod
|
|
216
|
+
from . import profile as profile_mod
|
|
217
|
+
from .interview import first_run
|
|
218
|
+
from .passages import passages as best_passages
|
|
219
|
+
from .search import BACKENDS, SearchBackendError
|
|
220
|
+
|
|
221
|
+
first_run(no_input)
|
|
222
|
+
if backend is not None and backend not in BACKENDS:
|
|
223
|
+
_fail(f"unknown backend {backend!r}, use {', '.join(BACKENDS)}", as_json, results=[])
|
|
224
|
+
wanted = limits_mod.explicit(profile=profile_mod.load()) # stage and budget, if saved
|
|
225
|
+
filters = {
|
|
226
|
+
"language": language,
|
|
227
|
+
"country": country,
|
|
228
|
+
"time_range": time_range,
|
|
229
|
+
"include_domains": include_domain or None,
|
|
230
|
+
"exclude_domains": exclude_domain or None,
|
|
231
|
+
}
|
|
232
|
+
filters = {k: v for k, v in filters.items() if v is not None}
|
|
233
|
+
try:
|
|
234
|
+
found = best_passages(query, max_results, top_k=top_k, per_site=per_site,
|
|
235
|
+
max_chars=max_chars, rerank=rerank, backend=backend,
|
|
236
|
+
**wanted, **filters)
|
|
237
|
+
except (SearchBackendError, ImportError) as exc:
|
|
238
|
+
_fail(str(exc), as_json, results=[])
|
|
239
|
+
if as_json:
|
|
240
|
+
_emit({"results": found})
|
|
241
|
+
return
|
|
242
|
+
if not found:
|
|
243
|
+
typer.echo("No results.")
|
|
244
|
+
return
|
|
245
|
+
for i, item in enumerate(found, 1):
|
|
246
|
+
typer.secho(f"{i}. {item['content']}", bold=True)
|
|
247
|
+
typer.echo(f" {item['url']}")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command()
|
|
251
|
+
def config(
|
|
252
|
+
show: bool = typer.Option(False, "--show", help="Print every setting and where it comes from."),
|
|
253
|
+
max_stage: Optional[str] = typer.Option(
|
|
254
|
+
None, "--max-stage", help="Save: plain, tls, browser or stealth ('default' clears it)."),
|
|
255
|
+
budget: Optional[str] = typer.Option(
|
|
256
|
+
None, "--budget", help="Save: low, normal or high ('default' clears it)."),
|
|
257
|
+
region: Optional[str] = typer.Option(
|
|
258
|
+
None, "--region", help="Save a DuckDuckGo search region such as de-de ('default' clears it)."),
|
|
259
|
+
no_input: bool = NO_INPUT_OPT,
|
|
260
|
+
as_json: bool = JSON_OPT,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Show or change your profile (how far searchify may go, budget, search region).
|
|
263
|
+
|
|
264
|
+
Without options, at a terminal, the two questions are asked again with your
|
|
265
|
+
current answers. With options, the values are saved directly. The profile is a
|
|
266
|
+
JSON file in your user config folder; the HTTP service and the MCP server never read it.
|
|
267
|
+
"""
|
|
268
|
+
from . import interview
|
|
269
|
+
from . import limits as limits_mod
|
|
270
|
+
from . import profile as profile_mod
|
|
271
|
+
from .search.regions import is_region
|
|
272
|
+
|
|
273
|
+
changes = {"max_stage": max_stage, "budget": budget, "region": region}
|
|
274
|
+
asked = {k: v for k, v in changes.items() if v is not None}
|
|
275
|
+
if asked:
|
|
276
|
+
new = profile_mod.load(force=True)
|
|
277
|
+
try:
|
|
278
|
+
for key, value in asked.items():
|
|
279
|
+
word = value.strip().lower()
|
|
280
|
+
if word in interview.RESET:
|
|
281
|
+
new.pop(key, None)
|
|
282
|
+
continue
|
|
283
|
+
if key == "max_stage":
|
|
284
|
+
limits_mod.resolve(max_stage=word)
|
|
285
|
+
elif key == "budget":
|
|
286
|
+
limits_mod.resolve(budget=word)
|
|
287
|
+
elif not is_region(word):
|
|
288
|
+
raise ValueError(f"unknown region {value!r}, use a DuckDuckGo code such as de-de")
|
|
289
|
+
new[key] = word
|
|
290
|
+
except ValueError as exc:
|
|
291
|
+
_fail(str(exc), as_json)
|
|
292
|
+
_save_profile(new, as_json)
|
|
293
|
+
elif not show:
|
|
294
|
+
if not interview.interactive(no_input):
|
|
295
|
+
_fail("`searchify config` asks questions and needs a terminal; use --show, or set "
|
|
296
|
+
"values with --max-stage, --budget or --region", as_json)
|
|
297
|
+
answers = interview.run(current=profile_mod.load(force=True))
|
|
298
|
+
if answers is not None:
|
|
299
|
+
_save_profile(answers, as_json)
|
|
300
|
+
_print_config(as_json)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _save_profile(values: dict, as_json: bool) -> None:
|
|
304
|
+
from . import profile as profile_mod
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
profile_mod.save(values)
|
|
308
|
+
except OSError as exc:
|
|
309
|
+
_fail(f"could not save the profile ({type(exc).__name__})", as_json)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _print_config(as_json: bool) -> None:
|
|
313
|
+
from . import limits as limits_mod
|
|
314
|
+
from . import profile as profile_mod
|
|
315
|
+
|
|
316
|
+
prof = profile_mod.load(force=True)
|
|
317
|
+
got = limits_mod.detail(profile=prof)
|
|
318
|
+
values = {name: {"value": value, "source": source} for name, (value, source) in got.items()}
|
|
319
|
+
values["region"] = ({"value": prof["region"], "source": "profile"} if "region" in prof
|
|
320
|
+
else {"value": None, "source": "default"})
|
|
321
|
+
data = {"path": str(profile_mod.profile_path()), "exists": profile_mod.exists(),
|
|
322
|
+
"operator_cap": limits_mod.operator_cap(), "values": values}
|
|
323
|
+
if as_json:
|
|
324
|
+
_emit(data)
|
|
325
|
+
return
|
|
326
|
+
typer.echo(f"profile: {data['path']}" + ("" if data["exists"] else " (not created yet)"))
|
|
327
|
+
for name, item in values.items():
|
|
328
|
+
typer.echo(f"{name}: {item['value']} ({item['source']})")
|
|
329
|
+
if data["operator_cap"]:
|
|
330
|
+
typer.echo(f"operator cap: {data['operator_cap']} (SEARCHIFY_MAX_STAGE)")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def main() -> None: # extraction uses worker processes, so entry points need a real main
|
|
334
|
+
app()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def mcp_main() -> None:
|
|
338
|
+
"""Entry point of searchify-mcp: start the MCP server, or say which extra is missing."""
|
|
339
|
+
try:
|
|
340
|
+
from .mcp_server import main as serve
|
|
341
|
+
except ImportError as exc:
|
|
342
|
+
if not (exc.name or "").split(".")[0] == "mcp":
|
|
343
|
+
raise
|
|
344
|
+
typer.secho('error: the MCP server needs the extra: pip install "searchify[mcp]" '
|
|
345
|
+
"(Python 3.10+)", fg=typer.colors.RED, err=True)
|
|
346
|
+
raise typer.Exit(1)
|
|
347
|
+
serve()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__":
|
|
351
|
+
main()
|
|
352
|
+
# Stand: 02.11.2026
|
searchify/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Configuration, read exclusively from environment variables.
|
|
2
|
+
|
|
3
|
+
No secrets in code: every key and URL comes from the environment or a local,
|
|
4
|
+
untracked ``.env`` file (see ``.env.example`` and ``.gitignore``).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get(name: str, default: str = "") -> str:
|
|
13
|
+
return os.environ.get(name, default)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_int(name: str, default: int) -> int:
|
|
17
|
+
raw = os.environ.get(name)
|
|
18
|
+
if not raw or not raw.strip():
|
|
19
|
+
return default
|
|
20
|
+
try:
|
|
21
|
+
return int(raw)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return default
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Settings:
|
|
28
|
+
# Search: local-first. "duckduckgo" (keyless) or "searxng" (self-hosted).
|
|
29
|
+
# "serper" is an optional cloud fallback, used only if its key is set.
|
|
30
|
+
search_backend: str
|
|
31
|
+
search_fallback: str
|
|
32
|
+
searxng_url: str
|
|
33
|
+
serper_api_key: str
|
|
34
|
+
# Tavily: last safety net for search and reading, only if its key is set.
|
|
35
|
+
tavily_api_key: str
|
|
36
|
+
|
|
37
|
+
# Reranker: a local cross-encoder loaded by Hugging Face name, or a local path.
|
|
38
|
+
reranker_model: str
|
|
39
|
+
|
|
40
|
+
# Networking
|
|
41
|
+
fetch_timeout: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_settings() -> Settings:
|
|
45
|
+
"""Read settings fresh from the environment (no caching, test friendly)."""
|
|
46
|
+
return Settings(
|
|
47
|
+
search_backend=_get("SEARCH_BACKEND", "duckduckgo"),
|
|
48
|
+
search_fallback=_get("SEARCH_FALLBACK", "searxng"),
|
|
49
|
+
searxng_url=_get("SEARXNG_URL", ""),
|
|
50
|
+
serper_api_key=_get("SERPER_API_KEY", ""),
|
|
51
|
+
tavily_api_key=_get("TAVILY_API_KEY", ""),
|
|
52
|
+
reranker_model=_get("RERANKER_MODEL", "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"),
|
|
53
|
+
fetch_timeout=_get_int("FETCH_TIMEOUT", 15),
|
|
54
|
+
)
|
|
55
|
+
# Stand: 24.10.2026
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Content extraction: fetch a page and return its clean article text.
|
|
2
|
+
|
|
3
|
+
A load ladder: a fast static fetch first, then a real browser only when the
|
|
4
|
+
static result is too thin, with an SSRF-safe outbound proxy for the browser
|
|
5
|
+
stages. Exposes a small, stable API.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from .htmltext import extract_document, extract_main_text, extract_page
|
|
12
|
+
from ..reasons import code_for, status_for
|
|
13
|
+
from .reader import read_pages, read_pages_ex
|
|
14
|
+
|
|
15
|
+
__all__ = ["extract", "extract_report", "read_pages", "read_pages_ex", "extract_document",
|
|
16
|
+
"extract_main_text", "extract_page"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract(urls: List[str], fmt: str = "text", timeout: Optional[float] = None) -> List[Dict]:
|
|
20
|
+
"""Fetch each address and return its clean content.
|
|
21
|
+
|
|
22
|
+
Each result is a dict with url, title, published, author, description,
|
|
23
|
+
content and links. `fmt` is "text" (default) or "markdown". Pages that fail
|
|
24
|
+
are left out of the result.
|
|
25
|
+
"""
|
|
26
|
+
docs, _failed = read_pages(list(urls), fmt, timeout)
|
|
27
|
+
return docs
|
|
28
|
+
# Stand: 24.10.2026
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract_report(
|
|
32
|
+
urls: List[str], fmt: str = "text", timeout: Optional[float] = None,
|
|
33
|
+
*, max_stage: Optional[str] = None, budget: Optional[str] = None,
|
|
34
|
+
cloud_fallback: Optional[bool] = None, use_profile: bool = True, structured: bool = False,
|
|
35
|
+
) -> Dict:
|
|
36
|
+
"""Like `extract`, but says what happened: ``{"results", "failed", "status"}``.
|
|
37
|
+
|
|
38
|
+
``results`` are the clean pages, exactly as `extract` returns them. ``failed``
|
|
39
|
+
lists ``{url, error, reason}`` per page that could not be read: ``error`` is the
|
|
40
|
+
human text, ``reason`` a fixed code (blocked, rate_limited, not_found, unavailable,
|
|
41
|
+
timeout, not_html, too_large, address_blocked, no_main_text, extract_failed). ``status`` is
|
|
42
|
+
``{read, failed, thin, reasons: {code: n}}``; ``thin`` counts delivered pages that
|
|
43
|
+
carry little text (thin_content or challenge) and is part of ``read``.
|
|
44
|
+
|
|
45
|
+
``max_stage`` (plain, tls, browser, stealth), ``budget`` (low, normal, high) and
|
|
46
|
+
``cloud_fallback`` limit the load for this call. What a call leaves out comes from
|
|
47
|
+
the user profile (``searchify config``) unless ``use_profile=False``, then from
|
|
48
|
+
today's defaults; everything stays below the operator cap SEARCHIFY_MAX_STAGE.
|
|
49
|
+
The raw ``structured`` block (JSON-LD, og:/twitter: meta) is left out unless ``structured``.
|
|
50
|
+
"""
|
|
51
|
+
from .. import limits as limits_mod # noqa: PLC0415
|
|
52
|
+
from .. import profile as profile_mod # noqa: PLC0415
|
|
53
|
+
|
|
54
|
+
kwargs = limits_mod.explicit(max_stage, budget, cloud_fallback,
|
|
55
|
+
profile_mod.load() if use_profile else None)
|
|
56
|
+
docs, failed, thin = read_pages_ex(list(urls), fmt, timeout, structured=structured, **kwargs)
|
|
57
|
+
failed = [{"url": f.get("url"), "error": f.get("error"),
|
|
58
|
+
"reason": f.get("reason") or code_for(f.get("error"))} for f in failed]
|
|
59
|
+
return {"results": docs, "failed": failed, "status": status_for(len(docs), failed, thin.values())}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Ein Seitenaufruf in Camoufox, als eigener Prozess aus dem Camoufox-venv.
|
|
2
|
+
|
|
3
|
+
Laeuft NICHT im Dienst-venv (Camoufox verlangt playwright < 1.63, der Dienst nutzt
|
|
4
|
+
1.63) und importiert darum nichts aus app/. Aufruf durch app/compat/stealth.py mit
|
|
5
|
+
`python -I camoufox_worker.py`, Eingabe als JSON auf stdin:
|
|
6
|
+
{"url": ..., "proxy_port": ..., "timeout": Sekunden, "settle": Sekunden, "max_chars": ...}
|
|
7
|
+
Ausgabe eine JSON-Zeile auf stdout: {"status": int, "url": Endstand, "html": ...}
|
|
8
|
+
oder {"error": ...}.
|
|
9
|
+
|
|
10
|
+
Alles Netz geht ueber den SSRF-Proxy (stealth_proxy.py), auch localhost (Firefox
|
|
11
|
+
umgeht den Proxy sonst fuer Loopback). WebRTC, Service Worker, DNS-Vorabfragen,
|
|
12
|
+
DoH und Vorabladen sind aus, Bilder werden nicht geladen. Kein GeoIP, keine
|
|
13
|
+
Standard-Addons (uBlock wuerde sonst ungepinnt von addons.mozilla.org kommen).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
PREFS = {
|
|
21
|
+
"network.proxy.allow_hijacking_localhost": True,
|
|
22
|
+
"network.proxy.no_proxies_on": "",
|
|
23
|
+
"network.proxy.failover_direct": False,
|
|
24
|
+
"network.trr.mode": 5,
|
|
25
|
+
"network.dns.disablePrefetch": True,
|
|
26
|
+
"network.prefetch-next": False,
|
|
27
|
+
"network.http.speculative-parallel-limit": 0,
|
|
28
|
+
"network.predictor.enabled": False,
|
|
29
|
+
"media.peerconnection.enabled": False,
|
|
30
|
+
"dom.serviceWorkers.enabled": False,
|
|
31
|
+
"browser.cache.disk.enable": False,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main() -> None:
|
|
36
|
+
job = json.loads(sys.stdin.read())
|
|
37
|
+
deadline = time.monotonic() + float(job["timeout"])
|
|
38
|
+
from camoufox import DefaultAddons
|
|
39
|
+
from camoufox.sync_api import Camoufox
|
|
40
|
+
|
|
41
|
+
out = {}
|
|
42
|
+
with Camoufox(
|
|
43
|
+
headless=True, geoip=False, exclude_addons=list(DefaultAddons), block_images=True,
|
|
44
|
+
block_webrtc=True, locale="de-DE", firefox_user_prefs=PREFS,
|
|
45
|
+
proxy={"server": f"http://localhost:{int(job['proxy_port'])}", "bypass": ""},
|
|
46
|
+
) as browser:
|
|
47
|
+
page = browser.new_page()
|
|
48
|
+
rest_ms = max(1000, int((deadline - time.monotonic()) * 1000))
|
|
49
|
+
resp = page.goto(job["url"], wait_until="domcontentloaded", timeout=rest_ms)
|
|
50
|
+
settle = min(float(job.get("settle", 3.0)), deadline - time.monotonic() - 0.5)
|
|
51
|
+
if settle > 0:
|
|
52
|
+
page.wait_for_timeout(int(settle * 1000))
|
|
53
|
+
html = page.content()
|
|
54
|
+
limit = int(job.get("max_chars", 4_000_000)) # wie READER_MAX_BYTES der anderen Stufen
|
|
55
|
+
out = {"status": resp.status if resp else 0, "url": page.url, "html": html[:limit]}
|
|
56
|
+
sys.stdout.write(json.dumps(out))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
try:
|
|
61
|
+
main()
|
|
62
|
+
except Exception as exc: # noqa: BLE001 - Fehler als JSON, der Dienst wertet aus
|
|
63
|
+
sys.stdout.write(json.dumps({"error": f"{type(exc).__name__}: {str(exc)[:200]}"}))
|
|
64
|
+
# Stand: 24.10.2026
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Configuration. Read exclusively from environment variables.
|
|
2
|
+
|
|
3
|
+
No secrets in code. All tuning comes from the environment or a local, untracked
|
|
4
|
+
`.env` file. Every value has a safe default, so the extractor runs with no setup.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get(name: str, default: str = "") -> str:
|
|
14
|
+
return os.environ.get(name, default)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_int(name: str, default: int) -> int:
|
|
18
|
+
raw = os.environ.get(name)
|
|
19
|
+
if raw is None or raw.strip() == "":
|
|
20
|
+
return default
|
|
21
|
+
try:
|
|
22
|
+
return int(raw)
|
|
23
|
+
except ValueError:
|
|
24
|
+
return default
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_bool(name: str, default: bool = False) -> bool:
|
|
28
|
+
raw = os.environ.get(name)
|
|
29
|
+
if raw is None:
|
|
30
|
+
return default
|
|
31
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Settings:
|
|
36
|
+
chunk_size: int
|
|
37
|
+
chunk_overlap: int
|
|
38
|
+
fetch_timeout: int
|
|
39
|
+
fetch_client: str
|
|
40
|
+
fetch_fallback: bool
|
|
41
|
+
extract_workers: int
|
|
42
|
+
fetch_budget_ms: int
|
|
43
|
+
page_cache_size: int
|
|
44
|
+
page_cache_ttl_s: int
|
|
45
|
+
doc_cache_max_mb: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_settings() -> Settings:
|
|
49
|
+
"""Read settings fresh from the environment (no caching, test friendly)."""
|
|
50
|
+
return Settings(
|
|
51
|
+
chunk_size=_get_int("CHUNK_SIZE", 50),
|
|
52
|
+
chunk_overlap=_get_int("CHUNK_OVERLAP", 12),
|
|
53
|
+
fetch_timeout=_get_int("FETCH_TIMEOUT", 5),
|
|
54
|
+
fetch_client=_get("FETCH_CLIENT", "httpx").strip().lower(),
|
|
55
|
+
fetch_fallback=_get_bool("FETCH_FALLBACK", True),
|
|
56
|
+
extract_workers=_get_int("EXTRACT_WORKERS", 0),
|
|
57
|
+
fetch_budget_ms=_get_int("FETCH_BUDGET_MS", 500),
|
|
58
|
+
page_cache_size=_get_int("PAGE_CACHE_SIZE", 5000),
|
|
59
|
+
page_cache_ttl_s=_get_int("PAGE_CACHE_TTL_S", 6 * 3600),
|
|
60
|
+
doc_cache_max_mb=_get_int("DOC_CACHE_MAX_MB", 400),
|
|
61
|
+
)
|
|
62
|
+
# Stand: 24.10.2026
|