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 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
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
5
+ # Stand: 24.10.2026
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