Ctrl K
ComunidadGuías…

scripts/fetch_github_repos.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
fetch_github_repos.py

從 GitHub 抓取使用者的 fork/star repo,生成知識卡片(repo-level only,不做整包 repo ingest),
加入 search_index.json。

Usage:
    python3 scripts/fetch_github_repos.py --forks
    python3 scripts/fetch_github_repos.py --stars
    python3 scripts/fetch_github_repos.py --forks --stars
    python3 scripts/fetch_github_repos.py --forks --limit 20
    python3 scripts/fetch_github_repos.py --forks --dry-run

Exit codes:
    0  — success, 0 new cards
    1  — error
    2  — success, >= 1 new cards added
"""

import argparse
import base64
import json

import re
import subprocess
import sys
import urllib.request
from datetime import datetime, timezone
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
from runtime_config import runtime_env

# ── Shared card prompt module ─────────────────────────────────────────────────
sys.path.insert(0, str(Path(__file__).parent))
from gbrain_publish import PublicationBatch
from xkb_frontmatter import parse as extract_frontmatter
from _card_prompt import (
    build_prompt, extract_summary, find_related_context,
    llm_call as _llm_call, SOURCE_LABELS, gbrain_put as _gbrain_put,
)
from category_classifier import classify_content
from category_classifier import apply_category
SOURCE_LABELS["github_fork"] = "GitHub 倉庫(Fork)"
SOURCE_LABELS["github_star"] = "GitHub 倉庫(Star)"

# ── Paths ──────────────────────────────────────────────────────────────────────
import xkb_paths
WORKSPACE_DIR = xkb_paths.WORKSPACE
BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR
CARDS_DIR = xkb_paths.CARDS_DIR
INDEX_FILE = xkb_paths.INDEX_FILE
GITHUB_RAW_DIR = BOOKMARKS_DIR / "github"   # raw metadata per repo (source layer)

CARD_CATEGORIES = [
    "ai-tools", "developer-tools", "workflows", "data",
    "startup", "design", "tech", "learning", "other"
]


def load_env_key() -> str:
    return runtime_env().get("LLM_API_KEY", "")


# llm_call imported from _card_prompt as _llm_call


def gh_api(endpoint: str) -> list:
    """Call gh CLI with pagination and return merged list."""
    result = subprocess.run(
        ["gh", "api", "--paginate", endpoint],
        capture_output=True, text=True
    )
    if result.returncode != 0:
        print(f"  [ERROR] gh api {endpoint}: {result.stderr.strip()}", file=sys.stderr)
        return []
    text = result.stdout.strip()
    # gh --paginate may return multiple JSON arrays on separate lines
    items = []
    for line in text.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            parsed = json.loads(line)
            if isinstance(parsed, list):
                items.extend(parsed)
            elif isinstance(parsed, dict):
                items.append(parsed)
        except Exception:
            pass
    return items or json.loads(text) if text else []


def get_readme_preview(full_name: str, max_chars: int = 500) -> str:
    """Fetch README first N chars. Returns empty string on any error — not fatal."""
    result = subprocess.run(
        ["gh", "api", f"repos/{full_name}/readme", "--jq", ".content"],
        capture_output=True, text=True
    )
    if result.returncode != 0 or not result.stdout.strip():
        return ""
    try:
        decoded = base64.b64decode(result.stdout.strip()).decode("utf-8", errors="ignore")
        decoded = re.sub(r'#+\s*', '', decoded)
        decoded = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', decoded)
        decoded = re.sub(r'!\[.*?\]\(.*?\)', '', decoded)
        return decoded[:max_chars].strip()
    except Exception:
        return ""


def load_index() -> dict:
    if INDEX_FILE.exists():
        return json.loads(INDEX_FILE.read_text(encoding="utf-8"))
    return {"version": "1.1", "items": []}


# extract_summary imported from _card_prompt


def filter_stars(repos: list) -> list:
    return [r for r in repos
            if not r.get("archived")
            and (r.get("description") or r.get("topics"))]


def make_dedup_key(url: str, action_type: str) -> str:
    """Dedup key is (source_url, source_type) — fork and star of same repo are different entries."""
    return f"{url}|{action_type}"


def process_repos(repos: list, action_type: str, dry_run: bool, api_key: str,
                  existing_keys: set, limit: int,
                  existing_items: list[dict] | None = None, publication=None) -> tuple[int, list[dict]]:
    """
    Returns (count_processed, new_index_items).
    Does NOT write to index — caller batches and saves once.
    """
    subdir = GITHUB_RAW_DIR / (action_type.replace("github_", "") + "s")
    subdir.mkdir(parents=True, exist_ok=True)
    CARDS_DIR.mkdir(parents=True, exist_ok=True)

    new_items = []
    processed = 0
    attempted = 0
    batch = publication or PublicationBatch(_gbrain_put)

    for repo in repos:
        if batch.blocked:
            break

        full_name = repo.get("full_name", "")
        url = repo.get("html_url", f"https://github.com/{full_name}")
        dedup_key = make_dedup_key(url, action_type)

        card_id = f"{action_type}-{full_name.replace('/', '-')}"
        saved = CARDS_DIR / f"{card_id}.md"
        if not dry_run and batch.existing(saved, card_id):
            continue
        if dedup_key in existing_keys:
            print(f"  [SKIP] 已存在:{full_name} ({action_type})")
            continue

        if limit and attempted >= limit:
            continue
        attempted += 1

        description = repo.get("description") or "(no description)"
        language = repo.get("language") or "unknown"
        topics = ", ".join(repo.get("topics", [])) or "(none)"
        stars = repo.get("stargazers_count", 0)

        print(f"  📦 {full_name} [{language}] {'★'+str(stars) if stars else ''}")

        if dry_run:
            print(f"     → {description[:80]}")
            processed += 1
            continue

        # Save raw metadata (source layer)
        safe_name = full_name.replace("/", "__")
        raw_path = subdir / f"{safe_name}.json"
        raw_path.write_text(json.dumps({
            "full_name": full_name, "url": url, "description": description,
            "language": language, "topics": repo.get("topics", []),
            "stars": stars, "archived": repo.get("archived", False),
            "pushed_at": repo.get("pushed_at", ""), "action_type": action_type,
            "fetched_at": datetime.now(timezone.utc).isoformat(),
        }, ensure_ascii=False, indent=2), encoding="utf-8")

        # Fetch README preview (non-fatal)
        readme = get_readme_preview(full_name)

        # Card ID (also used in frontmatter and filename)
        card_id = f"{action_type}-{full_name.replace('/', '-')}"

        # Generate 9-section card via shared _card_prompt
        content = (
            f"Repo: {full_name}\n"
            f"Description: {description}\n"
            f"Language: {language}\n"
            f"Topics: {topics}\n"
            f"Stars: {stars}\n"
            f"Action: {action_type} "
            f"(github_fork = 主動複製並可能修改;github_star = 標記感興趣)\n\n"
            f"README:\n{readme or '(not available)'}"
        )
        related = find_related_context(content, existing_items or [])
        classified = classify_content(content, source_type=action_type, current_category="tech")
        prompt = build_prompt(
            content=content,
            card_id=card_id,
            source_type=action_type,
            source_url=url,
            category=classified["category"],
            related_context=related,
        )
        try:
            card_content = _llm_call(prompt, api_key)
        except Exception as e:
            print(f"     ❌ LLM 失敗:{e}")
            continue

        # Inject id into frontmatter if missing
        if "---\n" in card_content and "id:" not in card_content:
            card_content = card_content.replace("---\n", f"---\nid: {card_id}\n", 1)
        card_content = apply_category(card_content, classified["category"])

        # Save LLM card to memory/cards/
        card_path = CARDS_DIR / f"{card_id}.md"
        card_path.write_text(card_content, encoding="utf-8")
        batch.publish(card_path, card_id, generated=True)
        print(f"     💾 memory/cards/{card_id}.md")

        # Build index item
        fm = extract_frontmatter(card_content)
        summary = extract_summary(card_content)
        tags_raw = fm.get("tags", "")
        tags = list({t.strip() for t in tags_raw.split(",") if t.strip()})
        if language and language != "unknown":
            tags.append(language.lower())

        new_items.append({
            "path": str(card_path),
            "relative_path": f"cards/{card_id}.md",
            "title": fm.get("title", full_name),
            "category": fm.get("category", classified["category"]),
            "tags": list(set(tags)),
            "summary": summary,
            "source_url": url,
            "source_type": action_type,
            "searchable": f"{full_name} {description} {topics} {summary} {' '.join(tags)}",
            "mtime": datetime.now(timezone.utc).isoformat(),
            "size": card_path.stat().st_size,
            "enriched": True,
            "language": language,
        })
        processed += 1

    return processed, new_items


def main():
    parser = argparse.ArgumentParser(
        description="Fetch GitHub forks/stars into XKB (repo-level cards only)")
    parser.add_argument("--forks", action="store_true")
    parser.add_argument("--stars", action="store_true")
    parser.add_argument("--limit", type=int, default=0, help="Max repos per type (0=all)")
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--no-filter", action="store_true", help="Disable star quality filter")
    args = parser.parse_args()

    if not args.forks and not args.stars:
        parser.error("Specify --forks and/or --stars")

    api_key = "" if args.dry_run else load_env_key()
    if not api_key and not args.dry_run:
        print("[ERROR] LLM_API_KEY not found")
        return 1

    # Build dedup key set from existing index
    index_data = load_index()
    existing_items_list = index_data.get("items", [])
    existing_keys = {
        make_dedup_key(item.get("source_url", ""), item.get("source_type", ""))
        for item in existing_items_list
    }

    batch = PublicationBatch(_gbrain_put)
    if not args.dry_run and not batch.ready():
        return 1
    all_new_items: list[dict] = []
    total = 0

    if args.forks:
        print("\n🔀 Fetching forks...")
        repos = gh_api("user/repos?type=fork&per_page=100")
        print(f"   Found {len(repos)} forks")
        n, items = process_repos(repos, "github_fork", args.dry_run, api_key,
                                 existing_keys, args.limit, existing_items_list, batch)
        print(f"   ✅ Processed {n} fork cards")
        total += n
        all_new_items.extend(items)

    if args.stars and not batch.blocked:
        print("\n⭐ Fetching stars...")
        repos = gh_api("user/starred?per_page=100")
        if not args.no_filter:
            before = len(repos)
            repos = filter_stars(repos)
            print(f"   Found {before} stars → {len(repos)} after filtering")
        else:
            print(f"   Found {len(repos)} stars (no filter)")
        n, items = process_repos(repos, "github_star", args.dry_run, api_key,
                                 existing_keys, args.limit, existing_items_list, batch)
        print(f"   ✅ Processed {n} star cards")
        total += n
        all_new_items.extend(items)

    # Rebuild once from the cards on disk.
    if not args.dry_run and not batch.finish():
        return 1
    if total and not args.dry_run:
        print(f"\n✅ 完成:共新增 {total} 張 GitHub 知識卡片,已寫入索引")
        print("💡 執行以下指令更新語意索引:")
        print("   python3 scripts/build_vector_index.py --incremental")
    else:
        print(f"\n✅ 完成:共新增 {total} 張 GitHub 知識卡片")

    # Exit code 2 = new cards added (for run_github_sync.sh to detect)
    return 2 if total > 0 and not args.dry_run else 0


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

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