Ctrl K
ComunidadGuías…

scripts/sync_tiege_queue.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Sync XKB bookmark markdown files into tiege-queue.json.

This is the bridge between raw bookmark ingestion and run_bookmark_worker.py.
It scans memory/bookmarks/**/*.md, reconciles existing cards, and preserves
existing queue state where possible.
"""
from __future__ import annotations

import json
import os
import re
from datetime import datetime, timezone
import sys
from pathlib import Path

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

import xkb_failures
import xkb_paths

WORKSPACE = xkb_paths.WORKSPACE
BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR
CARDS_DIR = xkb_paths.CARDS_DIR
QUEUE_PATH = xkb_paths.QUEUE_PATH

# abandoned 是重試用盡之後的終點(見 xkb_requeue_failed.py)。不列進來的話,
# 下面那個「不認得就重設成 todo」會讓已經放棄的項目每次同步都回到佇列,
# 於是「放棄」變成一個什麼都不代表的狀態。
VALID_STATUSES = {"todo", "processing", "done", "failed", "skipped", "abandoned"}


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def rel(path: Path) -> str:
    try:
        return str(path.relative_to(WORKSPACE))
    except ValueError:
        return str(path)


def parse_frontmatter_value(text: str, key: str) -> str:
    m = re.search(rf"^{re.escape(key)}:\s*\"?([^\"\n]+)\"?\s*$", text, re.MULTILINE)
    return m.group(1).strip() if m else ""


def parse_tweet_id(path: Path, text: str) -> str:
    for key in ("tweet_id", "id"):
        val = parse_frontmatter_value(text, key)
        if re.fullmatch(r"\d{10,}", val):
            return val
    return path.stem if re.fullmatch(r"\d{10,}", path.stem) else ""


def parse_title(path: Path, text: str) -> str:
    title = parse_frontmatter_value(text, "title")
    if title:
        return title
    m = re.search(r"^#\s+(.+)$", text, re.MULTILINE)
    if m:
        return m.group(1).strip()
    return f"Tweet {path.stem}" if path.stem.isdigit() else path.stem


def parse_source_url(text: str, tweet_id: str) -> str:
    for key in ("source_url", "original_url"):
        val = parse_frontmatter_value(text, key)
        if val:
            return val
    return f"https://x.com/i/status/{tweet_id}" if tweet_id else ""


def category_for(path: Path) -> str:
    try:
        parts = path.relative_to(BOOKMARKS_DIR).parts
        # inbox files are still valid raw bookmarks, but their category is not final yet.
        if len(parts) >= 2 and parts[0] != "inbox":
            return parts[0]
    except Exception:
        pass
    return "99-general"


def load_existing_queue() -> dict[str, dict]:
    if not QUEUE_PATH.exists():
        return {}
    try:
        data = json.loads(QUEUE_PATH.read_text(encoding="utf-8"))
        return {str(item.get("id")): item for item in data.get("items", []) if item.get("id")}
    except Exception as err:
        # 佇列讀不動會被當成空佇列,於是已經處理過的東西全部重排一次。
        xkb_failures.note("tiege queue", err, detail=str(QUEUE_PATH))
        return {}


def should_skip_path(path: Path) -> bool:
    name = path.name
    if name.startswith(".") or name in {"INDEX.md", "README.md"}:
        return True
    # Generated indexes/backups are not raw bookmarks.
    if "search_index" in name or "vector_index" in name or name.endswith(".bak"):
        return True
    if "notebooklm_exports" in path.parts:
        return True
    return False


def main() -> int:
    existing = load_existing_queue()
    # 去重移走的卡片也算「處理過」,否則來源書籤會被重排、重新產出重複的卡。
    existing_card_ids = xkb_paths.carded_ids()
    ts = now_iso()

    rows: list[dict] = []
    seen: set[str] = set()

    for path in sorted(BOOKMARKS_DIR.rglob("*.md")):
        if should_skip_path(path):
            continue
        text = path.read_text(encoding="utf-8", errors="ignore")
        tweet_id = parse_tweet_id(path, text)
        if not tweet_id or tweet_id in seen:
            continue
        seen.add(tweet_id)

        old = existing.get(tweet_id, {})
        status = old.get("status") or "todo"
        if status not in VALID_STATUSES:
            status = "todo"

        worker = old.get("worker", "")
        started_at = old.get("started_at", "")
        finished_at = old.get("finished_at", "")
        error = old.get("error", "")

        if tweet_id in existing_card_ids and status != "processing":
            status = "done"
            worker = worker or "reconcile"
            finished_at = finished_at or ts
            error = ""
        elif status in {"failed", "skipped", "abandoned"}:
            # Preserve explicit failed/skipped states; operator can reset if needed.
            pass
        elif status == "processing":
            # Avoid clobbering in-flight work.
            pass
        else:
            status = "todo"

        rows.append({
            "id": tweet_id,
            "title": old.get("title") or parse_title(path, text),
            "source_path": rel(path),
            "source_url": old.get("source_url") or parse_source_url(text, tweet_id),
            "category": old.get("category") or category_for(path),
            "status": status,
            "priority": old.get("priority", "normal"),
            "worker": worker,
            "started_at": started_at,
            "finished_at": finished_at,
            "error": error,
        })

    payload = {
        "version": 4,
        "updated_at": ts,
        "mode": "single-item",
        "notes": "Canonical single-item queue synced from memory/bookmarks for tiege processing and reconciled against memory/cards.",
        "items": rows,
    }

    QUEUE_PATH.parent.mkdir(parents=True, exist_ok=True)
    QUEUE_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    todo = sum(1 for r in rows if r["status"] == "todo")
    done = sum(1 for r in rows if r["status"] == "done")
    print(f"✅ queue synced: {QUEUE_PATH} ({len(rows)} items, todo={todo}, done={done})")
    return 0


import xkb_usage  # noqa: E402  — 量測誰在跑,見 scripts/xkb_usage.py

if __name__ == "__main__":
    xkb_usage.record(__file__)
    raise SystemExit(main())