#!/usr/bin/env python3 """ Sync enriched cards from memory/cards/ back into search_index.json. For each enriched card found in memory/cards/: - Update path to point to memory/cards/[id].md - Keep relative_path stable and truly relative - Extract and update summary from "## 1. 核心摘要" section - Extract and update tags from frontmatter - Extract and update title from "# Title" line - Set enriched: true - Only add orphan cards when they have valid, stable metadata Usage: python3 scripts/sync_enriched_index.py python3 scripts/sync_enriched_index.py --dry-run """ from __future__ import annotations import argparse import json import os import re import sys from pathlib import Path from urllib.parse import urlparse sys.path.insert(0, str(Path(__file__).resolve().parent)) import xkb_paths import xkb_index import xkb_frontmatter WORKSPACE = xkb_paths.WORKSPACE BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR CARDS_DIR = xkb_paths.CARDS_DIR INDEX_FILE = xkb_paths.INDEX_FILE def _extract_summary(text: str) -> str: # ── New format: ## 7. 雙語摘要(搜尋索引用)with ZH:/EN: labels ────────── bilingual = re.search(r"##\s+7\.\s*雙語摘要[^\n]*\n(.+?)(?=\n##|\Z)", text, re.DOTALL) if bilingual: block = bilingual.group(1) zh_m = re.search(r"^(?:ZH|中文)[::]\s*(.+)$", block, re.MULTILINE) en_m = re.search(r"^(?:EN|英文)[::]\s*(.+)$", block, re.MULTILINE) parts = [m.group(1).strip() for m in [zh_m, en_m] if m and m.group(1).strip()] if parts: return " | ".join(parts) # ── Legacy formats (old worker cards, github/local ingest cards) ────────── patterns = [ r"##\s+1\.\s*核心摘要\s*\n(.+?)(?=\n##|\Z)", r"##\s+1\.\s*English Summary\s*\n(.+?)(?=\n##|\Z)", r"##\s*📝\s*English Summary\s*\n(.+?)(?=\n##|\Z)", ] parts = [] for pat in patterns: m = re.search(pat, text, re.DOTALL) if m: lines = [l.strip().lstrip("-").strip() for l in m.group(1).strip().splitlines() if l.strip()] parts.append(" ".join(lines)[:300]) return " | ".join(parts) if parts else "" def _extract_tags(text: str) -> list[str]: m = re.search(r"^tags:\s*\[(.+?)\]", text, re.MULTILINE) if m: return [t.strip().strip('"').strip("'") for t in m.group(1).split(",") if t.strip()] return [] def _extract_title(text: str) -> str: m = re.search(r"^# (.+)$", text, re.MULTILINE) return m.group(1).strip() if m else "" def _extract_frontmatter_value(text: str, key: str) -> str: m = re.search(r"^" + re.escape(key) + r":\s*[\"']?([^\"'\n]+)[\"']?\s*$", text, re.MULTILINE) return m.group(1).strip() if m else "" def _extract_status_id(value: str) -> str: if not value: return "" m = re.search(r"/status/(\d{15,20})", value) return m.group(1) if m else "" def _looks_like_url(value: str) -> bool: if not value: return False try: parsed = urlparse(value) return parsed.scheme in {"http", "https"} and bool(parsed.netloc) except Exception: return False def _placeholder_text(value: str) -> bool: v = (value or "").strip().lower() return v in {"", "待整理", "待補充", "tbd", "todo", "n/a", "untitled", "(untitled)"} def _relative_from_workspace(path: Path) -> str: return str(path.relative_to(WORKSPACE)) def _resolve_item_id(item: dict) -> str: source_url = item.get("source_url", "") source_id = _extract_status_id(source_url) if source_id: return source_id for key in ("relative_path", "path"): stem = Path(item.get(key, "")).stem if re.fullmatch(r"\d{15,20}", stem): return stem m = re.match(r"^(\d{15,20})", stem) if m: return m.group(1) # Non-numeric stems (github_star-xxx, github_fork-xxx, local cards) — use full stem as id if stem: return stem return "" def _resolve_card_id(text: str, stem: str) -> str: # Non-numeric stems (github/local cards) — stem itself is the stable id if stem and not re.match(r"\d", stem): return stem if re.fullmatch(r"\d{15,20}", stem): return stem m = re.match(r"^(\d{15,20})", stem) if m: return m.group(1) for key in ("id", "tweet_id", "source_url"): value = _extract_frontmatter_value(text, key) resolved = _extract_status_id(value) if key == "source_url" else (value if re.fullmatch(r"\d{15,20}", value) else "") if resolved: return resolved m = re.search(r"/status/(\d{15,20})", text) if m: return m.group(1) return "" def _is_valid_orphan_card(text: str, card_id: str, source_url: str, title: str, summary: str) -> bool: _cleaned = re.sub(r"[\s\S]*?", "", text).strip() if not _cleaned.startswith("---"): return False card_type = _extract_frontmatter_value(text, "type") if card_type not in {"x-knowledge-card", "knowledge-card"}: return False source_type = _extract_frontmatter_value(text, "source_type") if source_type not in {"x-bookmark", "youtube", "github_fork", "github_star", "local-paper", "pubmed"}: return False if not _looks_like_url(source_url): return False if source_type == "x-bookmark" and not _extract_status_id(source_url): return False if source_type in {"youtube", "github_fork", "github_star", "local-paper", "pubmed"} and not card_id: return False if _placeholder_text(title) and _placeholder_text(summary): return False return True def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() if not CARDS_DIR.exists(): print(f"❌ Cards dir not found: {CARDS_DIR}") sys.exit(1) if not INDEX_FILE.exists(): print(f"❌ Index not found: {INDEX_FILE}") sys.exit(1) 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) id_to_idx: dict[str, int] = {} for idx, item in enumerate(items): item_id = _resolve_item_id(item) if item_id: id_to_idx[item_id] = idx card_files = xkb_paths.card_files() print(f"📁 {len(card_files)} enriched cards | {len(id_to_idx)} indexed items") updated = 0 unexcluded = 0 not_found_ids: list[str] = [] for card_path in card_files: raw_card_id = card_path.stem text = card_path.read_text(encoding="utf-8", errors="ignore") resolved_id = _resolve_card_id(text, raw_card_id) if not resolved_id or resolved_id not in id_to_idx: not_found_ids.append(raw_card_id) continue idx = id_to_idx[resolved_id] item = items[idx] summary = _extract_summary(text) tags = _extract_tags(text) title = _extract_title(text) category = _extract_frontmatter_value(text, "category") new_path = str(card_path) new_relative_path = _relative_from_workspace(card_path) changes: dict = {} if item.get("path") != new_path: changes["path"] = new_path if item.get("relative_path") != new_relative_path: changes["relative_path"] = new_relative_path if summary and item.get("summary") != summary: changes["summary"] = summary if tags and item.get("tags") != tags: changes["tags"] = tags if title and item.get("title") != title: changes["title"] = title if category and item.get("category") != category: changes["category"] = category if not item.get("enriched"): changes["enriched"] = True # 充實之後要把過期的排除標記撤掉——而且要撤在檔案上。 # # 這一段原本只改索引列。索引現在是衍生物,所以那是死碼:下一次重建會 # 從卡片檔案把 excluded: true 原封不動讀回來。結果是排除變成單行道—— # 一張被誤排除的卡(例如被那個「空欄位吞掉下一行」的解析 bug 害到的那 # 兩張),即使後來補上了標題與摘要,也沒有任何管線步驟能把它救回來, # 只能手動改檔案。原本反而是安全的:那時候下一次重建就洗掉了。 # # 只撤品質類的理由。duplicate_source_url 不是品質問題,撤掉它會把一份 # 重複的副本放回召回裡。 if item.get("excluded") and title and summary and not title.startswith("Tweet "): reason = str(item.get("excluded_reason") or "") if "duplicate_source_url" not in reason: for path in xkb_index.files_for_item(item): if xkb_frontmatter.unmark_excluded(path, dry_run=args.dry_run): unexcluded += 1 if changes: if not args.dry_run: item.update(changes) updated += 1 if args.dry_run: print(f" [dry-run] {raw_card_id}: {list(changes.keys())}") print(f"✅ Updated: {updated} | Not in index: {len(not_found_ids)}") if unexcluded: print(f"↩️ 撤掉 {unexcluded} 個過期的排除標記(卡片已補上標題與摘要)") if not_found_ids: print(f" (first 5 not found: {not_found_ids[:5]})") added = 0 existing_ids = {_resolve_item_id(item) for item in items} existing_urls = {(item.get("source_url") or "").strip() for item in items if (item.get("source_url") or "").strip()} existing_paths = {(item.get("relative_path") or item.get("path") or "").strip() for item in items} for raw_card_id in not_found_ids: card_path = CARDS_DIR / f"{raw_card_id}.md" if not card_path.exists(): continue text = card_path.read_text(encoding="utf-8", errors="ignore") title = _extract_title(text) summary = _extract_summary(text) tags = _extract_tags(text) source_url = _extract_frontmatter_value(text, "source_url") category = _extract_frontmatter_value(text, "category") source_type = _extract_frontmatter_value(text, "source_type") resolved_id = _resolve_card_id(text, raw_card_id) or raw_card_id if not _is_valid_orphan_card(text, resolved_id, source_url, title, summary): continue card_abs = str(card_path) card_rel = _relative_from_workspace(card_path) if resolved_id in existing_ids or source_url in existing_urls or card_rel in existing_paths or card_abs in existing_paths: continue new_entry = { "id": resolved_id, "path": card_abs, "relative_path": card_rel, "title": title, "summary": summary, "tags": tags, "source_url": source_url, "category": category, "source_type": source_type, "enriched": True, } if not args.dry_run: items.append(new_entry) added += 1 if args.dry_run: print(f" [dry-run] ADD {resolved_id}: {title[:60]}") if added: print(f"➕ Added {added} new index entries from orphaned cards") # 這支腳本不再寫索引。 # # 它做的事——把卡片的標題/摘要/標籤同步進索引列、把孤兒卡補進去—— # build_search_index.sh 從檔案重算時本來就全做了,而且做得更全:實測 # 一次乾淨重建找回三張這支腳本漏掉的卡(它只走 X 書籤 ID 那條路, # github_star-* 與 legacy-* 那類卡片它掃不到)。 # # 保留這個入口是為了不打斷既有的呼叫與習慣;真正做事的是 builder。 if not args.dry_run and (updated > 0 or added > 0 or not_found_ids): xkb_index.rebuild() import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) main()