Ctrl K
ComunidadGuías…

scripts/normalize_index_quality.py

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

Archivo original ↗Preguntar a KiBot
#!/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())