scripts/xkb_index.py
Rama main · réplica de lectura del repositorio original.
#!/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)