#!/usr/bin/env python3 """SQLite FTS5 full-text search over een directory. Stdlib only, 3.6+. Run: python3 fts-search.py index ./docs python3 fts-search.py search "deploy checklist" python3 fts-search.py serve --port 8083 Spec (dit bestand is de spec): CLI: index [--db] walk + (her)indexeer, incrementeel op mtime+size search BM25-ranking, snippets met -highlights serve [--port] HTTP UI + JSON API op /api/search JSON API: /api/search?q=&limit=&offset= 200 {"total","items":[{path,title, snippet,rank,mtime,size}]} /api/stats 200 {"docs","bytes","indexed_at"} Ranking: SQLite bm25(fts), snippets via snippet() met -markering. Query-taal: woorden worden AND-gecombineerd als frasen ("a" AND "b"), veilig ge-escaped, nooit rauwe MATCH-syntax van de gebruiker. Indexeer-regels: hidden files/dirs overgeslagen, binaire bestanden overgeslagen (extensie-lijst + NUL-byte sniff), alleen .txt/.md/.rst/ .py/.js/.html/.css/.json/.toml/.yaml/.xml/.log/.csv en extensieloze tekstbestanden. Caps: MAX_FILE 1 MiB, MAX_FILES 10000, MAX_QUERY 200 chars, MAX_LIMIT 50, MAX_BODY-index 200 KiB per bestand. """ import argparse import json import os import socketserver import sqlite3 import threading import time from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import parse_qs, urlparse TEXT_EXTS = frozenset([ ".txt", ".md", ".markdown", ".rst", ".py", ".js", ".ts", ".html", ".htm", ".css", ".json", ".toml", ".yaml", ".yml", ".xml", ".log", ".csv", ".tsv", ".ini", ".cfg", ".sh", ".sql", ".java", ".c", ".h", ".go", ".rs", "", ]) MAX_FILE = 1024 * 1024 MAX_FILES = 10000 MAX_INDEX_CHARS = 200 * 1024 class SearchError(Exception): pass class SearchEngine(object): """SQLite + FTS5 index. Een verbinding, RLock, WAL.""" SCHEMA = ("CREATE TABLE IF NOT EXISTS docs (" "path TEXT PRIMARY KEY," "title TEXT NOT NULL," "body TEXT NOT NULL," "mtime REAL NOT NULL," "size INTEGER NOT NULL," "indexed_at TEXT NOT NULL)") def __init__(self, db_path): self.db_path = db_path self._lock = threading.RLock() self.db = sqlite3.connect(db_path, check_same_thread=False) self.db.row_factory = sqlite3.Row self.db.execute("PRAGMA journal_mode=WAL") self.db.execute("PRAGMA busy_timeout=5000") self.db.execute("PRAGMA synchronous=NORMAL") with self.db: self.db.execute(self.SCHEMA) try: self.db.execute("CREATE VIRTUAL TABLE IF NOT EXISTS fts " "USING fts5(path, title, body, " "tokenize='porter unicode61')") except sqlite3.OperationalError: raise SearchError( "deze SQLite heeft geen FTS5 (nodig voor ranking). " "Python >= 3.9 bundelt het vrijwel altijd.") self.db.execute( "CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs " "BEGIN INSERT INTO fts(path, title, body) " "VALUES (new.path, new.title, new.body); END") self.db.execute( "CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs " "BEGIN DELETE FROM fts WHERE path=old.path; END") self.db.execute( "CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs " "BEGIN DELETE FROM fts WHERE path=old.path; " "INSERT INTO fts(path, title, body) " "VALUES (new.path, new.title, new.body); END") @staticmethod def match_query(query): """Bouw veilige MATCH-query: frasen met AND, alles ge-escaped.""" words = [w for w in query.split() if w.strip("\"'")] terms = [] for word in words[:10]: clean = word.strip("\"'()*:^").replace('"', "") if clean: terms.append('"%s"' % clean) return " AND ".join(terms) def search(self, query, limit=20, offset=0): query = (query or "")[:200].strip() if not query: return 0, [] match = self.match_query(query) if not match: return 0, [] limit = min(max(limit, 1), 50) offset = max(offset, 0) with self._lock: try: total = self.db.execute( "SELECT COUNT(*) AS n FROM fts WHERE fts MATCH ?", (match,)).fetchone()["n"] rows = self.db.execute( "SELECT d.path, d.title, d.mtime, d.size," " bm25(fts) AS rank," " snippet(fts, 2, '', '', ' ... ', 32) AS snippet" " FROM fts JOIN docs d ON d.path=fts.path" " WHERE fts MATCH ? ORDER BY rank LIMIT ? OFFSET ?", (match, limit, offset)).fetchall() except sqlite3.OperationalError: return 0, [] return total, [{"path": r["path"], "title": r["title"], "mtime": r["mtime"], "size": r["size"], "rank": r["rank"], "snippet": r["snippet"]} for r in rows] def stats(self): with self._lock: row = self.db.execute( "SELECT COUNT(*) AS docs, COALESCE(SUM(size),0) AS bytes," " MAX(indexed_at) AS indexed_at FROM docs").fetchone() return {"docs": row["docs"], "bytes": row["bytes"], "indexed_at": row["indexed_at"]} def upsert(self, path, title, body, mtime, size): with self._lock: now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) self.db.execute( "INSERT OR REPLACE INTO docs" " (path, title, body, mtime, size, indexed_at)" " VALUES (?, ?, ?, ?, ?, ?)", (path, title, body[:MAX_INDEX_CHARS], mtime, size, now)) def remove(self, path): with self._lock: self.db.execute("DELETE FROM docs WHERE path=?", (path,)) def known(self): with self._lock: return {r["path"]: (r["mtime"], r["size"]) for r in self.db.execute("SELECT path, mtime, size" " FROM docs").fetchall()} def commit(self): with self._lock: self.db.commit() class DocIndexer(object): """Walk + filter + incrementele indexering. Puur, testbaar.""" def __init__(self, engine): self.engine = engine @staticmethod def is_indexable(name, full): base = os.path.basename(name) if base.startswith("."): return False _, ext = os.path.splitext(base) if ext.lower() not in TEXT_EXTS: return False try: if os.path.getsize(full) > MAX_FILE: return False except OSError: return False return True @staticmethod def read_text(full): try: with open(full, "rb") as fh: raw = fh.read(MAX_FILE + 1) except OSError: return None if b"\x00" in raw[:8192]: return None try: return raw.decode("utf-8") except UnicodeDecodeError: try: return raw.decode("latin-1") except UnicodeDecodeError: return None def index_dir(self, root): root = os.path.abspath(root) known = self.engine.known() seen = set() added = updated = skipped = 0 for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if not d.startswith(".")] dirnames.sort() for name in sorted(filenames): full = os.path.join(dirpath, name) rel = os.path.relpath(full, root) if len(seen) >= MAX_FILES: skipped += 1 continue if not self.is_indexable(name, full): skipped += 1 continue try: st = os.stat(full) except OSError: skipped += 1 continue seen.add(rel) prev = known.get(rel) if prev == (st.st_mtime, st.st_size): continue text = self.read_text(full) if text is None: skipped += 1 continue title = os.path.basename(rel) self.engine.upsert(rel, title, text, st.st_mtime, st.st_size) if prev is None: added += 1 else: updated += 1 removed = 0 for rel in known: if rel not in seen: self.engine.remove(rel) removed += 1 self.engine.commit() return {"added": added, "updated": updated, "removed": removed, "skipped": skipped, "total": len(seen)} class SearchHandler(BaseHTTPRequestHandler): server_version = "TaiSearch/1.0" protocol_version = "HTTP/1.1" def log_message(self, fmt, *args): pass def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"): if isinstance(body, str): body = body.encode("utf-8") self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.end_headers() if self.command != "HEAD" and body: self.wfile.write(body) def _json(self, code, obj): self._send(code, json.dumps(obj), "application/json") def do_GET(self): try: parsed = urlparse(self.path) if parsed.path == "/": self._send(200, SEARCH_HTML, "text/html; charset=utf-8") return if parsed.path == "/api/stats": self._json(200, self.server.engine.stats()) return if parsed.path == "/api/search": qs = parse_qs(parsed.query) try: limit = int(qs.get("limit", ["20"])[0]) offset = int(qs.get("offset", ["0"])[0]) except ValueError: limit, offset = 20, 0 total, items = self.server.engine.search( qs.get("q", [""])[0], limit=limit, offset=offset) self._json(200, {"total": total, "limit": limit, "offset": offset, "items": items}) return self._send(404, "not found\n") except BrokenPipeError: pass SEARCH_HTML = """ Zoeken

Zoeken

    """ class SearchServer(socketserver.ThreadingMixIn, HTTPServer): daemon_threads = True allow_reuse_address = True def default_db(): home = os.environ.get("TAI_HOME") or os.path.expanduser("~/.tai") try: os.makedirs(home, exist_ok=True) return os.path.join(home, "fts-example.db") except OSError: return os.path.abspath("./fts-example.db") def cmd_index(args): if not os.path.isdir(args.dir): raise SearchError("geen directory: %s" % args.dir) engine = SearchEngine(args.db) result = DocIndexer(engine).index_dir(args.dir) print("index: %(added)d nieuw, %(updated)d bijgewerkt, " "%(removed)d weg, %(skipped)d overgeslagen " "(totaal %(total)d) -> %(db)s" % dict(result, db=args.db)) def cmd_search(args): if not os.path.exists(args.db): raise SearchError("geen index (%s). Run eerst: index " % args.db) engine = SearchEngine(args.db) total, items = engine.search(args.query, limit=args.limit, offset=args.offset) print("%d resultaat(en) voor %r:" % (total, args.query)) for item in items: clean = item["snippet"].replace("", "").replace("", "") print(" [%.2f] %s\n %s" % (item["rank"], item["path"], clean)) def cmd_serve(args): if not os.path.exists(args.db): raise SearchError("geen index (%s). Run eerst: index " % args.db) engine = SearchEngine(args.db) stats = engine.stats() server = SearchServer((args.host, args.port), SearchHandler) server.engine = engine print("search: http://%s:%d/ (%d docs)" % (args.host, args.port, stats["docs"])) try: server.serve_forever() except KeyboardInterrupt: pass def main(argv=None): parser = argparse.ArgumentParser( description="SQLite FTS5 full-text search over een directory.") parser.add_argument("--db", default=None, help="sqlite index file") sub = parser.add_subparsers(dest="cmd") # 3.6: geen required-kwarg sub.required = True p_index = sub.add_parser("index", help="directory indexeren") p_index.add_argument("dir") p_search = sub.add_parser("search", help="zoeken op CLI") p_search.add_argument("query") p_search.add_argument("--limit", type=int, default=20) p_search.add_argument("--offset", type=int, default=0) p_serve = sub.add_parser("serve", help="HTTP UI + JSON API starten") p_serve.add_argument("--host", default="127.0.0.1") p_serve.add_argument("--port", type=int, default=8083) args = parser.parse_args(argv) if args.db is None: args.db = default_db() try: if args.cmd == "index": cmd_index(args) elif args.cmd == "search": cmd_search(args) elif args.cmd == "serve": cmd_serve(args) except SearchError as exc: parser.error(str(exc)) if __name__ == "__main__": main()