- CHANGELOG.md (Keep a Changelog, German) for 0.1.0, 0.1.1 and unreleased documentation work, incl. "Beim Update zu tun" - README: documentation table (manual, how it works, changelog, license) - manual: release steps now include updating CHANGELOG/FUNKTIONSWEISE Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
33 KiB
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
- Installation
- Suchsyntax
- Kommandozeile
- Python-Bibliothek
- Web-API
- Such-Widget
- Betrieb auf einem Server
- Voraussetzungen an die Dokumente
- Rezepte
- Fehlermeldungen
- Fehlersuche
- Entwicklung & neue Versionen
1. Installation
Voraussetzung: Python 3.10 oder neuer, git (für die Installation aus dem Repository).
# 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:
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 mdgrepfindet 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 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:
andist ein normales Suchwort,ANDder Operator. - Wortbestandteile treffen auch mitten im Wort:
gofindet „Goddard". 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:
(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
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
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):
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 |
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
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)→QueryQuery.matches(text, timeout=None)→boolQuery.spans(text, timeout=None)→ Liste von(start, ende), sortiert und zusammengefasst; Begriffe unter NICHT werden nicht markiert- Bei Überschreitung von
timeout(Sekunden) wirdQueryTimeoutausgelöst
4.2 Markdown-Dateien durchsuchen
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:
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
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:
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 |
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
# 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
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
GET /search?q=rust%20-crypto&lang=de&offset=0&limit=30
q– Suchanfrage (leer → leeres Ergebnis)- je Name in
filtersein Parameter: leer oderall= keine Einschränkung,de,en= mehrere Werte offset,limit– Seiten
Antwort (HTTP 200):
{
"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):
{"error": {"code": "unclosed_paren", "message": "Missing closing parenthesis", "position": 0}}
5.4 Entwicklungsserver
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/:
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
<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:
<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
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:
<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:
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):
: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:
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
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
# /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
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
# 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 auftime_budget(Web-API: 1,5 s) und liefert danach ein Teilergebnis mittruncated: 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_metaaus 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. Voraussetzungen an die Dokumente
Welche Bedingungen eine Sammlung (z. B. Markdown-Dateien) erfüllen sollte – getrennt nach „sinnvoll durchsuchen" und „Treffer springen direkt an die Fundstelle".
8.1 Für die Suche
-
Textdateien mit bekannter Kodierung – Standard UTF-8; sonst
encoding=. Andere Endungen als.mdüber-g '*.txt'bzw.pattern=. -
Größe passend zum Ansatz – mdgrep arbeitet ohne Index: alle Blöcke liegen im Speicher und werden bei jeder Suche durchlaufen. Bis etwa 50 MB Text unproblematisch (Richtwert: 15 MB → 30–150 ms pro Suche); bei Hunderten MB ist ein Index-basiertes Werkzeug besser.
-
Gliederung, die zu einem Split-Modus passt – die UND/ODER/NICHT-Logik gilt pro Block, die Blockgröße bestimmt also die Trefferqualität:
Aufbau der Dokumente Split-Modus Absätze, durch Leerzeilen getrennt paragraphEinträge als Listenpunkte (Berichte, Changelogs, Protokolle) listAbschnitte mit Überschriften (Dokumentation, Wiki) headingkurze Einzeldokumente fileZu große Blöcke machen
a AND bbeliebig (in einer langen Datei steht fast alles), zu kleine verfehlen Begriffe, die in benachbarten Sätzen stehen. -
Ableitbare Metadaten – für Filter (Sprache, Kategorie), Sortierung (Datum) und Gruppierung (gleicher Eintrag in mehreren Dateien) braucht jeder Block Werte in
meta:- am einfachsten über ein einheitliches Datei- oder Pfad-Schema
(z. B.
2026_10_12_bericht_de.md) undmeta=lambda pfad: {…}; - YAML-Frontmatter wird nicht ausgewertet, sondern als Text durchsucht – dafür einen kleinen
Adapter schreiben, der Frontmatter liest und
Block-Objekte erzeugt (siehe 4.3); - zum Zusammenfassen von Duplikaten (
group_by) einen stabilen Schlüssel pro Eintrag (URL, ID).
- am einfachsten über ein einheitliches Datei- oder Pfad-Schema
(z. B.
-
Grenzen einplanen
- Keine Wortstammsuche: „Haus" findet nicht „Häuser" →
/h(au|äu)s/oderhaus ODER häuser. - Keine Relevanz-Sortierung: die Reihenfolge bestimmt
sort_key(z. B. Datum). - Markdown-Zeichen (Links, Fett, Code) werden vor der Suche entfernt; direkt eingebettetes
HTML (
<dl>) und Tabellen-Striche bleiben als Text stehen.
- Keine Wortstammsuche: „Haus" findet nicht „Häuser" →
8.2 Für Sprungmarken
Damit ein Treffer direkt an die Fundstelle einer gerenderten Seite springt, müssen zwei Seiten exakt zusammenpassen: die Blöcke von mdgrep und die IDs im HTML.
-
Gerenderte Seiten mit vorhersagbarer URL – aus jeder Quelldatei muss sich ihr HTML-Pfad berechnen lassen (
docs/a/b.md→site/a/b.html). Diesen Pfad mit Sprungmarke inmeta["href"]schreiben (das Widget verlinkt darauf, siehe 6.2). -
Jeder Block hat eine ID, die im HTML existiert – drei Wege:
Weg Voraussetzung Bewertung Überschriften ( split="heading")Der Renderer erzeugt Überschriften-IDs mit demselben Verfahren wie mdgrep ( slugify, GitHub-Stil)wenig Aufwand, aber prüfen: Python-Markdown ( toc), MkDocs, Hugo und GitHub behandeln Umlaute, Sonderzeichen und doppelte Überschriften unterschiedlichListenpunkte/Absätze ( split="list"/paragraph)Eigenes Nachbearbeitungs-Skript nummeriert die HTML-Elemente nach derselben Reihenfolge-Regel wie die Blöcke und setzt block.anchorentsprechendmittlerer Aufwand, robust Text Fragments ( seite.html#:~:text=…)Aktueller Browser; keine Änderung am HTML. Im Widget über die Option href(block)bauenkein Aufwand, aber unzuverlässig bei mehrdeutigem oder umformatiertem Text Bekannte Einschränkung:
slugifyhängt bei doppelten Überschriften kein-1,-2an (anders als GitHub). Kommen Überschriften mehrfach vor, springen Treffer zur ersten. -
Gleicher Markdown-Dialekt wie der Renderer – Renderer zerlegen Listen unterschiedlich (siehe 4.6). Passt
dialectnicht, verschiebt sich die Nummerierung und Links zeigen auf falsche Absätze. -
Quelle und HTML auf demselben Stand – nach jeder Änderung einer Quelldatei deren Seite neu erzeugen; ändert sich die Zählregel, alle Seiten.
-
Einmal vollständig prüfen – für jeden Block muss die Sprungmarke im HTML existieren und auf ein Element zeigen, das seinen Text enthält. Skizze:
import re from bs4 import BeautifulSoup def pruefe(blocks, html_von): # html_von(block) → gerendertes HTML der Seite fehler = [] for b in blocks: el = BeautifulSoup(html_von(b), "html.parser").find(id=b.anchor) probe = re.sub(r"\s+", " ", (b.title or b.text))[:40].lower() if el is None or probe not in re.sub(r"\s+", " ", el.get_text()).lower(): fehler.append((b.source, b.anchor)) return fehlerDas deckt Formatvarianten älterer Dokumente auf, die man sonst übersieht.
8.3 Checkliste
| Bedingung | |
|---|---|
| ☐ | Textdateien, bekannte Kodierung, insgesamt höchstens im zweistelligen MB-Bereich |
| ☐ | Gliederung passt zu einem Split-Modus; Blöcke sind sinnvolle Einheiten |
| ☐ | Metadaten über einheitliches Datei-Namensschema (oder Adapter) |
| ☐ | stabiler Schlüssel pro Eintrag, falls Duplikate zusammengefasst werden |
| ☐ | Sprungmarken: HTML-Pfad aus Quellpfad berechenbar |
| ☐ | Sprungmarken: IDs im HTML nach derselben Regel wie die Blöcke, gleicher Dialekt |
| ☐ | Sprungmarken: HTML nach jeder Änderung neu erzeugt; Konsistenz einmal geprüft |
9. Rezepte
Notizen nach zwei Begriffen im selben Absatz durchsuchen
mdgrep 'kubernetes "persistent volume"' ~/notizen
Dateien finden, die A enthalten, aber nicht B
mdgrep 'docker -podman' ~/wiki --split file -l
Changelog: alle Einträge zu Sicherheitslücken
mdgrep '/\bCVE-\d{4}-\d+\b/ OR security' CHANGELOG.md --split list
Treffer in einem Skript weiterverarbeiten
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)
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
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").
10. 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:
mdgrep: Missing closing parenthesis
(rust OR go
^
11. 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 |
12. Entwicklung & neue Versionen
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:
- Version in
pyproject.tomlundsrc/mdgrep/__init__.pyerhöhen (bei Widget-Änderungen auch im Kopf vonmdgrep-widget.js) CHANGELOG.md: Abschnitt[Unreleased]in die neue Version umbenennen (mit Datum), „Beim Update zu tun" ergänzen; bei Änderungen am inneren Aufbau auchFUNKTIONSWEISE.mdaktualisieren- Tests laufen lassen:
.venv/bin/pytest - Commit, Tag und Push:
git tag -a v0.1.2 -m "mdgrep 0.1.2" && git push origin main --tags - In den nutzenden Projekten den Tag in
requirements.txtanpassen undpip install -r requirements.txtausfü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.