242 lines
9.7 KiB
Python
242 lines
9.7 KiB
Python
"""GitLink 公开 API 共享客户端。
|
||
|
||
供 gitlink-skills-pack 下各 Skill 的脚本复用。仅依赖 Python 标准库,
|
||
无需第三方包,便于在受限环境或 Agent 沙箱中运行。
|
||
|
||
数据全部来自 GitLink 平台公开接口(https://www.gitlink.org.cn/api),
|
||
默认无需 token;如需访问私有仓库,可传入 token。
|
||
|
||
所有方法均为只读,不修改任何远程数据。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
API_BASE = "https://www.gitlink.org.cn/api"
|
||
USER_AGENT = "gitlink-skills-pack/1.0 (+https://www.gitlink.org.cn)"
|
||
DEFAULT_TIMEOUT = 30
|
||
COMMIT_PAGE_SIZE = 50 # GitLink commits 接口每页硬上限
|
||
|
||
|
||
class GitLinkError(RuntimeError):
|
||
"""API 调用中不可恢复的错误。"""
|
||
|
||
|
||
class GitLinkClient:
|
||
"""GitLink 公开数据接口客户端。
|
||
|
||
带可选文件缓存:同一资源重复读取不重复打网,对平台友好。
|
||
"""
|
||
|
||
def __init__(self, base: str = API_BASE, token: str | None = None,
|
||
timeout: int = DEFAULT_TIMEOUT, cache_dir: Path | None = None) -> None:
|
||
self.base = base.rstrip("/")
|
||
self.token = token
|
||
self.timeout = timeout
|
||
self.cache_dir = cache_dir
|
||
if self.cache_dir:
|
||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 底层请求
|
||
# ------------------------------------------------------------------
|
||
def _cache_path(self, url: str) -> Path | None:
|
||
if not self.cache_dir:
|
||
return None
|
||
safe = urllib.parse.quote(url, safe="")
|
||
return self.cache_dir / f"{safe}.json"
|
||
|
||
def get(self, path: str, query: dict[str, Any] | None = None) -> Any:
|
||
"""GET 请求,返回解析后的 JSON(dict/list)或 None。"""
|
||
url = f"{self.base}/{path.lstrip('/')}"
|
||
if query:
|
||
url = f"{url}?{urllib.parse.urlencode(query)}"
|
||
|
||
cache_path = self._cache_path(url)
|
||
if cache_path and cache_path.exists():
|
||
return json.loads(cache_path.read_text(encoding="utf-8"))
|
||
|
||
headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
|
||
if self.token:
|
||
headers["Authorization"] = f"Bearer {self.token}"
|
||
|
||
req = urllib.request.Request(url, headers=headers)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||
raw = resp.read().decode("utf-8", errors="replace")
|
||
except urllib.error.HTTPError as exc:
|
||
raise GitLinkError(f"HTTP {exc.code}: {url}") from exc
|
||
except urllib.error.URLError as exc:
|
||
raise GitLinkError(f"网络错误: {url} -> {exc.reason}") from exc
|
||
|
||
text = raw.strip()
|
||
if not text or text in ("null", "{}", "[]"):
|
||
data: Any = None
|
||
elif text[0] in "{[":
|
||
try:
|
||
data = json.loads(text)
|
||
except json.JSONDecodeError as exc:
|
||
raise GitLinkError(f"响应非 JSON: {url}") from exc
|
||
else:
|
||
raise GitLinkError(f"响应非 JSON(可能是 HTML): {url}")
|
||
|
||
if cache_path is not None:
|
||
cache_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||
return data
|
||
|
||
# ------------------------------------------------------------------
|
||
# 资源访问(高层封装)
|
||
# ------------------------------------------------------------------
|
||
def repo_info(self, owner: str, repo: str) -> dict[str, Any]:
|
||
"""仓库元信息。"""
|
||
data = self.get(f"{owner}/{repo}.json")
|
||
return data if isinstance(data, dict) else {}
|
||
|
||
def issues(self, owner: str, repo: str, limit: int = 50,
|
||
page: int = 1) -> list[dict[str, Any]]:
|
||
"""Issue 列表。"""
|
||
data = self.get(f"{owner}/{repo}/issues.json", {"page": page, "limit": limit})
|
||
return _extract_list(data, ("issues",))
|
||
|
||
def issue_detail(self, owner: str, repo: str, number: int) -> dict[str, Any]:
|
||
"""单个 Issue 详情(含完整字段)。"""
|
||
data = self.get(f"{owner}/{repo}/issues/{number}.json")
|
||
return data if isinstance(data, dict) else {}
|
||
|
||
def pulls(self, owner: str, repo: str, limit: int = 50,
|
||
page: int = 1) -> list[dict[str, Any]]:
|
||
"""PR 列表。"""
|
||
data = self.get(f"{owner}/{repo}/pulls.json", {"page": page, "limit": limit})
|
||
return _extract_list(data, ("issues", "pulls"))
|
||
|
||
def contributors(self, owner: str, repo: str) -> list[dict[str, Any]]:
|
||
"""贡献者列表。"""
|
||
data = self.get(f"{owner}/{repo}/contributors.json")
|
||
return _extract_list(data, ("list",))
|
||
|
||
def commits(self, owner: str, repo: str, max_pages: int = 4) -> list[dict[str, Any]]:
|
||
"""提交列表(按需翻页,每页 50 条,以 total_count 为终止依据)。"""
|
||
out: list[dict[str, Any]] = []
|
||
total: int | None = None
|
||
for page in range(1, max(1, max_pages) + 1):
|
||
data = self.get(f"{owner}/{repo}/commits.json",
|
||
{"page": page, "limit": COMMIT_PAGE_SIZE})
|
||
if total is None and isinstance(data, dict):
|
||
total = _safe_int(data.get("total_count")) or None
|
||
page_items = _extract_list(data, ("commits",))
|
||
if not page_items:
|
||
break
|
||
out.extend(page_items)
|
||
if total is not None and len(out) >= total:
|
||
break
|
||
return out
|
||
|
||
def list_dir(self, owner: str, repo: str, path: str = "",
|
||
ref: str = "master") -> list[dict[str, Any]]:
|
||
"""列出目录下的条目(文件与子目录)。
|
||
|
||
返回的每个 entry 含 name / path / type(file|dir) / sha / size,
|
||
文件类型的 entry 还可能直接带明文 content。
|
||
"""
|
||
data = self.get(f"{owner}/{repo}/sub_entries.json",
|
||
{"filepath": path, "ref": ref})
|
||
# 查询目录时 entries 为 list;查询单文件时 entries 为单个 dict。
|
||
# 统一归一化为 list,便于下游处理。
|
||
if isinstance(data, dict):
|
||
entries = data.get("entries")
|
||
if isinstance(entries, dict):
|
||
return [entries]
|
||
if isinstance(entries, list):
|
||
return entries
|
||
return _extract_list(data, ("entries",))
|
||
|
||
def file_content(self, owner: str, repo: str, filepath: str,
|
||
ref: str = "master") -> str | None:
|
||
"""读取单个文件的文本内容。
|
||
|
||
GitLink 的 sub_entries 接口对单文件查询会在 entries 中返回明文 content,
|
||
据此取出。文件不存在或无内容时返回 None。
|
||
"""
|
||
entries = self.list_dir(owner, repo, filepath, ref)
|
||
target = filepath.rsplit("/", 1)[-1]
|
||
for entry in entries:
|
||
if entry.get("type") == "file" and entry.get("name") == target:
|
||
content = entry.get("content")
|
||
if isinstance(content, str):
|
||
return content
|
||
# 回退:部分情况下单文件查询 entries 仅一项
|
||
if len(entries) == 1 and entries[0].get("type") == "file":
|
||
content = entries[0].get("content")
|
||
if isinstance(content, str):
|
||
return content
|
||
return None
|
||
|
||
def readme(self, owner: str, repo: str, ref: str = "master") -> str | None:
|
||
"""读取仓库 README(自动 base64 解码)。"""
|
||
data = self.get(f"{owner}/{repo}/readme.json", {"ref": ref})
|
||
if not isinstance(data, dict):
|
||
return None
|
||
content = data.get("content")
|
||
if not isinstance(content, str):
|
||
return None
|
||
# 注意:GitLink 的 readme.json 虽然 encoding 标为 base64,
|
||
# 实测 content 多为明文 Markdown。先探测明文特征,命中则直接返回;
|
||
# 否则再尝试 base64 解码。
|
||
stripped = content.lstrip()
|
||
if stripped.startswith(("#", "<", "[", "-", "*", "本", "这", "项")) or "\n" in content[:200]:
|
||
return content
|
||
try:
|
||
raw = base64.b64decode(content.encode("ascii", "ignore"))
|
||
decoded = raw.decode("utf-8", errors="replace")
|
||
# 解码结果若不像文本(大量替换符),回退为原文
|
||
if decoded.count("\ufffd") > len(decoded) * 0.1:
|
||
return content
|
||
return decoded
|
||
except (ValueError, TypeError):
|
||
return content
|
||
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# 辅助
|
||
# ----------------------------------------------------------------------------
|
||
|
||
def _extract_list(payload: Any, keys: tuple[str, ...]) -> list[Any]:
|
||
"""从可能嵌套的响应中提取第一个匹配键的列表。"""
|
||
if isinstance(payload, list):
|
||
return payload
|
||
if isinstance(payload, dict):
|
||
for key in keys:
|
||
value = payload.get(key)
|
||
if isinstance(value, list):
|
||
return value
|
||
return []
|
||
|
||
|
||
def _safe_int(value: Any, default: int = 0) -> int:
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def split_owner_repo(slug: str) -> tuple[str, str]:
|
||
"""把 'owner/repo' 或完整 URL 解析为 (owner, repo)。"""
|
||
s = slug.strip()
|
||
if s.startswith("http"):
|
||
parts = urllib.parse.urlparse(s).path.strip("/").split("/")
|
||
if len(parts) >= 2:
|
||
return parts[0], parts[1].replace(".git", "")
|
||
raise GitLinkError(f"无法从 URL 解析 owner/repo: {slug}")
|
||
if "/" in s:
|
||
owner, repo = s.split("/", 1)
|
||
return owner, repo.replace(".git", "")
|
||
raise GitLinkError(f"格式应为 owner/repo: {slug}")
|