scripts/fetch_github_repos.py
Rama main · réplica de lectura del repositorio original.
#!/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())