Files

286 lines
10 KiB
Python

from __future__ import annotations
import base64
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Any
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from config import GITHUB_TOKEN, GITHUB_TOKENS
from utils.logger import get_logger
logger = get_logger("clients.github_client")
@dataclass
class _TokenState:
remaining: int = 5000
available_at: float = 0.0
reset_at: float = 0.0
class GithubClient:
"""GitHub API 统一客户端。"""
def __init__(
self,
tokens: list[str] | None = None,
*,
min_interval: float = 0.2,
timeout: int = 30,
max_retries: int = 3,
) -> None:
raw_tokens = tokens if tokens is not None else (GITHUB_TOKENS or [GITHUB_TOKEN])
self.tokens = [token.strip() for token in raw_tokens if isinstance(token, str) and token.strip()]
self.min_interval = max(0.0, min_interval)
self.timeout = timeout
self.max_retries = max(1, max_retries)
self._lock = threading.Lock()
self._current_index = 0
self._next_request_time = 0.0
self._token_state = {token: _TokenState() for token in self.tokens}
self.session = requests.Session()
retry = Retry(
total=5,
backoff_factor=0.5,
status_forcelist=[500, 502, 503, 504],
allowed_methods=frozenset(["GET"]),
)
self.session.mount("https://", HTTPAdapter(max_retries=retry))
self.session.verify = True
def _wait_interval(self) -> None:
if self.min_interval <= 0:
return
with self._lock:
now = time.time()
wait = max(0.0, self._next_request_time - now)
if wait > 0:
time.sleep(wait)
self._next_request_time = time.time() + self.min_interval
def _get_token(self) -> str | None:
if not self.tokens:
return None
while True:
with self._lock:
now = time.time()
wait_seconds = None
chosen_token: str | None = None
for offset in range(len(self.tokens)):
idx = (self._current_index + offset) % len(self.tokens)
token = self.tokens[idx]
state = self._token_state[token]
if state.available_at <= now:
self._current_index = (idx + 1) % len(self.tokens)
chosen_token = token
break
token_wait = max(0.0, state.available_at - now)
wait_seconds = token_wait if wait_seconds is None else min(wait_seconds, token_wait)
if chosen_token:
return chosen_token
if wait_seconds is None:
return None
time.sleep(max(wait_seconds, 0.1))
def _update_token_state_from_headers(self, token: str | None, response: requests.Response) -> None:
if not token:
return
remaining_header = response.headers.get("X-RateLimit-Remaining")
reset_header = response.headers.get("X-RateLimit-Reset")
with self._lock:
state = self._token_state[token]
if remaining_header is not None:
try:
state.remaining = int(remaining_header)
except ValueError:
pass
if reset_header is not None:
try:
state.reset_at = float(reset_header)
except ValueError:
pass
if state.remaining <= 0 and state.reset_at > time.time():
state.available_at = state.reset_at
elif state.available_at < time.time():
state.available_at = 0.0
def _mark_token_cooldown(self, token: str | None, cooldown_seconds: float, remaining: int = 0) -> None:
if not token:
return
with self._lock:
state = self._token_state[token]
state.remaining = remaining
state.available_at = max(state.available_at, time.time() + cooldown_seconds)
def _request(
self,
endpoint: str,
*,
params: dict[str, Any] | None = None,
absolute: bool = False,
) -> requests.Response | None:
url = endpoint if absolute else f"https://api.github.com{endpoint}"
last_error: Exception | None = None
for attempt in range(1, self.max_retries + 1):
token = self._get_token()
headers = {"Accept": "application/vnd.github.v3+json"}
if token:
headers["Authorization"] = f"token {token}"
self._wait_interval()
try:
response = self.session.get(url, headers=headers, params=params, timeout=self.timeout)
except requests.RequestException as exc:
last_error = exc
logger.warning("GitHub 请求异常,第 %s 次重试: %s", attempt, str(exc))
self._mark_token_cooldown(token, cooldown_seconds=30.0)
if attempt < self.max_retries:
time.sleep(min(2 ** attempt, 8))
continue
self._update_token_state_from_headers(token, response)
if response.status_code == 401:
logger.warning("GitHub Token 认证失败,切换 Token")
self._mark_token_cooldown(token, cooldown_seconds=3600.0)
if attempt < self.max_retries:
continue
return None
if response.status_code == 403:
reset_header = response.headers.get("X-RateLimit-Reset")
wait = 60.0
if reset_header:
try:
wait = max(1.0, float(reset_header) - time.time())
except ValueError:
wait = 60.0
logger.warning("GitHub 速率限制触发,切换 Token")
self._mark_token_cooldown(token, cooldown_seconds=wait)
if attempt < self.max_retries:
continue
return None
if 500 <= response.status_code < 600:
logger.warning("GitHub 服务端错误 %s,第 %s 次重试", response.status_code, attempt)
if attempt < self.max_retries:
time.sleep(min(2 ** attempt, 8))
continue
return None
return response
if last_error:
logger.error("GitHub 请求失败: %s", str(last_error))
return None
def _request_json(
self,
endpoint: str,
*,
params: dict[str, Any] | None = None,
absolute: bool = False,
) -> dict[str, Any] | list[Any] | None:
response = self._request(endpoint, params=params, absolute=absolute)
if not response:
return None
if response.status_code >= 400:
logger.warning("GitHub 返回错误状态码: %s", response.status_code)
return None
try:
return response.json()
except ValueError:
logger.warning("GitHub 返回非 JSON 响应")
return None
def get_rate_limit(self) -> dict[str, Any] | None:
data = self._request_json("/rate_limit")
return data if isinstance(data, dict) else None
def search_repos(
self,
query: str,
*,
sort: str = "updated",
order: str = "desc",
page: int = 1,
per_page: int = 30,
) -> dict[str, Any] | None:
params = {
"q": query,
"sort": sort,
"order": order,
"page": page,
"per_page": per_page,
}
data = self._request_json("/search/repositories", params=params)
return data if isinstance(data, dict) else None
def get_commits(
self,
repo_full_name: str,
*,
since: datetime | str | None = None,
per_page: int = 10,
) -> list[dict[str, Any]] | None:
params: dict[str, Any] = {"per_page": per_page}
if since:
if isinstance(since, datetime):
params["since"] = since.strftime("%Y-%m-%dT%H:%M:%SZ")
else:
params["since"] = since
data = self._request_json(f"/repos/{repo_full_name}/commits", params=params)
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
return None
def get_commit_detail(self, repo_full_name: str, sha_or_url: str) -> dict[str, Any] | None:
if sha_or_url.startswith("http://") or sha_or_url.startswith("https://"):
data = self._request_json(sha_or_url, absolute=True)
else:
data = self._request_json(f"/repos/{repo_full_name}/commits/{sha_or_url}")
return data if isinstance(data, dict) else None
def get_readme(self, repo_full_name: str) -> str | None:
data = self._request_json(f"/repos/{repo_full_name}/readme")
if not isinstance(data, dict):
return None
encoded = data.get("content")
if not encoded:
return None
try:
normalized = str(encoded).replace("\n", "")
return base64.b64decode(normalized).decode("utf-8", errors="ignore")
except Exception:
logger.warning("README 解码失败: %s", repo_full_name)
return None
def get_repository(self, repo_full_name: str) -> dict[str, Any] | None:
data = self._request_json(f"/repos/{repo_full_name}")
return data if isinstance(data, dict) else None
def get_contents(self, repo_full_name: str, path: str = "") -> list[dict[str, Any]] | None:
endpoint = f"/repos/{repo_full_name}/contents/{path}" if path else f"/repos/{repo_full_name}/contents"
data = self._request_json(endpoint)
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(data, dict):
return [data]
return None
def get_json_by_url(self, url: str) -> dict[str, Any] | list[Any] | None:
return self._request_json(url, absolute=True)