diff --git "a/app.py" "b/app.py"
--- "a/app.py"
+++ "b/app.py"
@@ -1,1732 +1,1580 @@
-import asyncio
+from __future__ import annotations
import gzip
import hashlib
+import heapq
import json
-import posixpath
+import os
import random
import re
+import secrets
+import sqlite3
+import tempfile
+import threading
import time
-import functools
-import io
+import unicodedata
+from functools import lru_cache
+from contextlib import asynccontextmanager, contextmanager
from pathlib import Path
-from typing import Optional
-from urllib.parse import unquote, quote, urljoin, urlparse
-import aiohttp
-import jieba
+from typing import Literal, Optional
+from urllib.parse import parse_qs, quote, unquote
from fastapi import FastAPI, Query, Request
-from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, Response, StreamingResponse
-from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response
+from fastapi.staticfiles import StaticFiles
+from starlette.background import BackgroundTask
from pydantic import BaseModel, Field
-from contextlib import asynccontextmanager
-ALLOWED_DOWNLOAD_HOSTS = {
- "huggingface.co",
- "hf-mirror.com",
- "hf.co",
-}
-ALLOWED_DOWNLOAD_HOST_SUFFIXES = (
- ".hf.co",
- ".huggingface.co",
- ".xethub.hf.co",
-)
-MAX_REDIRECTS = 5
-MAX_ARCHIVE_NAME_LENGTH = 240
-DATA_PATH = Path("data/search_data.json.gz")
-NGRAM_PATHS = {2: Path("data/search_ngrams_2.bin.gz"), 3: Path("data/search_ngrams_3.bin.gz")}
-WORD_INDEX_PATH = Path("data/search_words.bin.gz")
-NGRAM_MAGIC = b"VNG3"
-WORD_INDEX_MAGIC = b"VWI2"
-WORD_INDEX_TOKENIZER = "jieba-0.42.1-cjk-char-v1"
-FOLDER_TREE_PATH = Path("data/folder_tree.json.gz")
-FOLDER_BROWSER_PATH = Path("data/folder_browser.json.gz")
-TXT_DIR = Path("txt")
-TXT_SPACE_RAW_BASE = "https://huggingface.co/spaces/VoiceOfML/Search/raw/main/txt"
-TXT_WARM_INTERVAL_SECONDS = 600
-TXT_WARM_IDLE_SECONDS = 600
-READER_ASSETS_URL = "https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/reader_assets.json.gz"
-READER_ASSETS_CACHE_TTL_SECONDS = 300
-READER_ASSETS_MAX_COMPRESSED_BYTES = 5 * 1024 * 1024
-READER_ASSETS_MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024
+BASE_DIR = Path(__file__).resolve().parent
+DATA_PATH = BASE_DIR / "data/search_data.json.gz"
+FOLDER_TREE_PATH = BASE_DIR / "data/folder_tree.json.gz"
+FOLDER_BROWSER_PATH = BASE_DIR / "data/folder_browser.json.gz"
+FULLTEXT_DIR = BASE_DIR / "data/fulltext"
+INDEX_BUILD_STATUS_PATH = FULLTEXT_DIR / "build-status.json"
+BUCKET_INDEX_STATUS_PATH = FULLTEXT_DIR / "bucket-index-status.json"
records: list[dict] = []
+name_order: list[int] = []
+size_order: list[int] = []
+source_name_order: dict[str, list[int]] = {}
+source_size_order: dict[str, list[int]] = {}
+record_map: dict[str, dict] = {}
+record_map_index: dict[str, int] = {}
+sources: list[dict] = []
+source_counts: dict[str, int] = {}
folder_tree_data: dict[str, list[dict]] = {}
folder_browser_data: dict[str, dict[str, dict]] = {}
-word_index = {}
-folder_index = {}
-extension_counts = {}
-repo_counts = {}
-repo_list = []
-extension_list = []
-repo_extension_counts = {}
-vocab_by_len = {}
-repo_records_map = {}
-sorted_by_name = []
-sorted_by_size = []
-repo_sorted_by_name = {}
-repo_sorted_by_size = {}
-txt_record_indices = []
-repo_txt_record_indices = {}
-READER_EXTENSIONS = frozenset({"pdf", "epub", "txt", "md", "markdown", "jpg", "jpeg", "png", "gif", "bmp", "webp"})
-reader_record_indices = []
-repo_reader_record_indices = {}
-reader_record_key_indices = {}
-initial_payload_global = None
-initial_payload_by_repo = {}
+source_records_map: dict[str, list[int]] = {}
+extension_counts: dict[str, int] = {}
+source_extension_counts: dict[str, dict[str, int]] = {}
+word_index: dict[str, set[int]] = {}
+latin_all_index: dict[str, set[int]] = {}
+latin_file_index: dict[str, set[int]] = {}
+latin_vocabulary_all: set[str] = set()
+TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+")
+LATIN_RE = re.compile(r"[a-z0-9]+")
+TOKENIZER_VERSION = "cjk-bigram-boundary-fts5-v6-snippet-anchors"
+LITERAL_PREFIX = "\0literal:"
API_CACHE_TTL_SECONDS = 120
-api_response_cache = {}
-_data_generation = 0
-SEARCH_CACHE_TTL_SECONDS = 120
-_search_cache: dict[tuple, tuple[float, dict]] = {}
-METADATA_CACHE_HEADERS = {
- "Cache-Control": "public, max-age=300, stale-while-revalidate=1800",
-}
-ngram_postings: dict[int, dict[str, bytes]] = {}
-ngram_posting_buffers: dict[int, bytes] = {}
-injected_html_cache: dict[str | None, str] = {}
-last_user_request_at = time.monotonic()
-reader_assets_cache = {"v": 1, "f": {}}
-reader_assets_cache_at = 0.0
-
-def cached_payload(key, builder):
- now = time.monotonic()
- cached = api_response_cache.get(key)
- if cached and now - cached[0] < API_CACHE_TTL_SECONDS:
- return cached[1]
- value = builder()
- api_response_cache[key] = (now, value)
- while len(api_response_cache) > 500:
- oldest_key = min(api_response_cache, key=lambda item: api_response_cache[item][0])
- api_response_cache.pop(oldest_key, None)
- return value
-
-
-def decode_reader_assets(raw: bytes) -> dict:
- if len(raw) > READER_ASSETS_MAX_COMPRESSED_BYTES:
- raise ValueError("reader assets sidecar is too large")
- with gzip.GzipFile(fileobj=io.BytesIO(raw)) as stream:
- decoded = stream.read(READER_ASSETS_MAX_DECOMPRESSED_BYTES + 1)
- if len(decoded) > READER_ASSETS_MAX_DECOMPRESSED_BYTES:
- raise ValueError("reader assets sidecar expands beyond limit")
- payload = json.loads(decoded)
- files = payload.get("f") if isinstance(payload, dict) and payload.get("v") == 1 else None
- if not isinstance(files, dict):
- raise ValueError("invalid reader assets sidecar")
- clean = {}
- for key, entry in files.items():
- if not isinstance(key, str) or not isinstance(entry, dict) or entry.get("s") not in (2, 4):
- continue
- if entry.get("s") == 2:
- path = entry.get("p")
- mode = entry.get("m")
- if mode not in ("p", "e", "d", "h") or not isinstance(path, str) or not re.fullmatch(r"objects/[0-9a-f]{2}/[0-9a-f]{64}/(?:[a-z0-9-]+/)?(?:document\.pdf|book\.epub|document\.docx|document\.html)", path):
- continue
- clean[key] = {"s": 2, "m": mode, "p": path}
+SEARCH_CACHE_TTL_SECONDS = 300
+API_CACHE_MAX_ENTRIES = 500
+SEARCH_CACHE_MAX_ENTRIES = 500
+PREVIEW_CACHE_MAX_ENTRIES = 64
+PREVIEW_CACHE_MAX_ENTRY_BYTES = 2 * 1024 * 1024
+api_response_cache: dict[tuple, tuple[float, object]] = {}
+api_response_cache_lock = threading.Lock()
+search_response_cache: dict[tuple, tuple[float, object]] = {}
+search_response_cache_lock = threading.Lock()
+preview_text_cache: dict[str, str] = {}
+preview_text_cache_lock = threading.Lock()
+cache_key_locks: dict[tuple, threading.Lock] = {}
+cache_key_locks_guard = threading.Lock()
+ZIP_TOKEN_TTL_SECONDS = 600
+ZIP_TOKEN_MAX_ENTRIES = 32
+zip_download_tokens: dict[str, tuple[float, list[str]]] = {}
+zip_download_lock = threading.Lock()
+FULLTEXT_IN_MEMORY = os.environ.get("CCRD_FULLTEXT_IN_MEMORY", "1").strip().lower() in {"1", "true", "yes", "on"}
+FULLTEXT_REFRESH_INTERVAL_SECONDS = max(1, int(os.environ.get("CCRD_FULLTEXT_REFRESH_INTERVAL_SECONDS", "5")))
+fulltext_refresh_stop = threading.Event()
+
+def normalize_text(text: str) -> str:
+ return unicodedata.normalize("NFKC", text or "").casefold()
+
+def query_terms(text: str) -> list[str]:
+ return list(dict.fromkeys(TOKEN_RE.findall(normalize_text(text))))
+
+def index_tokens(text: str) -> set[str]:
+ tokens = set()
+ for part in query_terms(text):
+ if re.fullmatch(r"[\u4e00-\u9fff\u3400-\u4dbf]+", part):
+ tokens.update(part)
+ tokens.update(part[index:index + 2] for index in range(len(part) - 1))
else:
- clean[key] = {"s": 4}
- return {"v": 1, "f": clean}
+ tokens.add(part)
+ return tokens
+@lru_cache(maxsize=4096)
+def query_tokens(text: str) -> list[str]:
+ tokens = []
+ for part in query_terms(text):
+ if re.fullmatch(r"[\u4e00-\u9fff\u3400-\u4dbf]+", part) and len(part) > 1:
+ tokens.extend(part[index:index + 2] for index in range(len(part) - 1))
+ else:
+ tokens.append(part)
+ return list(dict.fromkeys(tokens))
+
+@lru_cache(maxsize=4096)
+def literal_query_tokens(text: str) -> list[str]:
+ normalized = normalize_text(text)
+ return list(dict.fromkeys(
+ LITERAL_PREFIX + normalized[index:index + 3]
+ for index in range(len(normalized) - 2)
+ if not TOKEN_RE.fullmatch(normalized[index + 1])
+ ))
-async def get_reader_assets() -> dict:
- global reader_assets_cache, reader_assets_cache_at
- now = time.monotonic()
- if now - reader_assets_cache_at < READER_ASSETS_CACHE_TTL_SECONDS:
- return reader_assets_cache
- async with app.state.reader_assets_lock:
- now = time.monotonic()
- if now - reader_assets_cache_at < READER_ASSETS_CACHE_TTL_SECONDS:
- return reader_assets_cache
- response = None
+@lru_cache(maxsize=4096)
+def wildcard_required_tokens(text: str) -> list[str]:
+ tokens = []
+ for fixed_part in re.split(r"[*?]+", normalize_text(text)):
+ for term in TOKEN_RE.findall(fixed_part):
+ if re.fullmatch(r"[\u4e00-\u9fff\u3400-\u4dbf]+", term):
+ tokens.extend(query_tokens(term))
+ return list(dict.fromkeys(tokens))
+
+def decode_doc_numbers(payload: bytes, candidates: set[int] | None = None) -> set[int]:
+ results = set()
+ number = shift = previous = 0
+ for byte in payload:
+ number |= (byte & 0x7F) << shift
+ if byte & 0x80:
+ shift += 7
+ continue
+ previous += number
+ if candidates is None or previous in candidates:
+ results.add(previous)
+ number = shift = 0
+ return results
+
+class FulltextDatabases:
+
+ def __init__(self, directory: Path, in_memory: bool = False, refresh: bool = True):
+ self.directory = directory
+ self.in_memory = in_memory
+ self.lock = threading.RLock()
+ self.refresh_lock = threading.Lock()
+ self.connections = {}
+ self.has_doc_counts = {}
+ self.tokenizer_versions = {}
+ self.has_content_fts = {}
+ self.has_snippet_anchors = {}
+ self.document_counts = {}
+ self.fts_counts = {}
+ self.file_signatures = {}
+ self.connection_readers = {}
+ self.retired_connections = set()
+ self.closed = False
+ self.generation = 0
+ if refresh:
+ self.refresh()
+
+ def refresh(self) -> None:
+ with self.refresh_lock:
+ for path in self.directory.glob("*.sqlite3"):
+ try:
+ stat = path.stat()
+ signature = (stat.st_ino, stat.st_size, stat.st_mtime_ns)
+ except OSError:
+ continue
+ with self.lock:
+ if self.closed or self.file_signatures.get(path.stem) == signature:
+ continue
+ try:
+ candidate = self._open_candidate(path)
+ except (OSError, sqlite3.Error) as exc:
+ print(f"fulltext_open_failed={path.name}:{exc}")
+ continue
+ try:
+ current_stat = path.stat()
+ current_signature = (current_stat.st_ino, current_stat.st_size, current_stat.st_mtime_ns)
+ except OSError:
+ candidate["connection"].close()
+ continue
+ if current_signature != signature:
+ candidate["connection"].close()
+ continue
+ with self.lock:
+ if self.closed or self.file_signatures.get(path.stem) == signature:
+ candidate["connection"].close()
+ continue
+ self._install_candidate(path.stem, signature, candidate)
+
+ def _open_candidate(self, path: Path) -> dict:
+ uri = f"file:{path.resolve()}?mode=ro&immutable=1"
+ source_connection = sqlite3.connect(uri, uri=True, check_same_thread=False)
+ connection = source_connection
try:
- response = await app.state.http_session.get(
- READER_ASSETS_URL,
- timeout=aiohttp.ClientTimeout(total=20, connect=8, sock_read=10),
- )
- if response.status != 200:
- raise RuntimeError(f"reader assets HTTP {response.status}")
- chunks = bytearray()
- async for chunk in response.content.iter_chunked(64 * 1024):
- chunks.extend(chunk)
- if len(chunks) > READER_ASSETS_MAX_COMPRESSED_BYTES:
- raise ValueError("reader assets response is too large")
- raw = bytes(chunks)
- reader_assets_cache = decode_reader_assets(raw)
- reader_assets_cache_at = now
- except Exception as exc:
- print(f"reader assets refresh failed: {exc}")
- reader_assets_cache_at = now
+ columns = {row[1] for row in source_connection.execute("PRAGMA table_info(postings)")}
+ has_metadata = source_connection.execute(
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'metadata'"
+ ).fetchone()
+ version_row = source_connection.execute(
+ "SELECT value FROM metadata WHERE key = 'tokenizer_version'"
+ ).fetchone() if has_metadata else None
+ has_content_fts = bool(source_connection.execute(
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'content_fts'"
+ ).fetchone())
+ anchor_columns = {
+ row[1] for row in source_connection.execute("PRAGMA table_info(snippet_anchors)")
+ }
+ has_snippet_anchors = {"token_hash", "doc_number", "char_offset"} <= anchor_columns
+ document_count = int(source_connection.execute("SELECT COUNT(*) FROM documents").fetchone()[0])
+ fts_count = int(source_connection.execute("SELECT COUNT(*) FROM content_fts").fetchone()[0]) if has_content_fts else 0
+ if self.in_memory:
+ connection = sqlite3.connect(":memory:", check_same_thread=False)
+ source_connection.backup(connection)
+ except Exception:
+ if connection is not source_connection:
+ connection.close()
+ source_connection.close()
+ raise
finally:
- if response is not None:
- response.release()
- return reader_assets_cache
-
-def decode_varint(data: bytes, offset: int) -> tuple[int, int]:
- value = 0
- shift = 0
- while offset < len(data):
- byte = data[offset]
- offset += 1
- value |= (byte & 0x7F) << shift
- if not byte & 0x80:
- return value, offset
- shift += 7
- raise ValueError("truncated varint")
-
-def records_digest() -> bytes:
- digest = hashlib.sha256()
- for record in records:
- for value in (
- record.get("_file_lower", ""),
- record.get("_repo_lower", ""),
- record.get("_folder_path_lower", ""),
- ):
- digest.update(value.encode("utf-8"))
- digest.update(b"\0")
- return digest.digest()
-
-
-def load_ngram_index(path: Path, width: int, record_count: int, expected_digest: bytes) -> tuple[dict, bytes]:
- try:
- data = gzip.decompress(path.read_bytes())
- if not data.startswith(NGRAM_MAGIC) or len(data) < 37 or data[4] != width:
- return {}, b""
- if data[5:37] != expected_digest:
- return {}, b""
- offset = 37
- stored_record_count, offset = decode_varint(data, offset)
- count, offset = decode_varint(data, offset)
- if stored_record_count != record_count:
- return {}, b""
- postings = {}
- for _ in range(count):
- key_length, offset = decode_varint(data, offset)
- key = data[offset:offset + key_length].decode("utf-8")
- offset += key_length
- posting_count, offset = decode_varint(data, offset)
- posting_start = offset
- posting_length, offset = decode_varint(data, offset)
- posting_start = offset
- offset += posting_length
- if offset > len(data):
- raise ValueError("truncated postings")
- postings[key] = (posting_start, offset, posting_count)
- if offset != len(data):
- raise ValueError("trailing postings")
- return postings, data
- except (OSError, ValueError, UnicodeDecodeError, EOFError):
- return {}, b""
-
-
-def load_word_index(path: Path, record_count: int, expected_digest: bytes) -> dict:
- try:
- data = gzip.decompress(path.read_bytes())
- if not data.startswith(WORD_INDEX_MAGIC):
- return {}
- offset = len(WORD_INDEX_MAGIC)
- tokenizer_length, offset = decode_varint(data, offset)
- tokenizer = data[offset:offset + tokenizer_length].decode("utf-8")
- offset += tokenizer_length
- if tokenizer != WORD_INDEX_TOKENIZER or data[offset:offset + 32] != expected_digest:
- return {}
- offset += 32
- stored_record_count, offset = decode_varint(data, offset)
- count, offset = decode_varint(data, offset)
- if stored_record_count != record_count:
- return {}
- postings = {}
- for _ in range(count):
- key_length, offset = decode_varint(data, offset)
- key = data[offset:offset + key_length].decode("utf-8")
- offset += key_length
- posting_count, offset = decode_varint(data, offset)
- posting_length, offset = decode_varint(data, offset)
- posting_start = offset
- offset += posting_length
- if offset > len(data):
- raise ValueError("truncated postings")
- decoded = decode_postings(memoryview(data)[posting_start:offset])
- if len(decoded) != posting_count:
- raise ValueError("posting count mismatch")
- postings[key] = decoded
- if offset != len(data):
- raise ValueError("trailing postings")
- return postings
- except (OSError, ValueError, UnicodeDecodeError, EOFError):
- return {}
-
-def decode_postings(data: bytes) -> set[int]:
- record_ids = set()
- offset = 0
- previous = 0
- while offset < len(data):
- delta, offset = decode_varint(data, offset)
- previous += delta
- record_ids.add(previous)
- return record_ids
-
-
-def resolve_postings(value, buffer: bytes) -> set[int]:
- if isinstance(value, tuple):
- start, end, _ = value
- return decode_postings(memoryview(buffer)[start:end])
- return set(value) if isinstance(value, (set, frozenset)) else decode_postings(value)
-
-
-def word_postings(token: str) -> set[int]:
- value = word_index.get(token)
- return set(value) if value is not None else set()
-
-
-def word_posting_count(token: str) -> int:
- value = word_index.get(token)
- return len(value) if value is not None else 0
-
-def literal_candidates(query: str, repos=None) -> set[int] | None:
- width = 3 if len(query) >= 3 and ngram_postings.get(3) else 2
- postings = ngram_postings.get(width)
- if not postings or len(query) < width:
- return None
- candidates = None
- for index in range(len(query) - width + 1):
- encoded = postings.get(query[index:index + width])
- if encoded is None:
+ if source_connection is not connection:
+ source_connection.close()
+ return {
+ "connection": connection,
+ "has_doc_counts": "doc_count" in columns,
+ "tokenizer_version": version_row[0] if version_row else "legacy",
+ "has_content_fts": has_content_fts,
+ "has_snippet_anchors": has_snippet_anchors,
+ "document_count": document_count,
+ "fts_count": fts_count,
+ }
+
+ def _install_candidate(self, source: str, signature: tuple[int, int, int], candidate: dict) -> None:
+ connection = candidate["connection"]
+ old_connection = self.connections.get(source)
+ self.connections[source] = connection
+ self.has_doc_counts[source] = candidate["has_doc_counts"]
+ self.tokenizer_versions[source] = candidate["tokenizer_version"]
+ self.has_content_fts[source] = candidate["has_content_fts"]
+ self.has_snippet_anchors[source] = candidate["has_snippet_anchors"]
+ self.document_counts[source] = candidate["document_count"]
+ self.fts_counts[source] = candidate["fts_count"]
+ self.file_signatures[source] = signature
+ self.generation += 1
+ if old_connection is not None:
+ if self.connection_readers.get(old_connection, 0):
+ self.retired_connections.add(old_connection)
+ else:
+ old_connection.close()
+
+ @contextmanager
+ def _lease_source(self, source: str):
+ with self.lock:
+ connection = self.connections.get(source)
+ if connection is None or self.closed:
+ lease = None
+ else:
+ self.connection_readers[connection] = self.connection_readers.get(connection, 0) + 1
+ lease = (connection, {
+ "has_doc_counts": self.has_doc_counts.get(source, False),
+ "tokenizer_version": self.tokenizer_versions.get(source),
+ "has_content_fts": self.has_content_fts.get(source, False),
+ "has_snippet_anchors": self.has_snippet_anchors.get(source, False),
+ })
+ try:
+ yield lease
+ finally:
+ if lease is None:
+ return
+ with self.lock:
+ remaining = self.connection_readers.get(connection, 1) - 1
+ if remaining:
+ self.connection_readers[connection] = remaining
+ else:
+ self.connection_readers.pop(connection, None)
+ if connection in self.retired_connections:
+ self.retired_connections.remove(connection)
+ connection.close()
+
+ def close(self) -> None:
+ with self.refresh_lock:
+ with self.lock:
+ self.closed = True
+ connections = set(self.connections.values()) | self.retired_connections
+ self.connections.clear()
+ self.retired_connections = {
+ connection for connection in connections
+ if self.connection_readers.get(connection, 0)
+ }
+ for connection in connections - self.retired_connections:
+ connection.close()
+
+ def search_source(self, source: str, query: str, exact: bool = False) -> set[int]:
+ with self.lock:
+ missing = source not in self.connections
+ if missing:
+ self.refresh()
+ with self._lease_source(source) as lease:
+ if lease is None:
+ return set()
+ connection, state = lease
+ return self._search_source_connection(connection, query, exact, state)
+
+ def _search_source_connection(self, connection: sqlite3.Connection, query: str, exact: bool, state: dict) -> set[int]:
+ version = state["tokenizer_version"]
+ has_content_fts = state["has_content_fts"]
+ has_doc_counts = state["has_doc_counts"]
+ if exact and has_content_fts:
+ normalized_query = normalize_text(query)
+ try:
+ if has_wildcard_query(normalized_query):
+ required_tokens = wildcard_required_tokens(normalized_query) if version == TOKENIZER_VERSION else []
+ candidates = self._search_postings(connection, has_doc_counts, required_tokens) if required_tokens else None
+ fixed_parts = [part for part in re.split(r"[*?]+", normalized_query) if len(part) >= 3]
+ if fixed_parts:
+ match_query = " AND ".join(f'"{part.replace(chr(34), chr(34) * 2)}"' for part in fixed_parts)
+ fts_candidates = {
+ int(row[0]) for row in connection.execute(
+ "SELECT rowid FROM content_fts WHERE content_fts MATCH ?", (match_query,)
+ )
+ }
+ candidates = fts_candidates if candidates is None else candidates & fts_candidates
+ elif candidates is None:
+ candidates = {
+ int(row[0]) for row in connection.execute("SELECT rowid FROM content_fts")
+ }
+ return self.verify_wildcard_content(connection, candidates, query, fts_filtered=True)
+ if len(normalized_query) >= 3:
+ phrase = normalized_query.replace('"', '""')
+ rows = connection.execute(
+ "SELECT rowid FROM content_fts WHERE content_fts MATCH ?", (f'"{phrase}"',)
+ )
+ return {int(row[0]) for row in rows}
+ tokens = query_tokens(query) if version == TOKENIZER_VERSION else query_terms(query)
+ if version == TOKENIZER_VERSION:
+ tokens = [*tokens, *literal_query_tokens(query)]
+ # Short non-CJK literals (Latin substrings or punctuation-separated
+ # characters) are not provable from whole-token/unigram postings, so
+ # verify them against the normalized FTS content directly.
+ if not tokens or not re.fullmatch(r"[\u4e00-\u9fff\u3400-\u4dbf]+", normalized_query):
+ rows = connection.execute(
+ "SELECT rowid FROM content_fts WHERE instr(content, ?) > 0", (normalized_query,)
+ )
+ return {int(row[0]) for row in rows}
+ candidates = self._search_postings(connection, has_doc_counts, tokens)
+ return candidates
+ except sqlite3.OperationalError:
+ pass
+ if exact and version == TOKENIZER_VERSION and "*" not in query and "?" not in query:
+ tokens = [*query_tokens(query), *literal_query_tokens(query)]
+ else:
+ tokens = query_tokens(query) if version == TOKENIZER_VERSION else query_terms(query)
+ if not tokens:
return set()
- matches = resolve_postings(encoded, ngram_posting_buffers.get(width, b""))
- candidates = matches if candidates is None else candidates & matches
+ matched = None
+ token_hashes = [hashlib.sha256(token.encode("utf-8")).digest()[:16] for token in tokens]
+ if has_doc_counts:
+ placeholders = ",".join("?" for _ in token_hashes)
+ rows = connection.execute(
+ f"SELECT token_hash, docs, doc_count FROM postings WHERE token_hash IN ({placeholders})",
+ token_hashes,
+ ).fetchall()
+ if len(rows) != len(token_hashes):
+ return set()
+ posting_rows = [(docs, doc_count) for _token_hash, docs, doc_count in rows]
+ posting_rows.sort(key=lambda item: item[1])
+ else:
+ posting_rows = []
+ for token_hash in token_hashes:
+ row = connection.execute("SELECT docs FROM postings WHERE token_hash = ?", (token_hash,)).fetchone()
+ if row is None:
+ return set()
+ posting_rows.append((row[0], None))
+ for docs, _doc_count in posting_rows:
+ token_matches = decode_doc_numbers(docs, matched)
+ matched = token_matches if matched is None else matched & token_matches
+ if not matched:
+ return set()
+ result = matched or set()
+ if exact and has_content_fts and has_wildcard_query(query):
+ result = self.verify_wildcard_content(connection, result, query)
+ return result
+
+ def _search_postings(self, connection: sqlite3.Connection, has_doc_counts: bool, tokens: list[str]) -> set[int]:
+ if not tokens:
+ return set()
+ matched = None
+ token_hashes = [hashlib.sha256(token.encode("utf-8")).digest()[:16] for token in tokens]
+ if has_doc_counts:
+ placeholders = ",".join("?" for _ in token_hashes)
+ rows = connection.execute(
+ f"SELECT token_hash, docs, doc_count FROM postings WHERE token_hash IN ({placeholders})",
+ token_hashes,
+ ).fetchall()
+ if len(rows) != len(token_hashes):
+ return set()
+ posting_rows = [(docs, doc_count) for _token_hash, docs, doc_count in rows]
+ posting_rows.sort(key=lambda item: item[1])
+ else:
+ posting_rows = []
+ for token_hash in token_hashes:
+ row = connection.execute("SELECT docs FROM postings WHERE token_hash = ?", (token_hash,)).fetchone()
+ if row is None:
+ return set()
+ posting_rows.append((row[0], None))
+ for docs, _doc_count in posting_rows:
+ token_matches = decode_doc_numbers(docs, matched)
+ matched = token_matches if matched is None else matched & token_matches
+ if not matched:
+ return set()
+ return matched or set()
+
+ def verify_wildcard_content(
+ self,
+ connection: sqlite3.Connection,
+ candidates: set[int],
+ query: str,
+ fts_filtered: bool = False,
+ ) -> set[int]:
if not candidates:
return set()
- if repos:
- allowed = set()
- for repo in repos:
- allowed.update(repo_records_map.get(repo, []))
- candidates &= allowed
- return candidates or set()
-INITIAL_PAGE_SIZE = 100
-SIDEBAR_DATA_PATH = Path("data/sidebar")
-RECORD_KEY_MAP = {
- "r": "Repo",
- "f": "File",
- "e": "Extension",
- "d": "Folder",
- "s": "Size",
- "t": "HasTxt",
-}
-TREE_KEY_MAP = {
- "n": "name",
- "c": "count",
- "df": "hasDirectFiles",
- "ch": "children",
-}
-BROWSER_ENTRY_KEY_MAP = {
- "d": "folders",
- "f": "files",
-}
-BROWSER_FOLDER_ITEM_KEY_MAP = {
- "n": "name",
- "c": "count",
-}
-BROWSER_FILE_ITEM_KEY_MAP = {
- "n": "name",
- "e": "ext",
- "t": "hasTxt",
- "s": "size",
-}
-
-def build_record_relative_path(rec: dict) -> str:
- file_name = rec.get("File", "")
- extension = rec.get("Extension", "")
- full_name = f"{file_name}.{extension}" if extension else file_name
- folders = rec.get("Folder", []) or []
- return posixpath.join(*folders, full_name) if folders else full_name
-
-
-def build_reader_asset_key(rec: dict) -> str:
- return f"{rec.get('Repo', '')}\0{build_record_relative_path(rec)}"
-
-def build_record_link(rec: dict) -> str:
- repo = rec.get("Repo", "")
- rel_path = build_record_relative_path(rec)
- return f"https://huggingface.co/datasets/{repo}/resolve/main/{quote(rel_path, safe='/')}"
-
-def build_record_path_url(rec: dict) -> str:
- repo = rec.get("Repo", "")
- rel_path = build_record_relative_path(rec)
- return f"https://huggingface.co/datasets/{repo}/blob/main/{quote(rel_path, safe='/')}"
-
-def build_txt_relative_path(rec: dict) -> str:
- rel_path = build_record_relative_path(rec)
- if "." in rel_path.rsplit("/", 1)[-1]:
- rel_path = rel_path.rsplit(".", 1)[0]
- return f"{rel_path}.txt"
-
-def is_allowed_download_host(hostname: str | None) -> bool:
- if not hostname:
- return False
- return hostname in ALLOWED_DOWNLOAD_HOSTS or hostname.endswith(ALLOWED_DOWNLOAD_HOST_SUFFIXES)
-
-def validate_download_url(url: str) -> str:
- parsed = urlparse(url)
- if (
- parsed.scheme != "https"
- or not is_allowed_download_host(parsed.hostname)
- or parsed.port not in (None, 443)
- or parsed.username is not None
- or parsed.password is not None
- ):
- raise ValueError(f"不允许的下载跳转地址: {parsed.hostname or 'unknown'}")
- return url
-
-def normalize_download_url(url: str) -> str:
- parsed = urlparse(url)
- if parsed.hostname == "hf-mirror.com":
- return parsed._replace(netloc="huggingface.co").geturl()
- return url
-
-def validate_voiceofml_source_url(url: str) -> str:
- url = normalize_download_url(url)
- validate_download_url(url)
- parsed = urlparse(url)
- if parsed.hostname != "huggingface.co" or not parsed.path.startswith("/datasets/VoiceOfML/"):
- raise ValueError("只允许 VoiceOfML 数据集文件")
- return url
-
-
-READER_ASSET_SOURCE_RE = re.compile(
- r"^/datasets/vomebook/Reader-Assets/resolve/main/objects/[0-9a-f]{2}/[0-9a-f]{64}/(?:[a-z0-9-]+/)?(?:document\.pdf|book\.epub|document\.docx|document\.html)$"
-)
-VOICEOFML_READER_SOURCE_RE = re.compile(r"^/datasets/VoiceOfML/[A-Za-z0-9._-]+/(?:resolve|raw)/main/.+$")
-
-
-def validate_reader_source_url(url: str) -> str:
- url = normalize_download_url(url)
- validate_download_url(url)
- parsed = urlparse(url)
- if parsed.hostname != "huggingface.co" or parsed.fragment or parsed.query:
- raise ValueError("不允许的阅读来源")
- decoded_path = unquote(parsed.path)
- if "\\" in decoded_path or "\x00" in decoded_path or any(part in (".", "..") for part in decoded_path.split("/")):
- raise ValueError("不允许的阅读来源路径")
- if VOICEOFML_READER_SOURCE_RE.fullmatch(decoded_path) or READER_ASSET_SOURCE_RE.fullmatch(decoded_path):
- return url
- raise ValueError("不允许的阅读来源")
-
-
-async def open_download_response(
- session,
- url: str,
- timeout: aiohttp.ClientTimeout,
- *,
- method: str = "GET",
- request_headers: Optional[dict[str, str]] = None,
-):
- method = method.upper()
- if method not in ("GET", "HEAD"):
- raise ValueError("不支持的上游请求方法")
- current_url = validate_download_url(url)
- headers = {"Accept-Encoding": "identity"}
- headers.update(request_headers or {})
- for _ in range(MAX_REDIRECTS + 1):
- request_method = getattr(session, "request", None)
- if request_method is not None:
- response = await request_method(
- method,
- current_url,
- allow_redirects=False,
- timeout=timeout,
- headers=headers,
+ normalized_query = normalize_text(query)
+ fixed_parts = [part for part in re.split(r"[*?]+", normalized_query) if len(part) >= 3]
+ if fixed_parts and not fts_filtered:
+ match_query = " AND ".join(f'"{part.replace(chr(34), chr(34) * 2)}"' for part in fixed_parts)
+ try:
+ fts_candidates = {
+ int(row[0])
+ for row in connection.execute(
+ "SELECT rowid FROM content_fts WHERE content_fts MATCH ?",
+ (match_query,),
+ )
+ }
+ candidates &= fts_candidates
+ except sqlite3.OperationalError:
+ pass
+ glob_pattern = "*" + normalized_query.replace("[", "[[]") + "*"
+ matched: set[int] = set()
+ ordered = sorted(candidates)
+ for offset in range(0, len(ordered), 500):
+ batch = ordered[offset:offset + 500]
+ placeholders = ",".join("?" for _ in batch)
+ rows = connection.execute(
+ f"SELECT rowid FROM content_fts WHERE rowid IN ({placeholders}) AND (content GLOB ?) = 1",
+ [*batch, glob_pattern],
)
+ matched.update(int(row[0]) for row in rows)
+ return matched
+
+ def summaries(self, doc_ids: list[str]) -> dict[str, str]:
+ grouped = {}
+ for doc_id in doc_ids:
+ grouped.setdefault(doc_id.split(":", 1)[0], []).append(doc_id)
+ with self.lock:
+ missing = any(source not in self.connections for source in grouped)
+ if missing:
+ self.refresh()
+ summaries = {}
+ for source, source_doc_ids in grouped.items():
+ with self._lease_source(source) as lease:
+ if lease is None:
+ continue
+ connection, _state = lease
+ for offset in range(0, len(source_doc_ids), 500):
+ batch = source_doc_ids[offset:offset + 500]
+ rows = connection.execute(
+ f"SELECT doc_id, summary FROM documents WHERE doc_id IN ({','.join('?' for _ in batch)})",
+ batch,
+ )
+ summaries.update(rows)
+ return summaries
+
+ def matched_snippets(self, doc_ids: list[str], query: str, exact: bool = False, timings=None) -> dict[str, dict]:
+ normalized_query = normalize_text(query.strip())
+ # A fixed wildcard fragment can occur long before the actual match.
+ # Let the original-text snippet endpoint locate the verified span.
+ if exact and has_wildcard_query(normalized_query):
+ return {}
+ if exact:
+ needle = normalized_query
else:
- response = await session.get(
- current_url,
- allow_redirects=False,
- timeout=timeout,
- headers=headers,
- )
- if response.status not in (301, 302, 303, 307, 308):
- return response
- location = response.headers.get("Location")
- response.release()
- if not location:
- raise ValueError("上游重定向缺少地址")
- current_url = validate_download_url(urljoin(current_url, location))
- raise ValueError("上游重定向次数过多")
-
-def sanitize_archive_name(value) -> str:
- name = str(value or "file").replace("\\", "/").rsplit("/", 1)[-1]
- name = "".join(ch for ch in name if ord(ch) >= 32 and ch != "\x7f").strip()
- if not name or name in (".", ".."):
- name = "file"
- return name[:MAX_ARCHIVE_NAME_LENGTH]
-
-def build_browser_file_name(file_item: dict) -> str:
- name = str(file_item.get("name", "") or "file")
- ext = str(file_item.get("ext", "") or "")
- if not ext:
- return name
- if name.lower().endswith(f".{ext.lower()}"):
- return name
- return f"{name}.{ext}"
-
-def enrich_browser_entry(repo: str, path: str, entry: dict) -> dict:
- decoded = dict(entry or {})
- decoded["current_path"] = decoded.get("current_path", path)
- folders = []
- for folder in decoded.get("folders", []) or []:
- item = dict(folder)
- if "path" not in item:
- name = item.get("name", "")
- item["path"] = f"{path}/{name}" if path else name
- folders.append(item)
- files = []
- for file_item in decoded.get("files", []) or []:
- item = dict(file_item)
- full_name = build_browser_file_name(item)
- rel_path = f"{path}/{full_name}" if path else full_name
- item["link"] = item.get("link") or f"https://huggingface.co/datasets/{repo}/resolve/main/{quote(rel_path, safe='/')}"
- item["path"] = item.get("path") or f"https://huggingface.co/datasets/{repo}/blob/main/{quote(rel_path, safe='/')}"
- files.append(item)
- decoded["folders"] = folders
- decoded["files"] = files
- return decoded
-
-def decode_search_payload(data) -> list[dict]:
- if not isinstance(data, dict):
- return []
- repos = data.get("rp", []) or []
- folders = data.get("fd", []) or []
- records = []
- for item in data.get("rc", []) or []:
- if not isinstance(item, list) or len(item) < 6:
- continue
- repo = repos[item[0]] if isinstance(item[0], int) and 0 <= item[0] < len(repos) else ""
- folder = folders[item[3]] if isinstance(item[3], int) and 0 <= item[3] < len(folders) else []
- records.append({
- "Repo": repo,
- "File": item[1],
- "Extension": item[2],
- "Folder": folder,
- "Size": item[4],
- "HasTxt": bool(item[5]),
- })
- return records
-
-def decode_tree_node(node: dict, parent_path: str = "", is_root: bool = False) -> dict:
- name = node.get("n", "")
- path = "" if is_root else (f"{parent_path}/{name}" if parent_path else name)
- children = [decode_tree_node(child, path, False) for child in node.get("ch", [])]
- decoded = {
- "name": name,
- "path": path,
- "count": node.get("c", 0),
- "hasDirectFiles": node.get("df", False),
- "hasChildren": len(children) > 0,
- "showSelfToggle": bool(path and node.get("df", False) and len(children) > 0),
- "children": children,
- }
- if is_root:
- decoded["isRoot"] = True
- return decoded
-
-def decode_folder_tree_data(data: dict) -> dict:
- decoded = {}
- for repo, nodes in (data or {}).items():
- decoded[repo] = [decode_tree_node(node, "", True) for node in nodes]
- return decoded
+ needle = next(iter(query_tokens(query)), "")
+ if not needle:
+ return {}
+ grouped = {}
+ for doc_id in doc_ids:
+ source, _, number = doc_id.partition(":")
+ if number.isdigit():
+ grouped.setdefault(source, []).append((doc_id, int(number)))
+ with self.lock:
+ missing = any(source not in self.connections for source in grouped)
+ if missing:
+ self.refresh()
+ snippets = {}
+ for source, source_docs in grouped.items():
+ with self._lease_source(source) as lease:
+ if lease is None:
+ continue
+ connection, state = lease
+ if not state["has_content_fts"]:
+ continue
+ self._matched_snippets_source(
+ snippets, connection, state, source_docs, needle, query, exact, timings,
+ )
+ return snippets
+
+ def _matched_snippets_source(self, snippets, connection, state, source_docs, needle, query, exact, timings) -> None:
+ doc_id_by_number = {number: doc_id for doc_id, number in source_docs}
+ numbers = list(doc_id_by_number)
+ anchored_numbers = set()
+ # Exact snippets retain the established literal path. Normal snippets can seek
+ # directly to a token anchor without scanning FTS content with instr().
+ if not exact and state["has_snippet_anchors"]:
+ token_hash = hashlib.sha256(needle.encode("utf-8")).digest()[:16]
+ for offset in range(0, len(numbers), 500):
+ batch = numbers[offset:offset + 500]
+ placeholders = ",".join("?" for _ in batch)
+ try:
+ anchor_started = time.perf_counter()
+ rows = connection.execute(
+ f"""
+ SELECT content_fts.rowid, snippet_anchors.char_offset, length(content),
+ substr(content, max(snippet_anchors.char_offset - 219, 1), 440 + length(?))
+ FROM content_fts JOIN snippet_anchors ON snippet_anchors.doc_number = content_fts.rowid
+ WHERE snippet_anchors.token_hash = ? AND content_fts.rowid IN ({placeholders})
+ """,
+ [needle, token_hash, *batch],
+ ).fetchall()
+ if timings is not None:
+ timings["snippet_anchor_sql"] = timings.get("snippet_anchor_sql", 0) + (time.perf_counter() - anchor_started) * 1000
+ for number, char_offset, content_length, text in rows:
+ payload_started = time.perf_counter()
+ payload = build_normalized_snippet_payload(text or "", query)
+ if timings is not None:
+ timings["snippet_payload"] = timings.get("snippet_payload", 0) + (time.perf_counter() - payload_started) * 1000
+ if not payload["highlights"]:
+ continue
+ extracted_start = max(int(char_offset) - 219, 1)
+ if extracted_start > 1 and not payload["snippet"].startswith("..."):
+ payload["snippet"] = "..." + payload["snippet"]
+ if extracted_start + len(text or "") <= int(content_length or 0) and not payload["snippet"].endswith("..."):
+ payload["snippet"] += "..."
+ snippets[doc_id_by_number[int(number)]] = payload
+ anchored_numbers.add(int(number))
+ except sqlite3.OperationalError:
+ break
+ numbers = [number for number in numbers if number not in anchored_numbers]
+ for offset in range(0, len(numbers), 500):
+ batch = numbers[offset:offset + 500]
+ placeholders = ",".join("?" for _ in batch)
+ try:
+ fallback_started = time.perf_counter()
+ rows = connection.execute(
+ f"""
+ SELECT rowid, instr(content, ?) AS match_position, length(content),
+ substr(content, max(instr(content, ?) - 220, 1), 440 + length(?))
+ FROM content_fts
+ WHERE rowid IN ({placeholders})
+ """,
+ [needle, needle, needle, *batch],
+ ).fetchall()
+ if timings is not None:
+ timings["snippet_fallback_sql"] = timings.get("snippet_fallback_sql", 0) + (time.perf_counter() - fallback_started) * 1000
+ for number, match_position, content_length, text in rows:
+ payload_started = time.perf_counter()
+ extracted_start = max(int(match_position or 0) - 220, 1)
+ if exact and not has_wildcard_query(query):
+ payload = build_normalized_exact_snippet_payload(text or "", query)
+ if not payload["highlights"]:
+ payload = build_snippet_payload(text or "", query, exact=True)
+ else:
+ payload = build_snippet_payload(text or "", query, exact=exact)
+ if not payload["highlights"]:
+ payload = build_snippet_payload(text or "", needle, exact=True)
+ if timings is not None:
+ timings["snippet_payload"] = timings.get("snippet_payload", 0) + (time.perf_counter() - payload_started) * 1000
+ if payload["snippet"] and extracted_start > 1 and not payload["snippet"].startswith("..."):
+ payload["snippet"] = "..." + payload["snippet"]
+ if payload["snippet"] and extracted_start + len(text or "") <= int(content_length or 0) and not payload["snippet"].endswith("..."):
+ payload["snippet"] += "..."
+ snippets[doc_id_by_number[int(number)]] = payload
+ except sqlite3.OperationalError:
+ continue
+fulltext_databases: Optional[FulltextDatabases] = None
-def decode_browser_entry(entry: dict, current_path: str = "") -> dict:
- return {
- "folders": [
- {
- "name": item.get("n", ""),
- "path": f"{current_path}/{item.get('n', '')}" if current_path else item.get("n", ""),
- "count": item.get("c", 0),
- }
- for item in entry.get("d", [])
- ],
- "files": [
- {
- "name": item.get("n", ""),
- "ext": item.get("e", ""),
- "hasTxt": item.get("t", False),
- "size": item.get("s", ""),
- }
- for item in entry.get("f", [])
- ],
- "current_path": current_path,
- }
+def tokenize(text: str) -> list[str]:
+ return query_terms(text)
-def decode_folder_browser_data(data: dict) -> dict:
- decoded = {}
- for repo, repo_browser in (data or {}).items():
- decoded[repo] = {}
- for path, entry in (repo_browser or {}).items():
- decoded[repo][path] = decode_browser_entry(entry, path)
- return decoded
+def compile_exact_query(query: str) -> tuple[str, re.Pattern | None]:
+ normalized_query = normalize_text(query)
+ if has_wildcard_query(normalized_query):
+ expression = re.escape(normalized_query).replace(r"\*", ".*?").replace(r"\?", ".")
+ return normalized_query, re.compile(expression, re.DOTALL)
+ return normalized_query, None
-def tokenize(text: str) -> list[str]:
- tokens = []
- text_lower = text.lower()
- alpha_tokens = re.findall(r"[a-z0-9]+", text_lower)
- tokens.extend(alpha_tokens)
- chinese_text = re.sub(r"[a-z0-9\s]+", " ", text_lower)
- chinese_text = re.sub(r"[^\u4e00-\u9fff\u3400-\u4dbf\s]+", " ", chinese_text)
- chinese_tokens = jieba.lcut(chinese_text)
- tokens.extend([t.strip() for t in chinese_tokens if t.strip()])
- chinese_token_set = set(chinese_tokens)
- for ch in text_lower:
- if "\u4e00" <= ch <= "\u9fff" or "\u3400" <= ch <= "\u4dbf":
- if ch not in chinese_token_set:
- tokens.append(ch)
- return list(set(tokens))
+def matches_normalized_exact_query(text: str, normalized_query: str, pattern: re.Pattern | None = None) -> bool:
+ return pattern.search(text) is not None if pattern is not None else normalized_query in text
def matches_exact_query(text: str, query: str) -> bool:
- if "*" in query or "?" in query:
- pattern = re.escape(query).replace(r"\*", ".*").replace(r"\?", ".")
- return re.search(pattern, text, re.IGNORECASE) is not None
- return query.lower() in text.lower()
-
-def edit_distance(s1: str, s2: str, max_dist: int = 2) -> int:
- if abs(len(s1) - len(s2)) > max_dist:
- return 999
- prev = list(range(len(s2) + 1))
- for i, c1 in enumerate(s1):
- curr = [i + 1]
- for j, c2 in enumerate(s2):
- cost = 0 if c1 == c2 else 1
- curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + cost))
- if min(curr) > max_dist:
- return 999
- prev = curr
- return prev[-1]
-
-def could_be_fuzzy(tok: str, word: str, max_dist: int = 2) -> bool:
- if abs(len(tok) - len(word)) > max_dist:
- return False
- if len(tok) < 4:
- return True
- counts: dict[str, int] = {}
- for ch in tok:
- counts[ch] = counts.get(ch, 0) + 1
- diff = 0
- for ch in word:
- if counts.get(ch, 0) > 0:
- counts[ch] -= 1
+ normalized_query, pattern = compile_exact_query(query)
+ return matches_normalized_exact_query(normalize_text(text), normalized_query, pattern)
+
+def has_wildcard_query(query: str) -> bool:
+ return "*" in query or "?" in query
+
+def normalized_text_map(text: str) -> tuple[str, list[tuple[int, int]]]:
+ normalized = []
+ offsets = []
+ for index, character in enumerate(text):
+ value = normalize_text(character)
+ normalized.append(value)
+ offsets.extend((index, index + 1) for _ in value)
+ return "".join(normalized), offsets
+
+def snippet_match_spans(text: str, query: str, exact: bool = False) -> list[tuple[int, int]]:
+ normalized_text, offsets = normalized_text_map(text)
+ normalized_query = normalize_text(query.strip())
+ if not normalized_text or not normalized_query or not offsets:
+ return []
+ normalized_spans = []
+ if exact:
+ if has_wildcard_query(normalized_query):
+ pattern = re.escape(normalized_query).replace(r"\*", ".*?").replace(r"\?", ".")
+ match = re.search(pattern, normalized_text, re.DOTALL)
+ if match:
+ fixed_parts = [part for part in re.split(r"[*?]+", normalized_query) if part]
+ for part in fixed_parts:
+ index = normalized_text.find(part, match.start(), match.end())
+ if index != -1:
+ normalized_spans.append((index, index + len(part)))
+ if not normalized_spans and match.end() > match.start():
+ normalized_spans.append(match.span())
else:
- diff += 1
- if diff > 2 * max_dist:
- return False
- for remaining in counts.values():
- diff += remaining
- if diff > 2 * max_dist:
- return False
- return True
-
-def build_word_index():
- global word_index, vocab_by_len
- word_index = {}
- for idx, rec in enumerate(records):
- file_name = rec.get("File", "")
- folders = rec.get("Folder", [])
- for tok in tokenize(" ".join([file_name] + folders)):
- word_index.setdefault(tok, set()).add(idx)
- vocab_by_len = {}
- for word in word_index:
- vocab_by_len.setdefault(len(word), []).append(word)
-
-
-def build_vocab_by_len():
- global vocab_by_len
- vocab_by_len = {}
- for word in word_index:
- vocab_by_len.setdefault(len(word), []).append(word)
-
+ index = normalized_text.find(normalized_query)
+ if index != -1:
+ normalized_spans.append((index, index + len(normalized_query)))
+ else:
+ for token in query_tokens(query):
+ normalized_token = normalize_text(token)
+ index = normalized_text.find(normalized_token)
+ if index != -1:
+ normalized_spans.append((index, index + len(normalized_token)))
+ return [
+ (offsets[start][0], offsets[end - 1][1])
+ for start, end in normalized_spans
+ if start < end and end <= len(offsets)
+ ]
-def build_indexes(build_words=True):
- global _data_generation, word_index, folder_index, extension_counts, repo_counts, repo_list, extension_list, repo_extension_counts, repo_records_map, sorted_by_name, sorted_by_size, repo_sorted_by_name, repo_sorted_by_size, txt_record_indices, repo_txt_record_indices, reader_record_indices, repo_reader_record_indices, reader_record_key_indices
- _data_generation += 1
+def build_snippet_payload(text: str, query: str, window: int = 110, exact: bool = False) -> dict:
+ query = (query or "").strip()
+ if not text or not query:
+ return {"snippet": "", "highlights": []}
+ spans = snippet_match_spans(text, query, exact)
+ if not spans:
+ condensed = re.sub(r"\s+", " ", text).strip()
+ return {"snippet": condensed[: window * 2], "highlights": []}
+ match_start, match_end = min(spans, key=lambda span: span[0])
+ start = max(0, match_start - window)
+ end = min(len(text), match_end + window)
+ snippet = re.sub(r"\s+", " ", text[start:end]).strip()
+ if start > 0:
+ snippet = "..." + snippet
+ if end < len(text):
+ snippet = snippet + "..."
+ highlights = list(dict.fromkeys(
+ re.sub(r"\s+", " ", text[span_start:span_end]).strip()
+ for span_start, span_end in spans
+ if span_start < end and span_end > start
+ ))
+ return {"snippet": snippet, "highlights": [value for value in highlights if value]}
+
+def build_normalized_snippet_payload(text: str, query: str, window: int = 110) -> dict:
+ """Build a normal-search snippet when text already came from normalized content_fts."""
+ query = (query or "").strip()
+ if not text or not query:
+ return {"snippet": "", "highlights": []}
+ spans = []
+ for token in query_tokens(query):
+ token = normalize_text(token)
+ index = text.find(token)
+ if index != -1:
+ spans.append((index, index + len(token)))
+ if not spans:
+ return {"snippet": re.sub(r"\s+", " ", text).strip()[: window * 2], "highlights": []}
+ match_start, match_end = min(spans, key=lambda span: span[0])
+ start = max(0, match_start - window)
+ end = min(len(text), match_end + window)
+ snippet = re.sub(r"\s+", " ", text[start:end]).strip()
+ if start > 0:
+ snippet = "..." + snippet
+ if end < len(text):
+ snippet += "..."
+ highlights = list(dict.fromkeys(
+ re.sub(r"\s+", " ", text[span_start:span_end]).strip()
+ for span_start, span_end in spans
+ if span_start < end and span_end > start
+ ))
+ return {"snippet": snippet, "highlights": [value for value in highlights if value]}
+
+def build_normalized_exact_snippet_payload(text: str, query: str, window: int = 110) -> dict:
+ needle = normalize_text(query.strip())
+ index = text.find(needle)
+ if index == -1:
+ return {"snippet": re.sub(r"\s+", " ", text).strip()[: window * 2], "highlights": []}
+ start = max(0, index - window)
+ end = min(len(text), index + len(needle) + window)
+ snippet = re.sub(r"\s+", " ", text[start:end]).strip()
+ if start > 0:
+ snippet = "..." + snippet
+ if end < len(text):
+ snippet += "..."
+ return {"snippet": snippet, "highlights": [needle]}
+
+def build_first_match_snippet(text: str, query: str, window: int = 110, exact: bool = False) -> str:
+ return build_snippet_payload(text, query, window, exact)["snippet"]
+
+def load_json_gz(path: Path):
+ return json.loads(gzip.decompress(path.read_bytes()).decode("utf-8"))
+
+def build_indexes() -> None:
+ global word_index, latin_all_index, latin_file_index, latin_vocabulary_all, source_records_map, extension_counts, source_extension_counts, name_order, size_order, source_name_order, source_size_order
word_index = {}
- folder_index = {}
+ latin_all_index = {}
+ latin_file_index = {}
+ latin_vocabulary_all = set()
+ source_records_map = {}
extension_counts = {}
- repo_extension_counts = {}
- repo_counts = {}
- repo_records_map = {}
- repo_sorted_by_name = {}
- repo_sorted_by_size = {}
- txt_record_indices = []
- repo_txt_record_indices = {}
- reader_record_indices = []
- repo_reader_record_indices = {}
- reader_record_key_indices = {}
+ source_extension_counts = {}
for idx, rec in enumerate(records):
- repo = rec.get("Repo", "")
- repo_counts[repo] = repo_counts.get(repo, 0) + 1
- ext = (rec.get("Extension") or "").lower()
- if ext:
- extension_counts[ext] = extension_counts.get(ext, 0) + 1
- repo_ext = repo_extension_counts.setdefault(repo, {})
- repo_ext[ext] = repo_ext.get(ext, 0) + 1
- file_name = rec.get("File", "")
- folders = rec.get("Folder", [])
- if repo not in folder_index:
- folder_index[repo] = {}
- for depth in range(len(folders) + 1):
- folder_path = "/".join(folders[:depth]) if depth > 0 else ""
- folder_index[repo].setdefault(folder_path, []).append(idx)
- repo_records_map.setdefault(repo, []).append(idx)
- if rec.get("HasTxt"):
- txt_record_indices.append(idx)
- repo_txt_record_indices.setdefault(repo, []).append(idx)
- if ext in READER_EXTENSIONS:
- reader_record_indices.append(idx)
- repo_reader_record_indices.setdefault(repo, []).append(idx)
- reader_record_key_indices[build_reader_asset_key(rec)] = idx
- folders_lower = [f.lower() for f in (folders or [])]
- rec["_file_lower"] = file_name.lower()
- rec["_folders_lower"] = folders_lower
- rec["_folder_text"] = " ".join(folders_lower)
- rec["_folder_path_lower"] = "/".join(folders_lower)
- rec["_folder_path"] = "/".join(folders)
- rec["_ext_lower"] = ext
- rec["_repo_lower"] = repo.lower()
- repo_list = sorted(
- [{"name": r, "count": c} for r, c in repo_counts.items()],
- key=lambda x: x["name"],
+ source = rec["source"]
+ extension = rec["extension"]
+ source_records_map.setdefault(source, []).append(idx)
+ extension_counts[extension] = extension_counts.get(extension, 0) + 1
+ source_counts_for_ext = source_extension_counts.setdefault(source, {})
+ source_counts_for_ext[extension] = source_counts_for_ext.get(extension, 0) + 1
+ file_text = " ".join([rec["display_name"], rec["source_name"]])
+ path_text = rec["display_rel_path"]
+ tokens = index_tokens(f"{file_text} {path_text}")
+ rec["_file_search_text"] = normalize_text(file_text)
+ rec["_path_search_text"] = normalize_text(path_text)
+ rec["_search_text"] = normalize_text(f"{file_text} {path_text}")
+ rec["_rank_path_key"] = rec["display_rel_path"].lower()
+ for token in tokens:
+ word_index.setdefault(token, set()).add(idx)
+ if LATIN_RE.fullmatch(token):
+ latin_all_index.setdefault(token, set()).add(idx)
+ latin_vocabulary_all.add(token)
+ for token in TOKEN_RE.findall(rec["_file_search_text"]):
+ if LATIN_RE.fullmatch(token):
+ latin_file_index.setdefault(token, set()).add(idx)
+ name_order = sorted(
+ range(len(records)),
+ key=lambda idx: (records[idx]["display_rel_path"].lower(), records[idx]["doc_id"]),
)
- if build_words:
- build_word_index()
- else:
- word_index = {}
- global vocab_by_len
- vocab_by_len = {}
- extension_list = sorted(extension_counts.keys())
- all_indices = list(range(len(records)))
- sorted_by_name = sorted(all_indices, key=lambda i: (records[i].get("File") or "").lower())
- sorted_by_size = sorted(all_indices, key=lambda i: (
- -(records[i].get("Size") if isinstance(records[i].get("Size"), (int, float)) else 0),
- (records[i].get("File") or "").lower(),
- ))
- for repo, repo_indices in repo_records_map.items():
- repo_sorted_by_name[repo] = sorted(repo_indices, key=lambda i: (records[i].get("File") or "").lower())
- repo_sorted_by_size[repo] = sorted(repo_indices, key=lambda i: (
- -(records[i].get("Size") if isinstance(records[i].get("Size"), (int, float)) else 0),
- (records[i].get("File") or "").lower(),
- ))
-
-def load_data():
- global records, folder_tree_data, folder_browser_data, ngram_postings, ngram_posting_buffers, word_index
+ size_order = sorted(
+ range(len(records)),
+ key=lambda idx: (
+ -(records[idx].get("size") or 0),
+ records[idx]["display_rel_path"].lower(),
+ records[idx]["doc_id"],
+ ),
+ )
+ source_name_order = {source: [] for source in source_records_map}
+ source_size_order = {source: [] for source in source_records_map}
+ for idx in name_order:
+ source_name_order[records[idx]["source"]].append(idx)
+ for idx in size_order:
+ source_size_order[records[idx]["source"]].append(idx)
+
+def load_data() -> None:
+ global records, record_map, record_map_index, sources, source_counts, folder_tree_data, folder_browser_data
start = time.time()
- if DATA_PATH.exists():
- raw = gzip.decompress(DATA_PATH.read_bytes())
- records = decode_search_payload(json.loads(raw.decode("utf-8")))
- else:
- print("⚠ 数据文件不存在")
- records = []
- return
- if FOLDER_TREE_PATH.exists():
- folder_tree_data = decode_folder_tree_data(json.loads(gzip.decompress(FOLDER_TREE_PATH.read_bytes()).decode("utf-8")))
- else:
- folder_tree_data = {}
- if FOLDER_BROWSER_PATH.exists():
- folder_browser_data = decode_folder_browser_data(json.loads(gzip.decompress(FOLDER_BROWSER_PATH.read_bytes()).decode("utf-8")))
- else:
- folder_browser_data = {}
- print(f"📖 已加载 {len(records)} 条记录 ({time.time() - start:.2f}s)")
- build_indexes(build_words=False)
- digest = records_digest()
- word_index = load_word_index(WORD_INDEX_PATH, len(records), digest)
- if word_index:
- build_vocab_by_len()
- else:
- build_word_index()
- loaded_ngrams = {
- width: load_ngram_index(path, width, len(records), digest)
- for width, path in NGRAM_PATHS.items()
- }
- ngram_postings = {width: loaded[0] for width, loaded in loaded_ngrams.items()}
- ngram_posting_buffers = {width: loaded[1] for width, loaded in loaded_ngrams.items()}
- print(f"🧭 已加载词索引: {len(word_index)}")
- print(f"🔎 已加载 ngram 索引: 2={len(ngram_postings[2])}, 3={len(ngram_postings[3])}")
- build_initial_payloads()
- precompute_injected_html()
-
-def score_record(rec_idx, query_tokens, search_folders=True):
- rec = records[rec_idx]
+ payload = load_json_gz(DATA_PATH)
+ records = payload.get("records", [])
+ sources = payload.get("sources", [])
+ source_counts = {item["slug"]: item.get("count", 0) for item in sources}
+ record_map = {rec["doc_id"]: rec for rec in records}
+ record_map_index = {rec["doc_id"]: idx for idx, rec in enumerate(records)}
+ folder_tree_data = load_json_gz(FOLDER_TREE_PATH)
+ folder_browser_data = load_json_gz(FOLDER_BROWSER_PATH)
+ build_indexes()
+ print(f"loaded {len(records)} txt records in {time.time() - start:.2f}s")
+
+def score_record(idx: int, query_tokens: list[str], search_paths: bool = True) -> int:
score = 0
- file_name = rec.get("_file_lower", "")
- repo = rec.get("_repo_lower", "")
- folder_text = rec.get("_folder_text", "")
- for tok in query_tokens:
- if tok in file_name:
+ file_text = records[idx]["_file_search_text"]
+ path_text = records[idx]["_path_search_text"]
+ for token in query_tokens:
+ if token in file_text:
score += 3
- if search_folders and tok in folder_text:
+ if search_paths and token in path_text:
score += 2
- if tok in repo:
- score += 1
return score
-def apply_filters(indices, repos=None, extensions=None, folders=None, min_size=None, max_size=None, folder_match_mode="prefix"):
- extensions_lower = [e.lower() for e in extensions] if extensions else None
+def relevance_page(indices, query_tokens: list[str], search_paths: bool, start: int, page_size: int) -> list[int]:
+ limit = start + page_size
+ if limit <= 0:
+ return []
+ ranked = heapq.nsmallest(
+ limit,
+ indices,
+ key=lambda idx: (-score_record(idx, query_tokens, search_paths), records[idx]["_rank_path_key"], records[idx]["doc_id"]),
+ )
+ return ranked[start:limit]
+
+def ordered_page(indices, ordering: list[int], start: int, page_size: int) -> list[int]:
+ candidates = set(indices)
+ if not candidates or page_size <= 0:
+ return []
+ selected = []
+ matched = 0
+ for idx in ordering:
+ if idx not in candidates:
+ continue
+ if matched >= start:
+ selected.append(idx)
+ if len(selected) >= page_size:
+ break
+ matched += 1
+ return selected
+
+def apply_filters(indices: list[int], sources_filter=None, folders=None, min_size=None, max_size=None):
result = []
for idx in indices:
rec = records[idx]
- if repos and rec.get("Repo") not in repos:
+ if sources_filter is not None and rec["source"] not in sources_filter:
continue
- if extensions_lower:
- if rec.get("_ext_lower", "") not in extensions_lower:
- continue
if folders:
- rec_folders = rec.get("Folder", []) or []
- rec_path = rec.get("_folder_path", "/".join(rec_folders))
- matched = False
- for f in folders:
- f_clean = f.strip("/")
- if folder_match_mode == "exact":
- if rec_path == f_clean:
- matched = True
- break
- elif f_clean == "":
- matched = True
- break
- elif rec_path == f_clean or rec_path.startswith(f_clean + "/"):
- matched = True
- break
+ rel_dir = "/".join(rec.get("display_dirs", []))
+ matched = any(rel_dir == folder or rel_dir.startswith(folder + "/") for folder in folders)
if not matched:
continue
- size = rec.get("Size")
- if size and isinstance(size, (int, float)) and size > 0:
- if min_size is not None and size < min_size:
- continue
- if max_size is not None and size > max_size:
- continue
+ size = rec.get("size") or 0
+ if min_size is not None and size < min_size:
+ continue
+ if max_size is not None and size > max_size:
+ continue
result.append(idx)
return result
-def trim_results(raw):
- keys = ("Repo", "File", "Extension", "Link", "Path", "Folder", "Size", "HasTxt")
- trimmed = []
- for r in raw:
- item = {k: r.get(k, "" if k != "Folder" else []) for k in keys}
- item["Link"] = r.get("Link") or build_record_link(r)
- item["Path"] = r.get("Path") or build_record_path_url(r)
- trimmed.append(item)
- return trimmed
-
-def sort_key_name(idx: int):
- return (records[idx].get("File") or "").lower()
-
-def sort_key_size(idx: int):
- return (
- -(records[idx].get("Size") if isinstance(records[idx].get("Size"), (int, float)) else 0),
- (records[idx].get("File") or "").lower(),
- )
-
-def paginate_indices(indices: list[int], page: int, page_size: int) -> tuple[int, list[dict]]:
- total = len(indices)
- start = (page - 1) * page_size
- return total, [records[i] for i in indices[start:start + page_size]]
-
-def get_initial_indices(repos=None) -> list[int]:
- if repos:
- indices = []
- for repo in repos:
- indices.extend(repo_records_map.get(repo, []))
- return indices
- return list(range(len(records)))
-
-def build_response(results: list[dict], total: int, page: int, page_size: int) -> dict:
- return {
- "results": trim_results(results),
- "total": total,
- "page": page,
- "page_size": page_size,
- }
-
-def trim_initial_results(raw: list[dict]) -> list[dict]:
- keys = ("Repo", "File", "Extension", "Folder", "Size", "HasTxt")
- return [
- {key: record.get(key, [] if key == "Folder" else "") for key in keys}
- for record in raw
- ]
-
-def build_initial_payload(repo: str | None = None) -> dict:
- if repo:
- indices = repo_records_map.get(repo, [])
- result_records = [records[i] for i in indices[:INITIAL_PAGE_SIZE]]
- mode = "repo"
- total = len(indices)
- else:
- result_records = records[:INITIAL_PAGE_SIZE]
- mode = "global"
- total = len(records)
- return {
- "mode": mode,
- "repo": repo,
- "sort": "relevance",
- "page": 1,
- "page_size": INITIAL_PAGE_SIZE,
- "total": total,
- "results": trim_initial_results(result_records),
- }
+def has_filters(sources_filter=None, folders=None, min_size=None, max_size=None) -> bool:
+ return bool(sources_filter is not None or folders or min_size is not None or max_size is not None)
-def build_initial_payloads():
- global initial_payload_global, initial_payload_by_repo
- initial_payload_global = build_initial_payload(None)
- initial_payload_by_repo = {
- repo: build_initial_payload(repo)
- for repo in repo_records_map
- }
-def precompute_injected_html():
- global injected_html_cache
- index_path = Path("static/index.html")
- if not index_path.exists():
- injected_html_cache = {}
- return
- html = index_path.read_text(encoding="utf-8")
- injected_html_cache = {None: inject_initial_payload(html, None)}
- for repo in repo_records_map:
- injected_html_cache[repo] = inject_initial_payload(html, repo.split("/")[-1])
-
-def load_sidebar_payload(path: Path):
- if not path.exists() or not path.is_file():
+def empty_query_order(sources_filter=None, folders=None, min_size=None, max_size=None, sort="relevance") -> list[int] | range | None:
+ if folders or min_size is not None or max_size is not None:
return None
- try:
- return json.loads(path.read_text(encoding="utf-8"))
- except Exception:
+ if sources_filter is None:
+ if sort == "name":
+ return name_order
+ if sort == "size":
+ return size_order
+ if sort == "relevance":
+ return range(len(records))
return None
+ if len(sources_filter) != 1:
+ return None
+ source = sources_filter[0]
+ if sort == "name":
+ return source_name_order.get(source, [])
+ if sort == "size":
+ return source_size_order.get(source, [])
+ if sort == "relevance":
+ return source_records_map.get(source, [])
+ return None
-def build_global_bootstrap():
- sidebar = load_sidebar_payload(SIDEBAR_DATA_PATH / "global.json") or {}
+def trim_record(rec: dict) -> dict:
return {
- "generation": _data_generation,
- "repos": repo_list,
- "extensions": [{"name": name, "count": extension_counts[name]} for name in sorted(extension_counts)],
- "random_txt": {"available": bool(reader_record_indices), "count": len(reader_record_indices)},
- "sidebar": sidebar,
+ "doc_id": rec["doc_id"],
+ "Source": rec["source"],
+ "SourceName": rec["source_name"],
+ "File": rec["display_name"],
+ "Extension": rec["extension"],
+ "Folder": rec["display_dirs"],
+ "DisplayPath": rec["display_rel_path"],
+ "Size": rec["size"],
+ "HasTxt": True,
}
-def inject_initial_payload(html: str, repo_short: str | None = None) -> str:
- repo = f"VoiceOfML/{repo_short}" if repo_short else None
- payload = initial_payload_by_repo.get(repo) if repo else initial_payload_global
- if not payload:
- return html
- script = (
- '"
- )
- app_script = re.search(r'', html)
- if app_script:
- return html[:app_script.start()] + script + "\n" + app_script.group(0) + html[app_script.end():]
- return html.replace("