"""README.md репозитория, если описание задачи — только ссылка на git.
Не привязываемся к GitHub/GitLab (у пользователя может быть селф-хостед):
пробуем набор известных путей к сырому README — API github/gitlab/gitea
и raw-пути web-интерфейсов — пока какой-нибудь не ответит markdown'ом.
Нашли — отдаём, нет — None (фронт оставляет описание как было).
"""
from __future__ import annotations
import threading
import time
from urllib.parse import urlparse
import httpx
# Общий тайм-бюджет на одну ссылку: перебор кандидатов не дольше этого.
TOTAL_TIMEOUT = 15.0
PER_TRY_TIMEOUT = 5.0
MAX_BYTES = 256_000
CACHE_TTL = 600.0 # повторные чтения одной ссылки не долбят git-хост
_CACHE: dict[str, tuple[float, str | None]] = {}
_CACHE_LOCK = threading.Lock()
_HEADERS = {
# GitHub API отдаёт сырой README только с raw-мимом; остальным всё равно
"Accept": "application/vnd.github.raw+json, text/markdown;q=0.9, text/plain;q=0.8, */*;q=0.7",
"User-Agent": "gntodo/1.0 (README for task description)",
}
def repo_candidates(url: str) -> list[str]:
"""Ссылка на репозиторий → кандидаты URL сырого README.md (пусто — не похоже)."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return []
path = parsed.path.strip("/")
if path.endswith(".git"):
path = path[: -len(".git")]
segments = [s for s in path.split("/") if s]
# «Похоже на git»: ожидаем owner/repo (вложенность селф-хостеда — тоже git-система,
# берём последние два сегмента пути как owner/repo)
if len(segments) < 2:
return []
owner, repo = segments[-2], segments[-1]
base = f"{parsed.scheme}://{parsed.netloc}"
full_path = "/".join(segments)
out: list[str] = []
# raw-путь с HEAD (дефолтная ветка) — самый универсальный: github, GitBucket,
# Gogs; GitHub API вторым (умеет любой регистр/расширение README, но 60/час
# без токена), затем API GitLab/Gitea (работают и селф-хостед)
out.append(f"{base}/{full_path}/raw/HEAD/README.md")
if parsed.netloc == "github.com":
out.append(f"https://api.github.com/repos/{owner}/{repo}/readme")
# GitLab (gitlab.com и селф-хостед): url-encoded путь, HEAD = дефолтная ветка
quoted = full_path.replace("/", "%2F")
out.append(
f"{base}/api/v4/projects/{quoted}/repository/files/README%2Emd/raw?ref=HEAD"
)
# Gitea/Gogs API + прочие raw-пути web-интерфейсов
out.append(f"{base}/api/v1/repos/{owner}/{repo}/raw/README.md?ref=HEAD")
out.extend(
[
f"{base}/{full_path}/-/raw/HEAD/README.md",
f"{base}/{full_path}/raw/main/README.md",
f"{base}/{full_path}/raw/master/README.md",
]
)
return out
def _looks_html(text: str, content_type: str) -> bool:
if "html" in content_type.lower():
return True
head = text.lstrip()[:200].lower()
return head.startswith("<!doctype html") or head.startswith("<html")
async def _try(client: httpx.AsyncClient, url: str) -> str | None:
try:
resp = await client.get(url)
except httpx.HTTPError:
return None
if resp.status_code != 200:
return None
text = resp.text
if not text.strip() or _looks_html(text, resp.headers.get("content-type", "")):
return None
return text[:MAX_BYTES]
async def fetch_readme(url: str) -> str | None:
"""Сырой README.md по ссылке на репозиторий; None — не нашли (кэш 10 минут)."""
now = time.monotonic()
with _CACHE_LOCK:
hit = _CACHE.get(url)
if hit is not None and now - hit[0] < CACHE_TTL:
return hit[1]
content: str | None = None
candidates = repo_candidates(url)
if candidates:
started = time.monotonic()
async with httpx.AsyncClient(follow_redirects=True, timeout=PER_TRY_TIMEOUT) as client:
for candidate in candidates:
if time.monotonic() - started > TOTAL_TIMEOUT:
break
content = await _try(client, candidate)
if content is not None:
break
with _CACHE_LOCK:
if len(_CACHE) > 128:
_CACHE.clear()
_CACHE[url] = (now, content)
return content