refactor: 统一所有渠道后端,对齐 research 技能
GitHub: REST API → gh CLI(官方工具,认证后完整能力) Bilibili: 自写 API → yt-dlp(和 YouTube 统一后端,支持搜索 bilisearch) YouTube: 新增搜索功能(ytsearch via yt-dlp) README 中英文同步更新: - 平台表格:小红书/Exa/GitHub/YouTube/B站 描述全部更新 - 选型表格:新增 gh CLI、xiaohongshu-mcp,更新 yt-dlp/Exa 描述 - 按需解锁:去掉 Exa Key 注册步骤(已自动配置) - 配置难度说明:新增「自动配置」「mcporter」级别
This commit is contained in:
+101
-90
@@ -1,11 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""GitHub — via GitHub REST API (free, no config needed).
|
||||
"""GitHub — via gh CLI.
|
||||
|
||||
Backend: GitHub API v3
|
||||
Swap to: gh CLI, or any GitHub API wrapper
|
||||
Backend: gh CLI (https://cli.github.com)
|
||||
Swap to: GitHub REST API
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from urllib.parse import urlparse
|
||||
from .base import Channel, ReadResult, SearchResult
|
||||
from typing import List
|
||||
@@ -14,115 +16,124 @@ from typing import List
|
||||
class GitHubChannel(Channel):
|
||||
name = "github"
|
||||
description = "GitHub 仓库和代码"
|
||||
backends = ["GitHub API"]
|
||||
backends = ["gh CLI"]
|
||||
tier = 0
|
||||
|
||||
API = "https://api.github.com"
|
||||
def _gh(self, args: list, timeout: int = 15) -> str:
|
||||
r = subprocess.run(
|
||||
["gh"] + args,
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(r.stderr or r.stdout)
|
||||
return r.stdout
|
||||
|
||||
def _headers(self, config=None):
|
||||
h = {"Accept": "application/vnd.github+json"}
|
||||
token = config.get("github_token") if config else None
|
||||
if token:
|
||||
h["Authorization"] = f"Bearer {token}"
|
||||
return h
|
||||
|
||||
def check(self, config=None):
|
||||
import shutil
|
||||
token = config.get("github_token") if config else None
|
||||
has_gh = shutil.which("gh")
|
||||
if token or has_gh:
|
||||
return "ok", "完整可用(读取、搜索、Fork、Issue、PR 等)"
|
||||
return "ok", "公开仓库可读可搜。配置 gh CLI 或 github_token 可解锁 Fork、Issue、PR 等操作"
|
||||
def _gh_json(self, args: list, timeout: int = 15) -> dict:
|
||||
return json.loads(self._gh(args + ["--json"], timeout))
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
domain = urlparse(url).netloc.lower()
|
||||
return "github.com" in domain
|
||||
return "github.com" in urlparse(url).netloc.lower()
|
||||
|
||||
def check(self, config=None):
|
||||
if not shutil.which("gh"):
|
||||
return "warn", "gh CLI 未安装。安装:https://cli.github.com 。公开仓库仍可通过 Jina Reader 读取"
|
||||
try:
|
||||
self._gh(["auth", "status"], timeout=5)
|
||||
return "ok", "完整可用(读取、搜索、Fork、Issue、PR 等)"
|
||||
except Exception:
|
||||
return "ok", "gh CLI 已装但未认证。运行 gh auth login 可解锁完整功能"
|
||||
|
||||
async def read(self, url: str, config=None) -> ReadResult:
|
||||
path = urlparse(url).path.strip("/").split("/")
|
||||
if not shutil.which("gh"):
|
||||
# Fallback to Jina Reader for public repos
|
||||
from agent_reach.channels.web import WebChannel
|
||||
return await WebChannel().read(url, config)
|
||||
|
||||
path = urlparse(url).path.strip("/").split("/")
|
||||
if len(path) < 2:
|
||||
raise ValueError(f"Invalid GitHub URL: {url}")
|
||||
from agent_reach.channels.web import WebChannel
|
||||
return await WebChannel().read(url, config)
|
||||
|
||||
owner, repo = path[0], path[1]
|
||||
headers = self._headers(config)
|
||||
|
||||
# Issues/PRs
|
||||
# Issues / PRs
|
||||
if len(path) >= 4 and path[2] in ("issues", "pull"):
|
||||
num = path[3]
|
||||
resp = requests.get(f"{self.API}/repos/{owner}/{repo}/issues/{num}", headers=headers, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Get comments
|
||||
comments_text = ""
|
||||
if data.get("comments", 0) > 0:
|
||||
cr = requests.get(f"{self.API}/repos/{owner}/{repo}/issues/{num}/comments",
|
||||
headers=headers, params={"per_page": 20}, timeout=15)
|
||||
if cr.ok:
|
||||
for c in cr.json():
|
||||
comments_text += f"\n\n---\n**{c.get('user', {}).get('login', '')}** ({c.get('created_at', '')}):\n{c.get('body', '')}"
|
||||
|
||||
return ReadResult(
|
||||
title=data.get("title", ""),
|
||||
content=(data.get("body", "") or "") + comments_text,
|
||||
url=url,
|
||||
author=data.get("user", {}).get("login", ""),
|
||||
date=data.get("created_at", ""),
|
||||
platform="github",
|
||||
extra={"state": data.get("state"), "comments": data.get("comments", 0),
|
||||
"reactions": data.get("reactions", {}).get("total_count", 0)},
|
||||
)
|
||||
return await self._read_issue(owner, repo, path[3], url)
|
||||
|
||||
# Repo
|
||||
resp = requests.get(f"{self.API}/repos/{owner}/{repo}", headers=headers, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return await self._read_repo(owner, repo, url)
|
||||
|
||||
# Get README
|
||||
readme_text = ""
|
||||
rr = requests.get(f"{self.API}/repos/{owner}/{repo}/readme", headers=headers, timeout=15)
|
||||
if rr.ok:
|
||||
import base64
|
||||
readme_data = rr.json()
|
||||
if readme_data.get("encoding") == "base64":
|
||||
readme_text = base64.b64decode(readme_data["content"]).decode("utf-8", errors="replace")
|
||||
async def _read_repo(self, owner: str, repo: str, url: str) -> ReadResult:
|
||||
slug = f"{owner}/{repo}"
|
||||
try:
|
||||
# Get repo info
|
||||
info = self._gh(["repo", "view", slug])
|
||||
# Get README
|
||||
try:
|
||||
readme = self._gh(
|
||||
["api", f"repos/{slug}/readme", "--jq", ".content"],
|
||||
timeout=10,
|
||||
)
|
||||
import base64
|
||||
readme_text = base64.b64decode(readme).decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
readme_text = ""
|
||||
|
||||
return ReadResult(
|
||||
title=f"{owner}/{repo}",
|
||||
content=readme_text or data.get("description", ""),
|
||||
url=url,
|
||||
author=owner,
|
||||
platform="github",
|
||||
extra={"stars": data.get("stargazers_count", 0), "forks": data.get("forks_count", 0),
|
||||
"language": data.get("language", ""), "description": data.get("description", "")},
|
||||
)
|
||||
content = readme_text or info
|
||||
return ReadResult(
|
||||
title=slug, content=content, url=url,
|
||||
author=owner, platform="github",
|
||||
)
|
||||
except Exception:
|
||||
from agent_reach.channels.web import WebChannel
|
||||
return await WebChannel().read(url)
|
||||
|
||||
async def _read_issue(self, owner: str, repo: str, num: str, url: str) -> ReadResult:
|
||||
slug = f"{owner}/{repo}"
|
||||
try:
|
||||
out = self._gh(["issue", "view", num, "-R", slug])
|
||||
return ReadResult(
|
||||
title=f"{slug}#{num}", content=out, url=url,
|
||||
platform="github",
|
||||
)
|
||||
except Exception:
|
||||
# Might be a PR
|
||||
try:
|
||||
out = self._gh(["pr", "view", num, "-R", slug])
|
||||
return ReadResult(
|
||||
title=f"{slug}#{num}", content=out, url=url,
|
||||
platform="github",
|
||||
)
|
||||
except Exception:
|
||||
from agent_reach.channels.web import WebChannel
|
||||
return await WebChannel().read(url)
|
||||
|
||||
async def search(self, query: str, config=None, **kwargs) -> List[SearchResult]:
|
||||
if not shutil.which("gh"):
|
||||
raise ValueError("GitHub search requires gh CLI. Install: https://cli.github.com")
|
||||
|
||||
language = kwargs.get("language")
|
||||
limit = kwargs.get("limit", 5)
|
||||
|
||||
q = query
|
||||
args = ["search", "repos", query, "--sort", "stars", f"--limit={limit}"]
|
||||
if language:
|
||||
q += f" language:{language}"
|
||||
|
||||
resp = requests.get(
|
||||
f"{self.API}/search/repositories",
|
||||
headers=self._headers(config),
|
||||
params={"q": q, "sort": "stars", "per_page": min(limit, 30)},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
args += [f"--language={language}"]
|
||||
|
||||
out = self._gh(args, timeout=15)
|
||||
results = []
|
||||
for repo in resp.json().get("items", []):
|
||||
results.append(SearchResult(
|
||||
title=repo.get("full_name", ""),
|
||||
url=repo.get("html_url", ""),
|
||||
snippet=repo.get("description", ""),
|
||||
date=repo.get("updated_at", ""),
|
||||
extra={"stars": repo.get("stargazers_count", 0),
|
||||
"forks": repo.get("forks_count", 0),
|
||||
"language": repo.get("language", "")},
|
||||
))
|
||||
for line in out.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 1:
|
||||
slug = parts[0].strip()
|
||||
desc = parts[1].strip() if len(parts) > 1 else ""
|
||||
stars = parts[3].strip() if len(parts) > 3 else ""
|
||||
lang = parts[5].strip() if len(parts) > 5 else ""
|
||||
results.append(SearchResult(
|
||||
title=slug,
|
||||
url=f"https://github.com/{slug}",
|
||||
snippet=desc,
|
||||
extra={"stars": stars, "language": lang},
|
||||
))
|
||||
return results
|
||||
|
||||
Reference in New Issue
Block a user