#!/usr/bin/env python3 """ Normalize / exclude low-quality search index entries by persisting exclusion flags in source markdown. Phase 1 rules: - exclude entries with invalid source URLs - exclude entries with date-slug titles + low-signal summaries - exclude entries with tweet/numeric titles + low-signal summaries - preserve all data by marking entries instead of deleting source files Usage: python3 scripts/normalize_index_quality.py python3 scripts/normalize_index_quality.py --dry-run """ from __future__ import annotations import argparse import json import os import re import sys from pathlib import Path from typing import Any from urllib.parse import urlsplit sys.path.insert(0, str(Path(__file__).resolve().parent)) import xkb_paths import xkb_frontmatter import xkb_index WORKSPACE = xkb_paths.WORKSPACE BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR INDEX_FILE = xkb_paths.INDEX_FILE LOW_SIGNAL_SUMMARIES = {"", "(待整理)", "待整理", "todo", "tbd", "n/a"} def clean_summary(text: str) -> str: text = (text or "").strip() text = re.sub(r"^#+\s*", "", text) text = re.sub(r"^一句話摘要\s*", "", text) text = re.sub(r"^[-•]\s*", "", text) text = re.sub(r"\s+", " ", text) return text.strip() def is_valid_source_url(url: str) -> bool: try: parsed = urlsplit((url or "").strip()) if parsed.scheme not in {"http", "https"} or not parsed.hostname: return False if parsed.hostname.lower() in {"x.com", "www.x.com", "twitter.com", "www.twitter.com"}: return bool(re.match(r"/[^/]+/status/[0-9]{15,20}(?:/|$)", parsed.path)) return True except ValueError: return False # 從網路抓來的東西才該有網址。本機擷取的出處是一個檔案路徑,那不是品質問題, # 那就是它的出處——2026-09-10 實測,這條規則正要把兩張本機匯入的好卡片排除掉: # 「every-app/open-seo — Semrush/Ahrefs 開源替代方案」與那本 Obsidian 的書。 WEB_SOURCED_TYPES = {"x-bookmark", "github_star", "github_fork", "youtube"} def exclusion_reasons(item: dict[str, Any]) -> list[str]: title = (item.get("title") or "").strip() summary = clean_summary(item.get("summary") or "") source_url = (item.get("source_url") or "").strip() source_type = (item.get("source_type") or "").strip() reasons: list[str] = [] if (source_url and source_type in WEB_SOURCED_TYPES and not is_valid_source_url(source_url)): reasons.append("invalid_source_url") if re.match(r"^\d{4}-\d{2}-\d{2}-", title) and summary in LOW_SIGNAL_SUMMARIES: reasons.append("date_slug_low_signal") if re.fullmatch(r"\d{15,20}", title) and summary in LOW_SIGNAL_SUMMARIES: reasons.append("numeric_title_low_signal") if re.fullmatch(r"tweet\s+\d{15,20}", title.lower()) and summary in LOW_SIGNAL_SUMMARIES: reasons.append("tweet_numeric_low_signal") return reasons def main() -> int: parser = argparse.ArgumentParser(description="Normalize index quality") parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() raw = json.loads(INDEX_FILE.read_text(encoding="utf-8")) items = raw.get("items", raw) if isinstance(raw, dict) else raw is_dict = isinstance(raw, dict) changed = 0 excluded = 0 examples: list[tuple[str, str, list[str]]] = [] unmarkable: list[str] = [] for item in items: reasons = exclusion_reasons(item) rel_path = item.get("relative_path") or item.get("path") or "" current_excluded = bool(item.get("excluded")) current_reasons = item.get("exclude_reasons") or [] if reasons: merged = sorted(set(current_reasons) | set(reasons)) # 標在檔案上,不是索引列上。索引是衍生物,重建一次就把這個決定 # 洗掉——實測正式索引 1680 筆裡帶 excluded 的是 0 筆。 # # 而且一列代表的是一組檔案:同一份知識可能被歸進兩個分類資料夾。 # 只標其中一份的話,重建時去重會挑「沒有被排除」的那一份留下, # 排除等於沒發生——實測 8 筆標記只有 5 筆生效,而輸出說 8 筆成功。 files = xkb_index.files_for_item(item) if not files: unmarkable.append(rel_path) for path in files: if xkb_frontmatter.mark_excluded( path, "; ".join(merged), dry_run=args.dry_run): changed += 1 excluded += 1 if len(examples) < 20: examples.append((rel_path, item.get("title", ""), merged)) print(f"總項目數:{len(items)}") print(f"標記 excluded:{excluded}") print(f"實際變更:{changed}") print("\n範例:") for rel_path, title, reasons in examples: print(f"- {rel_path}") print(f" title: {title}") print(f" reasons: {', '.join(reasons)}") if unmarkable: # 標記不上跟標記了不一樣。安靜跳過的話這批會永遠標不掉,而輸出 # 看起來一切正常。 print(f"標記不上(檔案不存在或沒有 frontmatter):{len(unmarkable)}") for rel in unmarkable[:5]: print(f" - {rel}") # 這支腳本不再寫索引。決定寫在卡片檔案上,索引由 build_search_index.sh # 從檔案重算——那是唯一寫索引的地方。 if not args.dry_run and changed > 0: print(f"已標記 {changed} 張卡片。索引重建後生效:") print(" bash scripts/build_search_index.sh --incremental") return 0 import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) raise SystemExit(main())