746 lines
27 KiB
Markdown
746 lines
27 KiB
Markdown
|
|
# mdgrep – Bedienungsanleitung
|
|||
|
|
|
|||
|
|
**mdgrep** ist eine Volltextsuche für Markdown-Dateien und beliebige andere Texte. Sie versteht
|
|||
|
|
Wortbestandteile, Phrasen, reguläre Ausdrücke und logische Verknüpfungen mit Klammern – und lässt
|
|||
|
|
sich auf vier Arten nutzen:
|
|||
|
|
|
|||
|
|
| Baustein | Wofür |
|
|||
|
|
|---|---|
|
|||
|
|
| **Kommandozeile** `mdgrep` | schnell im Terminal suchen, wie `grep` – aber blockweise und mit UND/ODER/NICHT |
|
|||
|
|
| **Python-Bibliothek** | Suche in eigene Programme einbauen, auch für Nicht-Markdown-Daten |
|
|||
|
|
| **Web-API** (Flask) | JSON-Suchschnittstelle für Webseiten und andere Dienste |
|
|||
|
|
| **Such-Widget** (JavaScript) | fertige Suchmaske mit Live-Ergebnissen für jede Webseite |
|
|||
|
|
|
|||
|
|
Version: 0.1.1 · Lizenz: MIT · Repository: https://kitux.de/forgejo/dschlueter/mdgrep
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Inhaltsverzeichnis
|
|||
|
|
|
|||
|
|
1. [Installation](#1-installation)
|
|||
|
|
2. [Suchsyntax](#2-suchsyntax)
|
|||
|
|
3. [Kommandozeile](#3-kommandozeile)
|
|||
|
|
4. [Python-Bibliothek](#4-python-bibliothek)
|
|||
|
|
5. [Web-API](#5-web-api)
|
|||
|
|
6. [Such-Widget](#6-such-widget)
|
|||
|
|
7. [Betrieb auf einem Server](#7-betrieb-auf-einem-server)
|
|||
|
|
8. [Rezepte](#8-rezepte)
|
|||
|
|
9. [Fehlermeldungen](#9-fehlermeldungen)
|
|||
|
|
10. [Fehlersuche](#10-fehlersuche)
|
|||
|
|
11. [Entwicklung & neue Versionen](#11-entwicklung--neue-versionen)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. Installation
|
|||
|
|
|
|||
|
|
Voraussetzung: Python 3.10 oder neuer, `git` (für die Installation aus dem Repository).
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# nur Bibliothek + Kommandozeile
|
|||
|
|
pip install "mdgrep @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.1"
|
|||
|
|
|
|||
|
|
# zusätzlich Web-API (installiert Flask)
|
|||
|
|
pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.1"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In einer `requirements.txt`:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.1
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Prüfen:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
mdgrep --version # mdgrep 0.1.1
|
|||
|
|
python -c "import mdgrep; print(mdgrep.__version__)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> **Hinweis:** mdgrep ist nicht auf PyPI veröffentlicht. Immer mit Git-URL und Versions-Tag
|
|||
|
|
> installieren – ein bloßes `pip install mdgrep` findet das Paket nicht (oder, falls jemand den
|
|||
|
|
> Namen dort später belegt, ein fremdes).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. Suchsyntax
|
|||
|
|
|
|||
|
|
Groß- und Kleinschreibung spielen **nie** eine Rolle (auch nicht bei Umlauten).
|
|||
|
|
|
|||
|
|
| Eingabe | Bedeutung | Beispiel findet … |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `rust` | Wortbestandteil | „Rust", „Rustacean", „trusted" |
|
|||
|
|
| `"open source"` | genaue Phrase; beliebiger Leerraum (auch Zeilenumbruch) zwischen den Wörtern | „Open Source", „open<br>source" |
|
|||
|
|
| `/llms?\b/` | regulärer Ausdruck | „LLM", „LLMs" |
|
|||
|
|
| `a b` · `a AND b` · `a UND b` · `a & b` · `a && b` | beide Begriffe | |
|
|||
|
|
| `a OR b` · `a ODER b` · `a \| b` · `a \|\| b` | mindestens einer | |
|
|||
|
|
| `NOT a` · `NICHT a` · `-a` · `!a` | Begriff darf nicht vorkommen | |
|
|||
|
|
| `( … )` | Klammern | |
|
|||
|
|
|
|||
|
|
**Vorrang:** NICHT bindet stärker als UND, UND stärker als ODER.
|
|||
|
|
`a OR b AND c` bedeutet also `a OR (b AND c)`. Im Zweifel Klammern setzen.
|
|||
|
|
|
|||
|
|
**Hinweise:**
|
|||
|
|
|
|||
|
|
- Operatoren werden nur in **Großbuchstaben** erkannt: `and` ist ein normales Suchwort, `AND` der Operator.
|
|||
|
|
- Wortbestandteile treffen auch mitten im Wort: `go` findet „**Go**ddard". Für **ganze Wörter**
|
|||
|
|
einen regulären Ausdruck mit Wortgrenzen verwenden: `/\bgo\b/`.
|
|||
|
|
- Im regulären Ausdruck einen Schrägstrich als `\/` schreiben: `/tcp\/ip/`.
|
|||
|
|
- In Phrasen ein Anführungszeichen als `\"` schreiben.
|
|||
|
|
- Wörter mit Sonderzeichen funktionieren direkt: `AT&T`, `TCP/IP`, `e-mail`
|
|||
|
|
(ein `-` oder `&` *innerhalb* eines Wortes ist kein Operator).
|
|||
|
|
|
|||
|
|
**Beispiele:**
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
(rust ODER go) UND NICHT crypto
|
|||
|
|
"machine learning" -/pytorch|tensorflow/
|
|||
|
|
llm (datenschutz OR privacy)
|
|||
|
|
/\bgpt-\d+\b/ NICHT "open source"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Grenzen (Standard):** höchstens 500 Zeichen und 50 Suchbegriffe pro Anfrage
|
|||
|
|
(die Web-API begrenzt zusätzlich auf 300 Zeichen).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. Kommandozeile
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
mdgrep SUCHANFRAGE [PFAD ...] [Optionen]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Ohne Pfad wird das aktuelle Verzeichnis durchsucht. Verzeichnisse werden rekursiv nach `*.md`
|
|||
|
|
durchsucht. Die Suchanfrage immer in **einfache Anführungszeichen** setzen, damit die Shell
|
|||
|
|
Klammern, `!`, `|` und `&` nicht selbst auswertet.
|
|||
|
|
|
|||
|
|
### 3.1 Beispiele
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
mdgrep '(rust OR go) AND NOT crypto' notizen/ # alle *.md unter notizen/
|
|||
|
|
mdgrep '/\bllms?\b/' docs/*.md --split list # ein Treffer pro Listenpunkt
|
|||
|
|
mdgrep 'todo' . -l # nur Dateinamen
|
|||
|
|
mdgrep 'release' . -c # nur Anzahl der Treffer
|
|||
|
|
mdgrep '"open source"' . --json > treffer.json # JSON für andere Programme
|
|||
|
|
mdgrep 'fehler' logs/ -g '*.txt' # andere Dateien als Markdown
|
|||
|
|
mdgrep 'rust' . -m 5 # nach 5 Treffern aufhören
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Ausgabe (Fundstellen farbig, wenn die Ausgabe ein Terminal ist):
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
notizen/sprachen.md:5
|
|||
|
|
Rust guarantees memory safety without a garbage collector. Its borrow checker
|
|||
|
|
rejects data races at compile time.
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.2 Optionen
|
|||
|
|
|
|||
|
|
| Option | Bedeutung | Standard |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `-s`, `--split MODUS` | Einheit, die durchsucht und angezeigt wird (siehe 3.3) | `paragraph` |
|
|||
|
|
| `-g`, `--glob MUSTER` | Dateimuster in Verzeichnissen | `*.md` |
|
|||
|
|
| `-c`, `--count` | nur Anzahl der Treffer-Blöcke ausgeben | |
|
|||
|
|
| `-l`, `--files-with-matches` | nur Dateinamen ausgeben | |
|
|||
|
|
| `-j`, `--json` | JSON-Ausgabe (gleiches Format wie die Web-API) | |
|
|||
|
|
| `-C`, `--context N` | Zeichen Kontext um Treffer bei langen Blöcken | `80` |
|
|||
|
|
| `--max-chars N` | Blöcke über N Zeichen kürzen (`0` = nie) | `400` |
|
|||
|
|
| `-m`, `--max-count N` | nach N Treffern aufhören (`0` = alle) | `0` |
|
|||
|
|
| `--timeout SEK` | Zeitlimit für die ganze Suche | `30` |
|
|||
|
|
| `--color auto\|always\|never` | Farbe (`NO_COLOR` wird beachtet) | `auto` |
|
|||
|
|
| `-V`, `--version` | Version anzeigen | |
|
|||
|
|
|
|||
|
|
### 3.3 Split-Modi – was ist ein Treffer?
|
|||
|
|
|
|||
|
|
| Modus | Ein Block ist … | Geeignet für |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `paragraph` | ein durch Leerzeilen getrennter Absatz | Fließtext, Notizen |
|
|||
|
|
| `list` | ein Listenpunkt der obersten Ebene (mit eingerückten Unterpunkten); Text außerhalb von Listen wird zu Absätzen | Aufzählungen, Berichte, Changelogs |
|
|||
|
|
| `heading` | ein Abschnitt von Überschrift zu Überschrift | Dokumentation, Wikis |
|
|||
|
|
| `file` | eine ganze Datei | „In welchen Dateien kommt X und Y vor?" |
|
|||
|
|
|
|||
|
|
Die logische Verknüpfung gilt **pro Block**: `rust AND go` findet nur Blöcke, in denen beide
|
|||
|
|
Wörter vorkommen. Mit `--split file` gilt sie für die ganze Datei.
|
|||
|
|
|
|||
|
|
### 3.4 Rückgabewerte (für Skripte)
|
|||
|
|
|
|||
|
|
| Code | Bedeutung |
|
|||
|
|
|---|---|
|
|||
|
|
| `0` | Treffer gefunden |
|
|||
|
|
| `1` | kein Treffer |
|
|||
|
|
| `2` | Fehler: Syntaxfehler (mit Markierung der Position), Datei nicht lesbar oder Zeitlimit erreicht |
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
if mdgrep 'TODO OR FIXME' src/ -c > /dev/null; then echo "Es gibt noch offene Punkte"; fi
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. Python-Bibliothek
|
|||
|
|
|
|||
|
|
### 4.1 Nur die Suchsprache
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from mdgrep import compile_query, QuerySyntaxError
|
|||
|
|
|
|||
|
|
q = compile_query('(rust OR go) AND NOT /crypto(currency)?/')
|
|||
|
|
q.matches("Rust 2.0 is out") # True
|
|||
|
|
q.spans("Rust and Go") # [(0, 4), (9, 11)] → Positionen zum Hervorheben
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
compile_query("(rust OR go")
|
|||
|
|
except QuerySyntaxError as e:
|
|||
|
|
print(e.code, e.position, e.message) # unclosed_paren 0 Missing closing parenthesis
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- `compile_query(text, max_length=500, max_terms=50)` → `Query`
|
|||
|
|
- `Query.matches(text, timeout=None)` → `bool`
|
|||
|
|
- `Query.spans(text, timeout=None)` → Liste von `(start, ende)`, sortiert und zusammengefasst;
|
|||
|
|
Begriffe unter NICHT werden nicht markiert
|
|||
|
|
- Bei Überschreitung von `timeout` (Sekunden) wird `QueryTimeout` ausgelöst
|
|||
|
|
|
|||
|
|
### 4.2 Markdown-Dateien durchsuchen
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from mdgrep import load_markdown, SearchEngine
|
|||
|
|
|
|||
|
|
blocks = load_markdown("docs/", split="list", relative_to="docs/")
|
|||
|
|
engine = SearchEngine(blocks)
|
|||
|
|
|
|||
|
|
result = engine.search("rust -crypto", limit=20)
|
|||
|
|
print(result.total_blocks, "Treffer")
|
|||
|
|
for group in result.groups:
|
|||
|
|
b = group.block
|
|||
|
|
print(f"{b.source}:{b.line}", "".join(s["t"] for s in group.segments))
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`load_markdown(pfade, ...)`:
|
|||
|
|
|
|||
|
|
| Parameter | Bedeutung | Standard |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `paths` | Datei, Verzeichnis oder Liste davon | |
|
|||
|
|
| `split` | `file` · `heading` · `list` · `paragraph` | `paragraph` |
|
|||
|
|
| `meta` | Funktion `pfad → dict`; wird in die Metadaten jedes Blocks der Datei übernommen | |
|
|||
|
|
| `relative_to` | Verzeichnis, relativ zu dem `source`/`id` angegeben werden (wichtig für Web: keine Serverpfade preisgeben) | absolute/angegebene Pfade |
|
|||
|
|
| `pattern` | Dateimuster in Verzeichnissen | `*.md` |
|
|||
|
|
| `dialect` | `commonmark` oder `python-markdown` (siehe 4.6) | `commonmark` |
|
|||
|
|
| `encoding` | Zeichenkodierung | `utf-8` |
|
|||
|
|
|
|||
|
|
Jeder Block bekommt in `meta` automatisch: `section` (nächste Überschrift darüber),
|
|||
|
|
`kind` (`file`/`section`/`item`/`paragraph`) und `markdown` (Original-Quelltext).
|
|||
|
|
Durchsucht wird der Text **ohne** Markdown-Zeichen (Links, Fett, Code …).
|
|||
|
|
|
|||
|
|
### 4.3 Eigene Daten durchsuchen
|
|||
|
|
|
|||
|
|
mdgrep ist nicht auf Markdown beschränkt. Jede Datenquelle lässt sich als Liste von `Block`-Objekten
|
|||
|
|
übergeben:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from mdgrep import Block, SearchEngine
|
|||
|
|
|
|||
|
|
tickets = [
|
|||
|
|
Block(id="42", title="Ticket 42", text="Login schlägt mit SSO fehl", meta={"status": "offen"}),
|
|||
|
|
Block(id="43", title="Ticket 43", text="Dunkelmodus-Schalter defekt", meta={"status": "erledigt"}),
|
|||
|
|
]
|
|||
|
|
engine = SearchEngine(tickets)
|
|||
|
|
engine.search("login ODER sso", filters={"status": "offen"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| Feld | Bedeutung |
|
|||
|
|
|---|---|
|
|||
|
|
| `id` | eindeutige Kennung |
|
|||
|
|
| `title` | wird durchsucht und als Überschrift angezeigt (nicht im `text` wiederholen) |
|
|||
|
|
| `text` | wird durchsucht und als Ausschnitt angezeigt |
|
|||
|
|
| `anchor` | optionale Sprungmarke (`#anchor`), Linkziel im Widget, wenn kein `href` gesetzt ist |
|
|||
|
|
| `source`, `line` | Herkunft (Datei, Zeile ab 1) |
|
|||
|
|
| `meta` | beliebiges Wörterbuch; wird unverändert durchgereicht, Filter arbeiten darauf |
|
|||
|
|
|
|||
|
|
### 4.4 Suchoptionen
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
result = engine.search(
|
|||
|
|
"rust",
|
|||
|
|
filters={"lang": ["de", "en"]}, # alle Schlüssel müssen passen; leere Werte = egal
|
|||
|
|
predicate=lambda b: b.line > 1, # zusätzlicher eigener Filter
|
|||
|
|
group_by=lambda b: b.meta.get("url"), # gleiche Schlüssel → ein Ergebnis, Rest in group.also
|
|||
|
|
sort_key=lambda b: b.meta["date"], reverse=True, # neueste zuerst
|
|||
|
|
offset=0, limit=30, # Seiten (zählt Gruppen)
|
|||
|
|
time_budget=1.5, # Sekunden; danach Teilergebnis mit truncated=True
|
|||
|
|
snippet_chars=700, context=120, # lange Texte auf Fenster um die Treffer kürzen
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Das Ergebnis (`SearchResult`):
|
|||
|
|
|
|||
|
|
| Attribut | Bedeutung |
|
|||
|
|
|---|---|
|
|||
|
|
| `groups` | Treffer der aktuellen Seite; je Gruppe `block`, `segments`, `title_segments`, `hits`, `also` |
|
|||
|
|
| `total_groups`, `total_blocks` | Anzahl Ergebnisse bzw. Fundstellen insgesamt |
|
|||
|
|
| `next_offset` | `offset` der nächsten Seite oder `None` |
|
|||
|
|
| `truncated` | `True`, wenn das Zeitbudget aufgebraucht wurde |
|
|||
|
|
| `elapsed_ms` | Suchdauer |
|
|||
|
|
| `to_dict()` | JSON-fähiges Wörterbuch (Format siehe 5.3) |
|
|||
|
|
|
|||
|
|
`segments` ist eine Liste `[{"t": "Text", "hit": False}, {"t": "Treffer", "hit": True}, …]` –
|
|||
|
|
fertig zum Anzeigen mit Hervorhebung.
|
|||
|
|
|
|||
|
|
### 4.5 Automatisch neu laden
|
|||
|
|
|
|||
|
|
Ändern sich die Dateien, lädt die Suchmaschine sie selbst neu – ohne Neustart:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
def stand(verzeichnis=Path("docs")):
|
|||
|
|
dateien = [p.stat() for p in verzeichnis.rglob("*.md")]
|
|||
|
|
return len(dateien), max((s.st_mtime for s in dateien), default=0)
|
|||
|
|
|
|||
|
|
engine = SearchEngine(lambda: load_markdown("docs", relative_to="docs"),
|
|||
|
|
reload_check=stand, reload_interval=60)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`reload_check` liefert einen beliebigen vergleichbaren Wert; ändert er sich, wird neu geladen.
|
|||
|
|
Geprüft wird höchstens alle `reload_interval` Sekunden. `engine.reload()` lädt sofort neu.
|
|||
|
|
|
|||
|
|
### 4.6 Markdown-Dialekt – wenn Treffer auf HTML-Elemente verlinken
|
|||
|
|
|
|||
|
|
Wer Treffer mit Sprungmarken in gerenderten HTML-Seiten verknüpft, braucht dieselbe Aufteilung wie
|
|||
|
|
der Markdown-Renderer. Die Renderer unterscheiden sich bei Listen:
|
|||
|
|
|
|||
|
|
| | `commonmark` (Standard) | `python-markdown` |
|
|||
|
|
|---|---|---|
|
|||
|
|
| Liste direkt unter einer Absatzzeile (ohne Leerzeile) | beginnt eine Liste | gehört zum Absatz |
|
|||
|
|
| Unterpunkt mit 1–3 Leerzeichen Einrückung | verschachtelt | eigener Punkt auf oberster Ebene |
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
split_markdown(text, split="list", dialect="python-markdown")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.7 Weitere Funktionen
|
|||
|
|
|
|||
|
|
| Funktion | Zweck |
|
|||
|
|
|---|---|
|
|||
|
|
| `split_markdown(text, source=…, split=…, meta=…, dialect=…)` | einen Markdown-Text (statt Dateien) zerlegen |
|
|||
|
|
| `markdown_to_text(md)` | Markdown-Zeichen entfernen, Zeilen erhalten |
|
|||
|
|
| `segments(text, spans, max_chars=…, context=…)` | Text + Fundstellen → Anzeige-Segmente |
|
|||
|
|
| `export_static(zielverzeichnis)` | Widget-Dateien (JS + CSS) kopieren, siehe 6.4 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. Web-API
|
|||
|
|
|
|||
|
|
Benötigt `mdgrep[web]`.
|
|||
|
|
|
|||
|
|
### 5.1 Eigenständige App
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app.py
|
|||
|
|
from mdgrep import SearchEngine, load_markdown
|
|||
|
|
from mdgrep.web import create_app
|
|||
|
|
|
|||
|
|
engine = SearchEngine(lambda: load_markdown("docs", split="list", relative_to="docs"))
|
|||
|
|
app = create_app(engine, filters=["kind"]) # GET /search?q=…&kind=…
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.2 In eine bestehende Flask-App einhängen
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from mdgrep.web import create_blueprint
|
|||
|
|
|
|||
|
|
app.register_blueprint(create_blueprint(engine, filters=["lang"]), url_prefix="/api")
|
|||
|
|
# → GET /api/search?q=… und Widget-Dateien unter /api/mdgrep-static/
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Optionen von `create_blueprint()` (zusätzlich zu `group_by`, `sort_key`, `reverse`,
|
|||
|
|
`time_budget`, `snippet_chars`, `context` aus 4.4):
|
|||
|
|
|
|||
|
|
| Option | Bedeutung | Standard |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `filters` | Metadaten-Schlüssel, die als URL-Parameter filterbar sind | keine |
|
|||
|
|
| `default_limit` / `max_limit` | Ergebnisse pro Seite / Obergrenze | `30` / `100` |
|
|||
|
|
| `max_query_length` | maximale Länge der Suchanfrage | `300` |
|
|||
|
|
| `hide_meta` | Metadaten-Schlüssel, die nicht ausgeliefert werden | `("markdown",)` |
|
|||
|
|
| `name` | Blueprint-Name (bei mehreren Suchen in einer App) | `mdgrep` |
|
|||
|
|
|
|||
|
|
`create_app(engine, url_prefix="", static_dir=None, **optionen)` – mit `static_dir` liefert die App
|
|||
|
|
zusätzlich die Dateien dieses Verzeichnisses unter `/` aus (praktisch zum Testen einer statischen Seite).
|
|||
|
|
|
|||
|
|
### 5.3 Anfragen und Antworten
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
GET /search?q=rust%20-crypto&lang=de&offset=0&limit=30
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- `q` – Suchanfrage (leer → leeres Ergebnis)
|
|||
|
|
- je Name in `filters` ein Parameter: leer oder `all` = keine Einschränkung, `de,en` = mehrere Werte
|
|||
|
|
- `offset`, `limit` – Seiten
|
|||
|
|
|
|||
|
|
Antwort (HTTP 200):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"query": "rust -crypto", "total_groups": 12, "total_blocks": 15,
|
|||
|
|
"offset": 0, "limit": 30, "next_offset": null, "truncated": false, "elapsed_ms": 3.2,
|
|||
|
|
"results": [{
|
|||
|
|
"block": {
|
|||
|
|
"id": "a.md:4", "title": "…", "anchor": "", "source": "a.md", "line": 4, "meta": {},
|
|||
|
|
"title_segments": [{"t": "…", "hit": false}],
|
|||
|
|
"segments": [{"t": "The ", "hit": false}, {"t": "Rust", "hit": true}, {"t": " team …", "hit": false}],
|
|||
|
|
"hits": 1
|
|||
|
|
},
|
|||
|
|
"also": [{"id": "b.md:9", "title": "…", "anchor": "", "source": "b.md", "line": 9, "meta": {}}]
|
|||
|
|
}]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Fehler (HTTP 400):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"error": {"code": "unclosed_paren", "message": "Missing closing parenthesis", "position": 0}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.4 Entwicklungsserver
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
python -m mdgrep.web --dir docs --split list --static site --port 8000
|
|||
|
|
# → http://127.0.0.1:8000/ (Dateien aus site/)
|
|||
|
|
# → http://127.0.0.1:8000/api/search (API)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| Option | Bedeutung | Standard |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `--dir` | Verzeichnis mit Markdown-Dateien (Pflicht) | |
|
|||
|
|
| `--split` | Split-Modus | `paragraph` |
|
|||
|
|
| `--static` | dieses Verzeichnis unter `/` ausliefern; API dann unter `--api-prefix` | |
|
|||
|
|
| `--api-prefix` | Pfad der API, wenn `--static` gesetzt ist | `/api` |
|
|||
|
|
| `--filters` | kommagetrennte Metadaten-Schlüssel als Filter, z. B. `kind,section` | |
|
|||
|
|
| `--host`, `--port` | Adresse | `127.0.0.1`, `8000` |
|
|||
|
|
|
|||
|
|
Neue oder geänderte Dateien werden im Entwicklungsserver nach spätestens 5 Sekunden übernommen.
|
|||
|
|
Ein vollständiges Beispiel liegt in `examples/`:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
python -m mdgrep.web --dir examples/docs --split list --static examples/site
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. Such-Widget
|
|||
|
|
|
|||
|
|
Eine fertige Suchmaske: Ergebnisse erscheinen beim Tippen, veraltete Anfragen werden abgebrochen,
|
|||
|
|
die Suche steht in der URL (teilbar, Zurück-Taste funktioniert), Filter, „Mehr laden",
|
|||
|
|
aufklappbare Syntaxhilfe, Fehlermeldungen mit Position, Beschriftung Deutsch oder Englisch.
|
|||
|
|
Alle Texte werden sicher als Text eingefügt (kein HTML vom Server → kein Cross-Site-Scripting).
|
|||
|
|
Keine Abhängigkeiten, kein Framework.
|
|||
|
|
|
|||
|
|
### 6.1 Einbinden
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<link rel="stylesheet" href="/api/mdgrep-static/mdgrep-widget.css">
|
|||
|
|
<div id="suche"></div>
|
|||
|
|
<script src="/api/mdgrep-static/mdgrep-widget.js"></script>
|
|||
|
|
<script>
|
|||
|
|
MdGrep.mount(document.getElementById('suche'), {
|
|||
|
|
endpoint: '/api/search',
|
|||
|
|
labels: 'de',
|
|||
|
|
filters: [
|
|||
|
|
{ name: 'lang', label: 'Sprache', options: [
|
|||
|
|
{ value: '', label: 'Alle' }, { value: 'de', label: 'Deutsch' }, { value: 'en', label: 'Englisch' } ] },
|
|||
|
|
],
|
|||
|
|
badgeFields: ['lang'],
|
|||
|
|
dateField: 'date',
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Ganz ohne eigenes JavaScript:
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<div data-mdgrep data-endpoint="/api/search" data-labels="de"
|
|||
|
|
data-filters='[{"name":"kind","label":"Art","options":[{"value":"","label":"Alle"},{"value":"item","label":"Listenpunkte"}]}]'
|
|||
|
|
data-badge-fields="kind"></div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.2 Optionen
|
|||
|
|
|
|||
|
|
| Option | Bedeutung | Standard |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `endpoint` | URL der Such-API (relativ oder absolut) | `search` |
|
|||
|
|
| `labels` | `'de'`, `'en'` oder Objekt mit einzelnen Texten (siehe 6.5) | `'en'` |
|
|||
|
|
| `filters` | Auswahlfelder `[{name, label, options:[{value,label}], value}]`; `name` = URL-Parameter der API | `[]` |
|
|||
|
|
| `live` | beim Tippen suchen | `true` |
|
|||
|
|
| `debounce` | Wartezeit nach dem letzten Tastendruck (ms) | `300` |
|
|||
|
|
| `minChars` | Mindestlänge der Eingabe | `2` |
|
|||
|
|
| `limit` | Ergebnisse pro Seite | `30` |
|
|||
|
|
| `syncUrl` | Suche und Filter in der Seiten-URL halten | `true` |
|
|||
|
|
| `autofocus` | Eingabefeld beim Laden fokussieren | `true` |
|
|||
|
|
| `help` | Syntaxhilfe anzeigen | `true` |
|
|||
|
|
| `hrefField` | Metadaten-Feld mit dem Linkziel | `href` |
|
|||
|
|
| `href(block)` | eigene Funktion für das Linkziel (hat Vorrang) | |
|
|||
|
|
| `dateField` | Metadaten-Feld, das als Datum angezeigt wird | |
|
|||
|
|
| `badgeFields` | Metadaten-Felder, die als Plaketten angezeigt werden | `[]` |
|
|||
|
|
| `formatBadge(feld, wert, block)` | Text einer Plakette anpassen | |
|
|||
|
|
| `alsoText(block)` | Linktext in der Liste „auch in …" | Datum bzw. Titel |
|
|||
|
|
|
|||
|
|
Ohne `href` und ohne `hrefField`-Wert verlinkt ein Treffer auf `#anchor` des Blocks.
|
|||
|
|
|
|||
|
|
### 6.3 Steuern per JavaScript
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
var suche = MdGrep.mount(element, optionen);
|
|||
|
|
suche.setQuery('rust -crypto'); // Suchbegriff setzen und suchen
|
|||
|
|
suche.search(); // erneut suchen
|
|||
|
|
suche.input; // das Eingabefeld
|
|||
|
|
|
|||
|
|
element.addEventListener('mdgrep:results', function (e) {
|
|||
|
|
console.log(e.detail.total_groups); // vollständige API-Antwort
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Ist die Seite mit `?q=…` aufgerufen worden, sucht das Widget sofort. So kann ein einfaches
|
|||
|
|
Formular auf einer anderen Seite zur Suchseite weiterleiten:
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<form action="suche.html"><input type="search" name="q"><button>Suchen</button></form>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.4 Statische Webseiten
|
|||
|
|
|
|||
|
|
Liegt die Seite nicht in Flask, die Widget-Dateien einmalig (oder bei jedem Build) kopieren:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
import mdgrep
|
|||
|
|
mdgrep.export_static("site/static") # schreibt mdgrep-widget.js und mdgrep-widget.css
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Die Dateien werden nur neu geschrieben, wenn sich ihr Inhalt geändert hat.
|
|||
|
|
|
|||
|
|
### 6.5 Aussehen anpassen
|
|||
|
|
|
|||
|
|
Farben und Rundungen über CSS-Variablen (an `:root` oder einem umgebenden Element):
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
:root {
|
|||
|
|
--mdgrep-text: #1a1d2e; /* Text */
|
|||
|
|
--mdgrep-muted: #6b7394; /* Nebentexte */
|
|||
|
|
--mdgrep-surface: #ffffff; /* Kästchen, Eingabefeld */
|
|||
|
|
--mdgrep-border: #d5d8e3; /* Rahmen */
|
|||
|
|
--mdgrep-accent: #4a7cff; /* Fokus, Hover */
|
|||
|
|
--mdgrep-mark-bg: #ffe58a; /* Hervorhebung */
|
|||
|
|
--mdgrep-mark-text: inherit;
|
|||
|
|
--mdgrep-error: #c0392b; /* Fehlermeldungen */
|
|||
|
|
--mdgrep-radius: 0.5rem;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Für einen Dunkelmodus die Variablen auf die eigenen Farbvariablen abbilden, z. B.
|
|||
|
|
`--mdgrep-surface: var(--meine-flaeche);` – dann folgt das Widget dem Seitenthema automatisch.
|
|||
|
|
|
|||
|
|
Einzelne Texte überschreiben:
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
labels: { lang: 'de', none: 'Leider nichts gefunden.', more: 'Weitere Treffer' }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 7. Betrieb auf einem Server
|
|||
|
|
|
|||
|
|
Der Flask-Entwicklungsserver ist nur zum Testen gedacht. Für den Dauerbetrieb:
|
|||
|
|
|
|||
|
|
### 7.1 Mit gunicorn
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
pip install gunicorn
|
|||
|
|
gunicorn --workers 2 --bind 127.0.0.1:8790 app:app
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Jeder Worker hält alle Blöcke im Speicher (Richtwert: ca. 90 MB für 15 MB Markdown / 26 000 Blöcke).
|
|||
|
|
|
|||
|
|
### 7.2 Als systemd-Dienst
|
|||
|
|
|
|||
|
|
```ini
|
|||
|
|
# /etc/systemd/system/meine-suche.service
|
|||
|
|
[Unit]
|
|||
|
|
Description=Volltextsuche (mdgrep)
|
|||
|
|
After=network.target
|
|||
|
|
|
|||
|
|
[Service]
|
|||
|
|
User=meinuser
|
|||
|
|
WorkingDirectory=/home/meinuser/projekt
|
|||
|
|
ExecStart=/home/meinuser/projekt/venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8790 app:app
|
|||
|
|
Restart=on-failure
|
|||
|
|
NoNewPrivileges=true
|
|||
|
|
PrivateTmp=true
|
|||
|
|
|
|||
|
|
[Install]
|
|||
|
|
WantedBy=multi-user.target
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
sudo systemctl daemon-reload && sudo systemctl enable --now meine-suche
|
|||
|
|
systemctl status meine-suche
|
|||
|
|
journalctl -u meine-suche -n 50
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.3 Hinter nginx mit Ratenbegrenzung
|
|||
|
|
|
|||
|
|
```nginx
|
|||
|
|
# http-Kontext, z. B. /etc/nginx/conf.d/suche.conf
|
|||
|
|
limit_req_zone $binary_remote_addr zone=suche:10m rate=5r/s;
|
|||
|
|
|
|||
|
|
# im server-Block
|
|||
|
|
location = /api/search {
|
|||
|
|
limit_req zone=suche burst=20 nodelay;
|
|||
|
|
limit_req_status 429;
|
|||
|
|
proxy_pass http://127.0.0.1:8790/search$is_args$args;
|
|||
|
|
proxy_read_timeout 10s;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Das Widget zeigt bei HTTP 429 „Zu viele Anfragen – bitte einen Moment warten."
|
|||
|
|
|
|||
|
|
### 7.4 Sicherheit
|
|||
|
|
|
|||
|
|
- **Bösartige reguläre Ausdrücke** (z. B. `/(a|aa)+$/`) können exponentiell lange rechnen. mdgrep
|
|||
|
|
begrenzt jede Suche auf `time_budget` (Web-API: 1,5 s) und liefert danach ein Teilergebnis
|
|||
|
|
mit `truncated: true`.
|
|||
|
|
- **Ratenbegrenzung** im Reverse-Proxy (7.3) verhindert, dass viele Anfragen den Server auslasten.
|
|||
|
|
- **Keine Serverpfade** preisgeben: `load_markdown(..., relative_to=…)` verwenden.
|
|||
|
|
- **Interne Metadaten** mit `hide_meta` aus Antworten entfernen.
|
|||
|
|
- Das Widget fügt Ergebnisse nur als Text ein – auch Markdown mit eingebettetem HTML ist sicher.
|
|||
|
|
|
|||
|
|
### 7.5 Leistung
|
|||
|
|
|
|||
|
|
Ohne Index werden alle Blöcke bei jeder Suche durchlaufen. Richtwerte für 26 000 Blöcke (15 MB):
|
|||
|
|
einfache Begriffe und Phrasen 30–70 ms, reguläre Ausdrücke und Verknüpfungen 100–150 ms.
|
|||
|
|
Für deutlich größere Datenmengen (Hunderte MB) ist ein Index-basiertes Werkzeug besser geeignet.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 8. Rezepte
|
|||
|
|
|
|||
|
|
**Notizen nach zwei Begriffen im selben Absatz durchsuchen**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
mdgrep 'kubernetes "persistent volume"' ~/notizen
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Dateien finden, die A enthalten, aber nicht B**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
mdgrep 'docker -podman' ~/wiki --split file -l
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Changelog: alle Einträge zu Sicherheitslücken**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
mdgrep '/\bCVE-\d{4}-\d+\b/ OR security' CHANGELOG.md --split list
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Treffer in einem Skript weiterverarbeiten**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
mdgrep 'rust' docs --json | python -c "import json,sys; print([r['block']['source'] for r in json.load(sys.stdin)['results']])"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Suche mit Sprachfilter aus dem Dateinamen** (`bericht_de.md`, `bericht_en.md`)
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
engine = SearchEngine(load_markdown("berichte", split="list", relative_to="berichte",
|
|||
|
|
meta=lambda p: {"lang": p.stem.rsplit("_", 1)[-1]}))
|
|||
|
|
engine.search("datenschutz", filters={"lang": "de"})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Gleiche Einträge aus mehreren Dateien zusammenfassen, neueste zuerst**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
engine.search("rust", group_by=lambda b: b.meta.get("url"),
|
|||
|
|
sort_key=lambda b: b.meta["date"], reverse=True)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Praxisbeispiel:** Die Hacker-News-Berichte auf https://jamulix.de/HackerNews/search.html nutzen
|
|||
|
|
mdgrep mit einem kleinen Adapter (`hnews_search.py` im Projekt *hackernews*): Stories und Insights
|
|||
|
|
als Blöcke, Sprache/Datum/Linkziel als Metadaten, Gruppierung gleicher Stories, Sprungmarken in den
|
|||
|
|
HTML-Seiten (`dialect="python-markdown"`).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 9. Fehlermeldungen
|
|||
|
|
|
|||
|
|
Ungültige Anfragen liefern einen festen Code (für eigene Übersetzungen), eine Meldung und die
|
|||
|
|
Position (0-basiert; das Widget zeigt sie 1-basiert an).
|
|||
|
|
|
|||
|
|
| Code | Bedeutung | Beispiel |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `empty_query` | leere Anfrage | ` ` |
|
|||
|
|
| `empty_phrase` | leere Phrase | `""` |
|
|||
|
|
| `unterminated_phrase` | schließendes Anführungszeichen fehlt | `"open source` |
|
|||
|
|
| `unterminated_regex` | schließender Schrägstrich fehlt | `/abc` |
|
|||
|
|
| `invalid_regex` | regulärer Ausdruck ungültig | `/a(b/` |
|
|||
|
|
| `missing_operand` | Suchbegriff erwartet | `rust OR` · `AND go` |
|
|||
|
|
| `unexpected_paren` | schließende Klammer ohne öffnende | `rust)` |
|
|||
|
|
| `unexpected_token` | Operator an unerwarteter Stelle | |
|
|||
|
|
| `unclosed_paren` | schließende Klammer fehlt | `(rust OR go` |
|
|||
|
|
| `empty_group` | leere Klammern | `()` |
|
|||
|
|
| `too_long` | Anfrage zu lang | |
|
|||
|
|
| `too_many_terms` | zu viele Suchbegriffe | |
|
|||
|
|
|
|||
|
|
Auf der Kommandozeile wird die Stelle markiert:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
mdgrep: Missing closing parenthesis
|
|||
|
|
(rust OR go
|
|||
|
|
^
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 10. Fehlersuche
|
|||
|
|
|
|||
|
|
| Problem | Ursache / Lösung |
|
|||
|
|
|---|---|
|
|||
|
|
| `bash: !…: event not found` | Suchanfrage in **einfache** Anführungszeichen setzen: `mdgrep 'rust !crypto'` |
|
|||
|
|
| `go` findet zu viel | Wortbestandteile treffen auch in „Goddard" – ganze Wörter mit `/\bgo\b/` |
|
|||
|
|
| `and`/`or` wirken nicht als Operator | Operatoren nur in Großbuchstaben: `AND`, `OR`, `NOT` (oder `UND`, `ODER`, `NICHT`) |
|
|||
|
|
| Keine Treffer in `.txt`-Dateien | Standardmuster ist `*.md` → `-g '*.txt'` bzw. `pattern="*.txt"` |
|
|||
|
|
| `ModuleNotFoundError: flask` | Web-Teil braucht `pip install "mdgrep[web] @ git+…"` |
|
|||
|
|
| Widget zeigt „Die Suche ist fehlgeschlagen" | API nicht erreichbar: `endpoint` prüfen, Browser-Konsole/Netzwerk ansehen |
|
|||
|
|
| Widget zeigt „Zu viele Anfragen" | Ratenbegrenzung des Proxys greift (HTTP 429) – kurz warten oder `burst` erhöhen |
|
|||
|
|
| „Zeitlimit erreicht" | sehr aufwendiger regulärer Ausdruck; vereinfachen oder `time_budget` erhöhen |
|
|||
|
|
| Neue Dateien werden nicht gefunden | `reload_check` gesetzt? Geprüft wird nur alle `reload_interval` Sekunden |
|
|||
|
|
| Sprungmarken zeigen auf falsche Stellen | gleichen Markdown-`dialect` wie der HTML-Renderer verwenden (4.6) |
|
|||
|
|
| Widget-Darstellung „verbogen" | Seiten-CSS überschreibt Elemente (z. B. `details`, `ol`, `button`); gezielt unter `.mdgrep …` zurücksetzen |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 11. Entwicklung & neue Versionen
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git clone https://kitux.de/forgejo/dschlueter/mdgrep.git && cd mdgrep
|
|||
|
|
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|||
|
|
.venv/bin/pytest # alle Tests
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Aufbau:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
src/mdgrep/
|
|||
|
|
├── query.py # Suchsprache: Tokenizer, Parser, Auswertung, Fundstellen
|
|||
|
|
├── engine.py # Block, SearchEngine, SearchResult
|
|||
|
|
├── snippets.py # Anzeige-Segmente, Kürzen um Treffer
|
|||
|
|
├── markdown.py # Markdown → Blöcke, Markdown → Text
|
|||
|
|
├── cli.py # Kommandozeile
|
|||
|
|
├── web.py # Flask-Blueprint/-App, Entwicklungsserver
|
|||
|
|
└── static/ # mdgrep-widget.js, mdgrep-widget.css
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Neue Version veröffentlichen:
|
|||
|
|
|
|||
|
|
1. Version in `pyproject.toml` **und** `src/mdgrep/__init__.py` erhöhen (bei Widget-Änderungen auch im Kopf von `mdgrep-widget.js`)
|
|||
|
|
2. Tests laufen lassen: `.venv/bin/pytest`
|
|||
|
|
3. Commit, Tag und Push: `git tag -a v0.1.2 -m "mdgrep 0.1.2" && git push origin main --tags`
|
|||
|
|
4. In den nutzenden Projekten den Tag in `requirements.txt` anpassen und `pip install -r requirements.txt`
|
|||
|
|
ausführen; laufende Dienste neu starten.
|
|||
|
|
|
|||
|
|
Versionsregel: Änderungen an öffentlichen Funktionen, am JSON-Format oder an Widget-Optionen, die
|
|||
|
|
bestehende Nutzer brechen, nur mit erhöhter Nebenversion (0.**2**.0) und Hinweis im Commit.
|