Ctrl K
ComunidadGuías…

scripts/xkb_index.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""重建搜尋索引——唯一寫 search_index.json 的地方。

索引是衍生物。它的每一個欄位都是從卡片與書籤檔案解析出來的,2026-09-10 實測:
把它整份從檔案重建,1680 筆一模一樣,而且比正在用的那份還準——重建找回了三張
現行索引漏掉的卡,也解掉了一組同一份內容被記成兩個路徑的分歧。

會漂走,是因為原本有八支程式各自對它做讀-改-寫:

    build_search_index.sh   從檔案重建
    sync_enriched_index.py  只走 X 書籤 ID 那條路
    canonicalize_duplicates.py / normalize_index_quality.py   標記品質
    fetch_github_repos.py / fetch_youtube_playlist.py / local_ingest.py   各自寫入
    migrate_schema.py       遷移

每一支都合併而不是覆蓋,所以不會大規模掉資料;但沒有共用鎖,而且各自對「這
是同一個來源嗎」有不同的認定——`github-x`、`github_fork-x`、`github_star-x`
會被當成三個東西。於是索引慢慢跟磁碟上的檔案說的不一樣,而沒有任何地方會發現。

現在的規則只有一條:**擷取器寫檔案,索引由這裡從檔案重算。**

這條規則附帶一個約束,而且是刻意的:任何值得留下來的東西都必須寫進卡片檔案,
不能只寫進索引。原本違反這條規則的兩樣——「排除低品質」與「標記重複」的旗標
——實測在正式索引 1680 筆裡剩 0 筆,早就被重建洗光了,而讀它的四個地方一直讀
到 False,沒有任何錯誤。
"""
from __future__ import annotations

import os
import re
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import xkb_failures
import xkb_paths

BUILDER = xkb_paths.SCRIPTS_DIR / "build_search_index.sh"


_SOURCE_URL = re.compile(r'^source_url:[ \t]*"?([^"\n]*?)"?[ \t]*$', re.MULTILINE)

_by_stem: dict[str, list[Path]] | None = None
_by_url: dict[str, list[Path]] | None = None


def _scan() -> tuple[dict[str, list[Path]], dict[str, list[Path]]]:
    """掃一次磁碟,建 stem → 檔案 與 source_url → 檔案 兩張表。

    每一列各自去掃全部檔案的話是 1680 × 3300 次讀檔。掃一次就夠。
    """
    global _by_stem, _by_url
    if _by_stem is not None and _by_url is not None:
        return _by_stem, _by_url
    by_stem: dict[str, list[Path]] = {}
    by_url: dict[str, list[Path]] = {}
    for root in (xkb_paths.CARDS_DIR, xkb_paths.BOOKMARKS_DIR):
        if not root.exists():
            continue
        for found in root.rglob("*.md"):
            if found.name.startswith(".") or not found.is_file():
                continue
            by_stem.setdefault(found.stem, []).append(found)
            try:
                text = found.read_text(encoding="utf-8", errors="ignore")
            except OSError:
                continue
            match = _SOURCE_URL.search(text)
            url = match.group(1).strip() if match else ""
            # 只有真的是網址才當識別依據。空字串會把所有沒有來源的卡片串成一組。
            if url.startswith(("http://", "https://")):
                by_url.setdefault(url, []).append(found)
    _by_stem, _by_url = by_stem, by_url
    return by_stem, by_url


def reset_scan() -> None:
    """測試用,或磁碟在同一個行程裡被改過之後。"""
    global _by_stem, _by_url
    _by_stem = _by_url = None


def files_for_item(item: dict, *, by_source: bool = True) -> list[Path]:
    """索引的一列代表的是「一組檔案」,不是一個檔案。

    by_source 決定「一組」有多大,而這個差別很要緊:

      True (預設)連同來源相同的其他檔案一起——品質排除要的是這個。一個
            沒被充實過的空殼書籤,它的每一份副本都該一起排除。

      False 只認路徑與檔名相同的那些。去重要的是這個:重複組本來就共用同一
            個來源,用來源去找會連「要保留的那一份」都撈進來。2026-09-10 我
            就是這樣寫的,結果整組七份全被標成排除——那份知識會從召回裡完全
            消失,而輸出只會說「已標記」。


    索引會依 source_url 去重,所以同一份知識在磁碟上可能有兩三個檔案——同一
    個書籤被歸進兩個分類資料夾、或書籤與卡片各一份——而索引裡只留下最好的那
    一列。

    2026-09-10:品質腳本照著那一列去標記檔案,標到的是「贏的那一列指向的檔
    案」,另外那幾份沒標。重建之後去重會挑「沒有被排除」的那一份留下,於是
    排除等於沒有發生:8 筆標記,索引裡只看得到 5 筆生效,而兩支腳本都回報成功。

    所以要標就要標齊。這裡回傳同一份知識的所有檔案。
    """
    by_stem, by_url = _scan()
    paths: list[Path] = []
    seen: set[str] = set()

    def _add(path: Path) -> None:
        key = str(path.resolve())
        if key not in seen and path.is_file():
            seen.add(key)
            paths.append(path)

    raw = (item.get("path") or "").strip()
    if raw and Path(raw).is_absolute():
        _add(Path(raw))
    # 這一列自己的檔案。relative_path 的基準歷來有三種寫法,全部試過。
    rel = (item.get("relative_path") or "").strip()
    if rel:
        for base in (xkb_paths.WORKSPACE, xkb_paths.BOOKMARKS_DIR,
                     xkb_paths.CARDS_DIR):
            _add(base / rel)

    # 以下是「擴散到同一份知識的其他副本」,只在整組模式下做。
    #
    # 原本這段靠檔名的擴散在 by_source 判斷之外,於是 by_source=False 仍然會
    # 跨目錄擴散:normalize_index_quality 標一筆原始書籤時,會連 cards/ 裡同名
    # 的那張充實過的卡片一起標掉。去重那邊沒事只是因為它另外算了 keep_files,
    # 品質排除沒有那道保險。
    if by_source:
        stem = Path(rel or raw).stem
        for found in by_stem.get(stem, []):
            _add(found)
        url = (item.get("source_url") or "").strip()
        if url.startswith(("http://", "https://")):
            for found in by_url.get(url, []):
                _add(found)
    return paths


def rebuild(*, incremental: bool = True, timeout: int = 900) -> bool:
    """從檔案重算索引。回傳有沒有成功。

    增量模式只重新解析 mtime/size 變過的檔案,所以每次擷取之後跑一次的成本,
    跟原本「自己修補幾列」是同一個數量級——差別在結果由檔案決定,不是由呼叫
    順序決定。
    """
    if not BUILDER.exists():
        xkb_failures.note("search index rebuild",
                          FileNotFoundError(str(BUILDER)), repeat=True)
        return False

    env = {
        **os.environ,
        "WORKSPACE_DIR": str(xkb_paths.WORKSPACE),
        "BOOKMARKS_DIR": str(xkb_paths.BOOKMARKS_DIR),
        "CARDS_DIR": str(xkb_paths.CARDS_DIR),
        "INDEX_FILE": str(xkb_paths.INDEX_FILE),
        "PYTHONIOENCODING": "utf-8",
        "PYTHONUTF8": "1",
    }
    cmd = ["bash", str(BUILDER)]
    if incremental:
        cmd.append("--incremental")

    try:
        result = subprocess.run(cmd, env=env, capture_output=True, text=True,
                                timeout=timeout)
    except (OSError, subprocess.SubprocessError) as err:
        xkb_failures.note("search index rebuild", err, repeat=True)
        return False

    if result.returncode != 0:
        # 重建失敗要說話。安靜失敗的話,擷取器會回報「收了 N 筆」而那 N 筆
        # 查不到——正是這個模組要消滅的那種等價。
        xkb_failures.note(
            "search index rebuild",
            RuntimeError(f"exit {result.returncode}: {(result.stderr or '').strip()[:200]}"),
            repeat=True)
        return False

    tail = (result.stdout or "").strip().splitlines()
    if tail:
        print(tail[-1])
    return True


def finish_ingest(saved_count: int) -> bool:
    """Complete an ingest only after the saved cards are searchable."""
    if rebuild():
        return True
    print(
        f"[ERROR] {saved_count} card(s) saved, but the search index rebuild failed. "
        "Keep the cards and repair the index with the same data configuration: "
        "python3 scripts/xkb_index.py --rebuild",
        file=sys.stderr,
    )
    return False


if __name__ == "__main__":
    import argparse
    import xkb_usage

    parser = argparse.ArgumentParser(description="Rebuild the search index from saved cards")
    parser.add_argument("--rebuild", action="store_true", required=True)
    parser.parse_args()
    xkb_usage.record(__file__)
    raise SystemExit(0 if rebuild() else 1)