New section 8: requirements for searching a collection (encoding, size, split mode vs. structure, metadata via naming scheme, limits) and for jump anchors (predictable HTML paths, three ways to ids, same Markdown dialect, keeping source and HTML in sync, consistency check script), plus a checklist. Following sections renumbered 9-12. 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) - 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.