mdgrep/BEDIENUNGSANLEITUNG.md
dschlueter 9c6b78ec36 release: mdgrep 0.1.2 (documentation)
- version 0.1.2 in pyproject.toml and __init__.py
- CHANGELOG: [Unreleased] -> [0.1.2] with "Beim Update zu tun"
- install examples and version notes in README, BEDIENUNGSANLEITUNG,
  FUNKTIONSWEISE point to v0.1.2
- no code changes; widget files unchanged (v0.1.0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:05:29 +02:00

33 KiB
Raw Blame History

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.2 · Lizenz: MIT · Repository: https://kitux.de/forgejo/dschlueter/mdgrep


Inhaltsverzeichnis

  1. Installation
  2. Suchsyntax
  3. Kommandozeile
  4. Python-Bibliothek
  5. Web-API
  6. Such-Widget
  7. Betrieb auf einem Server
  8. Voraussetzungen an die Dokumente
  9. Rezepte
  10. Fehlermeldungen
  11. Fehlersuche
  12. 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.2"

# zusätzlich Web-API (installiert Flask)
pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.2"

In einer requirements.txt:

mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.2

Prüfen:

mdgrep --version            # mdgrep 0.1.2
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
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 „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) → 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

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 filters ein Parameter: leer oder all = 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 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. 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

  1. Textdateien mit bekannter Kodierung – Standard UTF-8; sonst encoding=. Andere Endungen als .md über -g '*.txt' bzw. pattern=.

  2. 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.

  3. 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 paragraph
    Einträge als Listenpunkte (Berichte, Changelogs, Protokolle) list
    Abschnitte mit Überschriften (Dokumentation, Wiki) heading
    kurze Einzeldokumente file

    Zu große Blöcke machen a AND b beliebig (in einer langen Datei steht fast alles), zu kleine verfehlen Begriffe, die in benachbarten Sätzen stehen.

  4. 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) und meta=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).
  5. Grenzen einplanen

    • Keine Wortstammsuche: „Haus" findet nicht „Häuser" → /h(au|äu)s/ oder haus 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.

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.

  1. 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 in meta["href"] schreiben (das Widget verlinkt darauf, siehe 6.2).

  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 unterschiedlich
    Listenpunkte/Absätze (split="list" / paragraph) Eigenes Nachbearbeitungs-Skript nummeriert die HTML-Elemente nach derselben Reihenfolge-Regel wie die Blöcke und setzt block.anchor entsprechend mittlerer Aufwand, robust
    Text Fragments (seite.html#:~:text=…) Aktueller Browser; keine Änderung am HTML. Im Widget über die Option href(block) bauen kein Aufwand, aber unzuverlässig bei mehrdeutigem oder umformatiertem Text

    Bekannte Einschränkung: slugify hängt bei doppelten Überschriften kein -1, -2 an (anders als GitHub). Kommen Überschriften mehrfach vor, springen Treffer zur ersten.

  3. Gleicher Markdown-Dialekt wie der Renderer – Renderer zerlegen Listen unterschiedlich (siehe 4.6). Passt dialect nicht, verschiebt sich die Nummerierung und Links zeigen auf falsche Absätze.

  4. Quelle und HTML auf demselben Stand – nach jeder Änderung einer Quelldatei deren Seite neu erzeugen; ändert sich die Zählregel, alle Seiten.

  5. 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 fehler
    

    Das 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:

  1. Version in pyproject.toml und src/mdgrep/__init__.py erhöhen (bei Widget-Änderungen auch im Kopf von mdgrep-widget.js)
  2. CHANGELOG.md: Abschnitt [Unreleased] in die neue Version umbenennen (mit Datum), „Beim Update zu tun" ergänzen; bei Änderungen am inneren Aufbau auch FUNKTIONSWEISE.md aktualisieren
  3. Tests laufen lassen: .venv/bin/pytest
  4. Commit, Tag und Push: git tag -a v0.1.2 -m "mdgrep 0.1.2" && git push origin main --tags
  5. 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.