diff --git a/.prettierrc.json b/.prettierrc.json
index b463fa0a306db0954fd055675d0b8a1622afe5dd..e22dd1dd9bacb0ab12d1d9ceacc6d0525d188333 100644
--- a/.prettierrc.json
+++ b/.prettierrc.json
@@ -3,7 +3,8 @@
{
"files": [
"static/reader*.{js,css,html}",
- "scripts/check_reader_release.mjs"
+ "static/reader/**/*.mjs",
+ "scripts/build/check_reader_release.mjs"
],
"options": { "printWidth": 100, "trailingComma": "none" }
}
diff --git a/Dockerfile b/Dockerfile
index d1070f4414ce117fef50f4fe25be75591d1f4899..a08c46c71a2fb2a87d30fe2e6cfb19a3749ed0e0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,10 +11,10 @@ RUN npm install --global "esbuild@${ESBUILD_VERSION}" \
&& test "$(esbuild --version)" = "${ESBUILD_VERSION}"
COPY static/ /build/static/
-COPY scripts/build_static_assets.mjs /build/build_static_assets.mjs
-COPY scripts/copy_reader_vendor.mjs /build/copy_reader_vendor.mjs
-COPY scripts/check_reader_release.mjs /build/check_reader_release.mjs
-COPY scripts/compose_app.mjs /build/compose_app.mjs
+COPY scripts/build/build_static_assets.mjs /build/build_static_assets.mjs
+COPY scripts/build/copy_reader_vendor.mjs /build/copy_reader_vendor.mjs
+COPY scripts/build/check_reader_release.mjs /build/check_reader_release.mjs
+COPY scripts/build/compose_app.mjs /build/compose_app.mjs
RUN mkdir -p /out \
&& cp -a static/. /out/ \
@@ -22,7 +22,8 @@ RUN mkdir -p /out \
&& node /build/compose_app.mjs > /build/app-composed.js \
&& esbuild /build/app-composed.js --minify --target=es2020 --charset=utf8 --legal-comments=none --outfile=/out/app.js \
&& for file in $(node -e 'console.log(require("./static/reader-resources.js").buildFiles("hf").filter(f => f !== "app.js").join(" "))'); do \
- esbuild "static/$file" --minify --target=es2020 --charset=utf8 --legal-comments=none --outfile="/out/$file" || exit 1; \
+ flags=$(node -e 'console.log(require("./static/reader-resources.js").bundleArguments(process.argv[1]).join(" "))' "$file"); \
+ esbuild "static/$file" $flags --minify --target=es2020 --charset=utf8 --legal-comments=none --outfile="/out/$file" || exit 1; \
done \
&& node /build/build_static_assets.mjs \
&& esbuild /out/sw.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/sw.min.js \
@@ -34,9 +35,9 @@ FROM python:3.11.17-slim AS search-index-build
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev \
&& rm -rf /var/lib/apt/lists/*
-COPY search_index_native.c .
-COPY scripts/build_search_index_native.py scripts/build_search_index_native.py
-RUN python scripts/build_search_index_native.py --output-dir /native
+COPY backend/native/search_index_native.c backend/native/search_index_native.c
+COPY scripts/build/build_search_index_native.py scripts/build/build_search_index_native.py
+RUN python scripts/build/build_search_index_native.py --output-dir /native
FROM python:3.11.17-slim
@@ -49,7 +50,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
-COPY app.py data_formats.py proxy_streams.py record_metadata.py upstream_http.py search_execution.py search_indexes.py reader_assets.py reader_v3_catalog.py .
+COPY app.py .
+COPY backend/ backend/
COPY --from=search-index-build /native/ /app/
COPY --from=static-assets /out/ static/
COPY data/ data/
diff --git a/app.py b/app.py
index 0edb966dde4c902dd033379c399b76a450a8ee97..0d2859a09279a1578695258df2fd83f8c3439a99 100644
--- a/app.py
+++ b/app.py
@@ -1,2925 +1,3 @@
-import asyncio
-import gzip
-import hashlib
-import json
-import posixpath
-import random
-import re
-import sys
-import time
-import functools
-import threading
-import weakref
-from collections import OrderedDict
-from array import array
-from uuid import uuid4
-import io
-import zipfile
-import zlib
-from pathlib import Path
-from typing import Annotated, Literal, Optional, Sequence
-from urllib.parse import unquote, quote, urlparse, parse_qs
-import aiohttp
-from huggingface_hub import HfFileSystem
-import jieba
-from fastapi import FastAPI, HTTPException, Query, Request
-from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, Response
-from fastapi.staticfiles import StaticFiles
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.middleware.gzip import GZipMiddleware
-from pydantic import BaseModel, Field
-from contextlib import asynccontextmanager
-from proxy_streams import OwnedStreamingResponse, SharedRangeFlight, UpstreamLease
-from search_execution import SearchBusy, SearchExecutor
-from search_indexes import (
- NGRAM_MAGIC, WORD_INDEX_MAGIC, WORD_INDEX_TOKENIZER,
- decode_postings, decode_varint, load_ngram_index, load_word_index,
- resolve_postings,
-)
-from reader_assets import (
- READER_ASSET_FILENAMES, READER_BUCKET_PATH_RE, decode_reader_assets,
-)
-from reader_v3_catalog import load_reading_catalog, merge_sidecar as merge_v3_sidecar
-from data_formats import (
- decode_browser_entry, decode_folder_browser_data, decode_folder_tree_data,
- decode_search_payload, decode_tree_node,
-)
-from record_metadata import (
- READER_ASSET_MODES,
- add_reader_assets, build_browser_file_name, build_reader_asset_key,
- build_record_link, build_record_path_url, build_record_relative_path,
- build_txt_relative_path, enrich_browser_entry, enrich_reader_record,
- reader_asset_url, reader_extension_for_asset, reader_resolved_record,
-)
-from upstream_http import (
- ALLOWED_DOWNLOAD_HOSTS, ALLOWED_DOWNLOAD_HOST_SUFFIXES, MAX_REDIRECTS,
- READER_ASSET_PATH_RE, READER_ASSET_SOURCE_RE, VOICEOFML_READER_SOURCE_RE,
- decode_url_path, is_allowed_download_host, is_immutable_reader_asset,
- normalize_download_url, open_download_response, source_url_scope,
- validate_download_url, validate_path_safety, validate_reader_redirect_url,
- validate_reader_source_url, validate_voiceofml_redirect_url, validate_voiceofml_source_url,
-)
-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")
-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/buckets/vomebook/reader-assets-v2/resolve/reader-index/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
-READER_ASSET_ROUTING_CONTRACT_VERSION = "reader-buckets-v2"
-records: list[dict] = []
-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", "djvu", "caj", "kdh", "epub", "mobi", "azw", "azw3", "fb2", "fbz", "html", "htm", "txt", "md", "markdown", "vcf", "ini", "jpg", "jpeg", "png", "gif", "bmp", "tif", "tiff", "webp", "mp3", "mp4", "wav", "m4a", "flac", "mov", "mpga", "wma", "ape", "amr"})
-reader_record_indices = []
-repo_reader_record_indices = {}
-reader_record_key_indices = {}
-reader_source_id_indices = {}
-_reader_indexes_ready = False
-_reader_indexes_lock = threading.Lock()
-_reader_index_build = None
-READER_INDEX_BATCH_RECORDS = 256
-READER_INDEX_BATCH_SECONDS = .008
-READER_INDEX_WARM_DELAY_SECONDS = 5
-READER_INDEX_IDLE_SECONDS = 5
-initial_payload_global = None
-initial_payload_by_repo = {}
-API_CACHE_TTL_SECONDS = 120
-RESPONSE_CACHE_BYTES_MAX = 16 * 1024 * 1024
-RESPONSE_CACHE_ENTRY_BYTES_MAX = 2 * 1024 * 1024
-_api_response_cache_lock = threading.Lock()
-api_response_cache = {}
-_data_generation = 0
-SEARCH_CACHE_TTL_SECONDS = 120
-_search_cache: dict[tuple, tuple[float, dict, int]] = {}
-_search_instance = uuid4().hex
-# Bump when matching, filtering, ordering or public record semantics change.
-SEARCH_SEMANTICS_VERSION = "hf-search-v2"
-_search_generation = None
-_search_logged_generation = None
-_search_order_cache = OrderedDict()
-SEARCH_ORDER_CACHE_MAX = 32
-SEARCH_ORDER_CACHE_BYTES_MAX = 16 * 1024 * 1024
-SEARCH_ORDER_CACHE_TTL_SECONDS = 600
-_search_anchor_cache = OrderedDict()
-SEARCH_ANCHOR_ORDER_MAX = 8
-SEARCH_ANCHOR_PER_ORDER_MAX = 8
-SEARCH_ANCHOR_KEY_MAX = 4096
-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
-reader_assets_revision = hashlib.sha256(b'{"v":1,"f":{}}').hexdigest()
-reader_join_cache_key = None
-reader_join_cache = {"all": {}, "repos": {}}
+"""Uvicorn entry point for the search service."""
-def _cached(store, key, builder, ttl, limit):
- now = time.monotonic()
- cached = store.get(key)
- if cached and now - cached[0] < ttl:
- store[key] = store.pop(key)
- return cached[1]
- value = builder()
- store.pop(key, None)
- for old_key, entry in list(store.items()):
- if now - entry[0] >= ttl:
- del store[old_key]
- size = (len(value) if isinstance(value, bytes) else len(json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))) + len(str(key).encode("utf-8"))
- if size > RESPONSE_CACHE_ENTRY_BYTES_MAX:
- return value
- total = sum(entry[2] if len(entry) > 2 else len(json.dumps(entry[1]).encode("utf-8")) for entry in store.values())
- while store and (len(store) >= limit or total + size > RESPONSE_CACHE_BYTES_MAX):
- oldest_key = next(iter(store))
- entry = store.pop(oldest_key)
- total -= entry[2] if len(entry) > 2 else len(json.dumps(entry[1]).encode("utf-8"))
- if size <= RESPONSE_CACHE_BYTES_MAX:
- store[key] = (now, value, size)
- return value
-
-
-def cached_payload(key, builder):
- with _api_response_cache_lock:
- return _cached(api_response_cache, key, builder, API_CACHE_TTL_SECONDS, 500)
-
-
-def cached_json_response(key, builder, headers=None):
- # Encode once, measure the actual body and reuse it within the existing budget.
- body = cached_payload(("json", key), lambda: JSONResponse(builder()).body)
- return Response(body, media_type="application/json", headers=headers)
-
-
-async def get_reader_assets(*, wait: bool = True) -> dict:
- if reader_assets_cache_at != 0 and time.monotonic() - reader_assets_cache_at < READER_ASSETS_CACHE_TTL_SECONDS:
- return reader_assets_cache
- task = getattr(app.state, "reader_assets_task", None)
- if task is None or task.done():
- task = asyncio.create_task(refresh_reader_assets())
- app.state.reader_assets_task = task
- # Search can enrich with the last snapshot while an owned refresh runs.
- return await asyncio.shield(task) if wait else reader_assets_cache
-
-
-async def refresh_reader_assets() -> dict:
- global reader_assets_cache, reader_assets_cache_at, reader_assets_revision
- now = time.monotonic()
- if reader_assets_cache_at != 0 and 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 reader_assets_cache_at != 0 and now - reader_assets_cache_at < READER_ASSETS_CACHE_TTL_SECONDS:
- return reader_assets_cache
- response = None
- try:
- for url in (READER_ASSETS_URL,):
- response = await app.state.http_session.get(
- f"{url}?v={int(time.time())}",
- timeout=aiohttp.ClientTimeout(total=20, connect=8, sock_read=10),
- )
- if response.status == 200:
- break
- if response.status != 404:
- raise RuntimeError(f"reader assets HTTP {response.status}")
- response.release()
- response = None
- if response is None:
- raise RuntimeError("reader assets bucket and legacy sources unavailable")
- 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)
- decoded = await asyncio.to_thread(decode_reader_assets, raw)
- v3_catalog = await load_reading_catalog(app.state.http_session)
- if v3_catalog is None and reader_assets_cache.get("v3"):
- raise ValueError("previously published v3 pointer disappeared")
- decoded = merge_v3_sidecar(decoded, v3_catalog)
- reader_assets_cache = decoded
- revision_body = raw + json.dumps(decoded["v3"], sort_keys=True).encode() if v3_catalog else raw
- reader_assets_revision = hashlib.sha256(revision_body).hexdigest()
- reader_assets_cache_at = time.monotonic()
- except Exception as exc:
- print(f"reader assets refresh failed: {exc}")
- # Retain last-known assets, but retry failures after a short backoff.
- reader_assets_cache_at = time.monotonic() - READER_ASSETS_CACHE_TTL_SECONDS + 5
- finally:
- if response is not None:
- response.release()
- return reader_assets_cache
-
-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 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
- entries = []
- for gram in dict.fromkeys(query[index:index + width] for index in range(len(query) - width + 1)):
- encoded = postings.get(gram)
- if encoded is None:
- return set()
- entries.append(encoded)
- # VNG3 entries carry validated counts; materialized test postings have lengths.
- entries.sort(key=lambda entry: entry[2] if isinstance(entry, tuple) else len(entry))
- candidates = None
- allowed = None
- if repos:
- allowed = set()
- for repo in repos:
- allowed.update(repo_records_map.get(repo, []))
- for encoded in entries:
- matches = resolve_postings(encoded, ngram_posting_buffers.get(width, b""))
- if candidates is None:
- candidates = matches
- if allowed is not None:
- candidates.intersection_update(allowed)
- else:
- candidates.intersection_update(matches)
- if not candidates:
- return set()
- 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",
-}
-
-READER_SHORT_ID_RE = re.compile(r"^[0-9a-z]{13}$")
-# Only lowercase escapes, encoded unreserved ASCII and malformed percent signs
-# require normalization. Canonical reserved/UTF-8 escapes can pass through.
-READER_ESCAPE_NORMALIZATION_RE = re.compile(
- r"%(?:[0-9a-fA-F][a-f]|[a-f][0-9a-fA-F]|2[DE]|3[0-9]|4[1-9A-F]|5[0-9AF]|6[1-9A-F]|7[0-9AE])"
- r"|%(?![0-9a-fA-F]{2})"
-)
-
-
-def canonical_reader_source_url(value: str) -> str:
- # Match reader-contract.js byte normalization without decoding path separators
- # or treating an encoded percent sign as a second escape sequence.
- def path_replace(match):
- def token_replace(token_match):
- token = token_match.group()
- if len(token) == 3:
- char = chr(int(token[1:], 16))
- return char if char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~-" else token.upper()
- return "%25"
- # Quote once, then skip already-canonical reserved and UTF-8 escapes.
- path = quote(match[2], safe="/%")
- return match[1] + READER_ESCAPE_NORMALIZATION_RE.sub(token_replace, path)
- return re.sub(r"^(https://huggingface\.co/datasets/VoiceOfML/)([^?#]*)", path_replace, value)
-
-
-def reader_short_id(value: str) -> str:
- digest = 1469598103934665603
- for byte in canonical_reader_source_url(value).encode("utf-8"):
- digest = ((digest ^ byte) * 1099511628211) & ((1 << 64) - 1)
- return _base36(digest).rjust(13, "0")
-
-
-def _base36(value: int) -> str:
- alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
- output = ""
- while value:
- value, remainder = divmod(value, 36)
- output = alphabet[remainder] + output
- return output or "0"
-
-
-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 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 compile_exact_matcher(query: str):
- if "*" not in query and "?" not in query:
- lower = query.lower()
- return lambda text: lower in text.lower()
- # Bitset NFA: each bit is a pattern position. Collapse adjacent stars so
- # epsilon closure needs one shift. No input can cause regex backtracking.
- pattern = re.sub(r"\*+", "*", query)
- stars = questions = 0
- literals = {}
- for index, char in enumerate(pattern):
- bit = 1 << index
- if char == "*":
- stars |= bit
- elif char == "?":
- questions |= bit
- else:
- literals[char] = literals.get(char, 0) | bit
- # Single-character regexes retain Python IGNORECASE's Unicode semantics.
- literals = [(re.compile(re.escape(char), re.IGNORECASE), bits)
- for char, bits in literals.items()]
- masks = {}
- end = 1 << len(pattern)
- start = 1 | ((1 & stars) << 1)
-
- def matches(text):
- # Every literal is mandatory; C-level scans reject unrelated fields early.
- if any(regex.search(text) is None for regex, _ in literals):
- return False
- active = start
- if active & end:
- return True
- for char in text:
- mask = masks.get(char)
- if mask is None:
- mask = sum(bits for regex, bits in literals if regex.fullmatch(char))
- if len(masks) >= 512:
- masks.clear()
- masks[char] = mask
- wildcard = char != "\n"
- active = ((active & (mask | (questions if wildcard else 0))) << 1) | (
- active & stars if wildcard else 0) | 1
- active |= (active & stars) << 1
- if active & end:
- return True
- return False
- return matches
-
-
-def matches_exact_query(text: str, query: str) -> bool:
- return compile_exact_matcher(query)(text)
-
-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
- 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 is_fuzzy_match(token: str, word: str) -> bool:
- return could_be_fuzzy(token, word, 2) and edit_distance(token, word, max_dist=2) <= 2
-
-
-def matches_file_token(record: dict, token: str) -> bool:
- return token in record.get("_file_lower", "") or token in record.get("_repo_lower", "")
-
-
-def scoped_word_postings(token: str, search_folders: bool):
- postings = word_postings(token)
- if search_folders:
- return postings
- return {index for index in postings if matches_file_token(records[index], token)}
-
-
-def matches_file_query(record: dict, tokens, fuzzy_tokens) -> bool:
- file_tokens = tokenize(record.get("_file_lower", "")) if fuzzy_tokens else []
- return all(matches_file_token(record, token) or (
- token in fuzzy_tokens and any(is_fuzzy_match(token, word) for word in file_tokens)
- ) for token in tokens)
-
-
-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)
-
-
-def _advance_reader_indexes(limit=READER_INDEX_BATCH_RECORDS, budget=READER_INDEX_BATCH_SECONDS):
- global reader_record_indices, repo_reader_record_indices
- global reader_record_key_indices, reader_source_id_indices, _reader_indexes_ready
- global _reader_index_build
- with _reader_indexes_lock:
- if _reader_indexes_ready:
- return True
- if _reader_index_build is None or _reader_index_build["generation"] != _data_generation:
- _reader_index_build = {"generation": _data_generation, "records": records, "next": 0,
- "indices": [], "repos": {}, "keys": {}, "ids": {}}
- build = _reader_index_build
- deadline = time.perf_counter() + budget
- end = min(len(build["records"]), build["next"] + limit)
- while build["next"] < end:
- idx = build["next"]
- rec = build["records"][idx]
- repo = rec.get("Repo", "")
- build["keys"][build_reader_asset_key(rec)] = idx
- source_id = reader_short_id(rec.get("Link") or build_record_link(rec))
- build["ids"].setdefault(source_id, []).append(idx)
- if rec.get("_ext_lower", "") in READER_EXTENSIONS:
- build["indices"].append(idx)
- build["repos"].setdefault(repo, []).append(idx)
- build["next"] += 1
- if time.perf_counter() >= deadline:
- break
- if build["next"] < len(build["records"]):
- return False
- if build["generation"] != _data_generation or build["records"] is not records:
- _reader_index_build = None
- return False
- reader_record_indices = array('I', build["indices"])
- repo_reader_record_indices = {key: array('I', indices) for key, indices in build["repos"].items()}
- reader_record_key_indices = build["keys"]
- reader_source_id_indices = build["ids"]
- _reader_indexes_ready = True
- _reader_index_build = None
- return True
-
-
-def _build_reader_indexes():
- while not _advance_reader_indexes(len(records), float("inf")):
- pass
-
-
-def ensure_reader_indexes():
- global _reader_indexes_ready
- if _reader_indexes_ready:
- return
- _build_reader_indexes()
-
-
-async def ensure_reader_indexes_async():
- """Join the shared background build, or start one for an early Reader request."""
- if _reader_indexes_ready:
- return
- task = getattr(app.state, "reader_index_build_task", None)
- if task is None or task.done():
- async def build():
- while not _reader_indexes_ready:
- executor = getattr(app.state, "search_executor", None)
- if executor is None:
- await asyncio.to_thread(_advance_reader_indexes)
- else:
- try:
- await executor.run(_advance_reader_indexes, background=True)
- except SearchBusy:
- await asyncio.sleep(.02)
- continue
- await asyncio.sleep(0)
- task = asyncio.create_task(build())
- app.state.reader_index_build_task = task
- await asyncio.shield(task)
-
-
-async def warm_reader_indexes():
- """Prepare Reader routing during a quiet period without delaying startup."""
- await asyncio.sleep(READER_INDEX_WARM_DELAY_SECONDS)
- while not _reader_indexes_ready:
- while time.monotonic() - last_user_request_at < READER_INDEX_IDLE_SECONDS:
- await asyncio.sleep(READER_INDEX_IDLE_SECONDS)
- try:
- await ensure_reader_indexes_async()
- except SearchBusy:
- await asyncio.sleep(READER_INDEX_IDLE_SECONDS)
-
-
-def build_indexes(build_words=True, build_reader=True):
- global _reader_index_build
- global reader_source_id_indices, _search_generation
- 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, _reader_indexes_ready
- _data_generation += 1
- _search_generation = None
- _search_order_cache.clear()
- _search_anchor_cache.clear()
- _search_cache.clear()
- word_index = {}
- folder_index = {}
- 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 = {}
- reader_source_id_indices = {}
- _reader_indexes_ready = False
- _reader_index_build = None
- 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)
- 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"],
- )
- 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(),
- ))
- # These complete, ordered postings are read-only after publication.
- # Packed IDs avoid retaining a Python pointer (and often an int) per slot.
- sorted_by_name = array('I', sorted_by_name)
- sorted_by_size = array('I', sorted_by_size)
- txt_record_indices = array('I', txt_record_indices)
- for mapping in (repo_records_map, repo_sorted_by_name, repo_sorted_by_size,
- repo_txt_record_indices, *folder_index.values()):
- for key, indices in mapping.items():
- mapping[key] = array('I', indices)
- if build_reader:
- _build_reader_indexes()
-
-def search_data_generation(raw: bytes, index_paths) -> str:
- digest = hashlib.sha256()
- digest.update(SEARCH_SEMANTICS_VERSION.encode())
- digest.update(str(sys.version_info[:3]).encode())
- digest.update(jieba.__version__.encode())
- digest.update(hashlib.sha256(raw).digest())
- for path in index_paths:
- # Index content matters too; file timestamps and process IDs do not.
- digest.update(path.name.encode())
- digest.update(hashlib.sha256(path.read_bytes()).digest() if path.exists() else b"missing")
- return "api:" + digest.hexdigest()
-
-
-def load_data():
- global records, folder_tree_data, folder_browser_data, ngram_postings, ngram_posting_buffers, word_index, _search_generation
- start = time.perf_counter()
- stage_start = start
- timings = {}
- 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 = {}
- timings["data_ms"] = round((time.perf_counter() - stage_start) * 1000, 2)
- print(f"📖 已加载 {len(records)} 条记录 ({time.perf_counter() - start:.2f}s)")
- stage_start = time.perf_counter()
- # Reader ID/pool indexes are built on first Reader use; normal search does
- # not need to scan every record to prepare them during cold startup.
- build_indexes(build_words=False, build_reader=False)
- digest = records_digest()
- timings["metadata_ms"] = round((time.perf_counter() - stage_start) * 1000, 2)
- stage_start = time.perf_counter()
- word_index = load_word_index(WORD_INDEX_PATH, len(records), digest)
- word_index_source = "generated" if word_index else "rebuilt"
- if word_index:
- build_vocab_by_len()
- else:
- build_word_index()
- timings["words_ms"] = round((time.perf_counter() - stage_start) * 1000, 2)
- stage_start = time.perf_counter()
- 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()}
- timings["ngrams_ms"] = round((time.perf_counter() - stage_start) * 1000, 2)
- stage_start = time.perf_counter()
- _search_generation = search_data_generation(raw, [WORD_INDEX_PATH, *NGRAM_PATHS.values()])
- timings["fingerprint_ms"] = round((time.perf_counter() - stage_start) * 1000, 2)
- print(f"🧭 已加载词索引: {len(word_index)}")
- print(f"🔎 已加载 ngram 索引: 2={len(ngram_postings[2])}, 3={len(ngram_postings[3])}")
- build_initial_payloads()
- precompute_injected_html()
- del raw
- stage_start = time.perf_counter()
- # A generated postings file avoids corpus tokenization, but query tokenization
- # still needs jieba's dictionary. Prepare it before accepting the first query,
- # after releasing the decoded source and finishing index-loading temporaries.
- jieba.initialize()
- timings["tokenizer_ms"] = round((time.perf_counter() - stage_start) * 1000, 2)
- timings["total_ms"] = round((time.perf_counter() - start) * 1000, 2)
- print("search_performance " + json.dumps({"stage": "startup", "word_index": word_index_source, **timings}))
-
-def score_record(rec_idx, query_tokens, search_folders=True):
- rec = records[rec_idx]
- 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:
- score += 3
- if search_folders and tok in folder_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"):
- repos_set = set(repos) if repos else None
- extensions_lower = {e.lower() for e in extensions} if extensions else None
- folder_set = {f.strip("/") for f in folders} if folders else None
- if folder_match_mode != "exact" and folder_set and "" in folder_set:
- # Unlike the Worker, an empty API prefix includes the complete scope.
- folder_set = None
- folder_prefix = next(iter(folder_set)) + "/" if folder_set and len(folder_set) == 1 and folder_match_mode != "exact" else None
- result = []
- for idx in indices:
- rec = records[idx]
- if repos_set and rec.get("Repo") not in repos_set:
- continue
- if extensions_lower:
- if rec.get("_ext_lower", "") not in extensions_lower:
- continue
- if folder_set:
- rec_path = rec["_folder_path"] if "_folder_path" in rec else "/".join(rec.get("Folder", []) or [])
- matched = rec_path in folder_set
- if not matched and folder_match_mode != "exact":
- if folder_prefix is not None:
- matched = rec_path.startswith(folder_prefix)
- else:
- end = rec_path.rfind("/")
- while end > 0:
- if rec_path[:end] in folder_set:
- matched = True
- break
- end = rec_path.rfind("/", 0, end)
- 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
- result.append(idx)
- return result
-
-def public_record(record: dict) -> dict:
- item = {
- key: value
- for key, value in record.items()
- if isinstance(key, str) and not key.startswith("_")
- }
- item.setdefault("Repo", "")
- item.setdefault("File", "")
- item.setdefault("Extension", "")
- item.setdefault("Folder", [])
- item.setdefault("Size", "")
- item.setdefault("HasTxt", False)
- item["Link"] = record.get("Link") or build_record_link(record)
- item["Path"] = record.get("Path") or build_record_path_url(record)
- return item
-
-
-def trim_results(raw):
- keys = ("Repo", "File", "Extension", "Link", "Path", "Folder", "Size", "HasTxt")
- return [
- {key: public.get(key, "" if key != "Folder" else []) for key in keys}
- for public in (public_record(record) for record in raw)
- ]
-
-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: Sequence[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) -> Sequence[int]:
- if repos:
- indices = []
- for repo in dict.fromkeys(repos):
- indices.extend(repo_records_map.get(repo, []))
- return indices
- return range(len(records))
-
-def search_generation() -> str:
- return _search_generation or f"api:{_search_instance}:{_data_generation}"
-
-
-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,
- "generation": search_generation(),
- }
-
-
-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),
- "generation": search_generation(),
- }
-
-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():
- return None
- try:
- return json.loads(path.read_text(encoding="utf-8"))
- except Exception:
- return None
-
-def build_global_bootstrap():
- sidebar = load_sidebar_payload(SIDEBAR_DATA_PATH / "global.json") or {}
- return {
- "generation": _data_generation,
- "repos": repo_list,
- "extensions": [{"name": name, "count": extension_counts[name]} for name in sorted(extension_counts)],
- "random_txt": {"available": bool(txt_record_indices), "count": len(txt_record_indices)},
- "sidebar": sidebar,
- }
-
-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("