From f26fb3a6912a71596efa0b8127ad94eb97761c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A4=D0=BE=D1=84=D0=B0=D0=BD=D0=BE=D0=B2=20=D0=94=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=92=D0=B8=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Tue, 1 Sep 2026 11:29:40 +0300 Subject: [PATCH] Initial import: UDP tracker scraper --- .gitignore | 3 ++ README.md | 13 +++++ pyproject.toml | 15 ++++++ src/torrent_tracker_scraper/__init__.py | 1 + src/torrent_tracker_scraper/cli.py | 24 ++++++++++ src/torrent_tracker_scraper/udp.py | 63 +++++++++++++++++++++++++ 6 files changed, 119 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/torrent_tracker_scraper/__init__.py create mode 100644 src/torrent_tracker_scraper/cli.py create mode 100644 src/torrent_tracker_scraper/udp.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98dbeb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..956c240 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Torrent Tracker Scraper + +Современная, компактная переработка `/_torrent/torrent.py` и `GetPeers.py`. +Утилита отправляет стандартный UDP scrape-запрос трекеру и выводит число seeders, +completed и leechers для указанного info hash. Она не скачивает файлы и не +подключается к пиринговой сети. + +```bash +python -m pip install -e . +torrent-tracker-scrape tracker.example.org 6969 0123456789abcdef0123456789abcdef01234567 +``` + +Используйте только с трекерами и материалами, доступ к которым у вас есть. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..791a6c7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "torrent-tracker-scraper" +version = "0.1.0" +description = "Inspect peer statistics from UDP BitTorrent trackers." +requires-python = ">=3.10" + +[project.scripts] +torrent-tracker-scrape = "torrent_tracker_scraper.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/torrent_tracker_scraper/__init__.py b/src/torrent_tracker_scraper/__init__.py new file mode 100644 index 0000000..9fc35a5 --- /dev/null +++ b/src/torrent_tracker_scraper/__init__.py @@ -0,0 +1 @@ +"""Клиент UDP scrape-протокола BitTorrent-трекеров.""" diff --git a/src/torrent_tracker_scraper/cli.py b/src/torrent_tracker_scraper/cli.py new file mode 100644 index 0000000..1ed3b27 --- /dev/null +++ b/src/torrent_tracker_scraper/cli.py @@ -0,0 +1,24 @@ +"""Командный интерфейс одноразовой диагностики UDP-трекера.""" + +from __future__ import annotations + +import argparse + +from .udp import scrape + + +def main() -> None: + """Выполнить scrape и напечатать ответ в удобном для скриптов виде.""" + + parser = argparse.ArgumentParser(description="Read one BitTorrent UDP tracker scrape record") + parser.add_argument("host") + parser.add_argument("port", type=int) + parser.add_argument("info_hash") + parser.add_argument("--timeout", type=float, default=8.0) + args = parser.parse_args() + result = scrape(args.host, args.port, args.info_hash, args.timeout) + print(f"seeders={result.seeders} completed={result.completed} leechers={result.leechers}") + + +if __name__ == "__main__": + main() diff --git a/src/torrent_tracker_scraper/udp.py b/src/torrent_tracker_scraper/udp.py new file mode 100644 index 0000000..9df4b48 --- /dev/null +++ b/src/torrent_tracker_scraper/udp.py @@ -0,0 +1,63 @@ +"""Небольшая реализация BEP 15 для чтения агрегированной статистики трекера.""" + +from __future__ import annotations + +import os +import socket +import struct +from dataclasses import dataclass + +_PROTOCOL_ID = 0x41727101980 + + +@dataclass(frozen=True) +class ScrapeStats: + """Сводные значения, возвращаемые трекером для одного info hash.""" + + seeders: int + completed: int + leechers: int + + +def _transaction_id() -> int: + """Создать случайный идентификатор, связывающий запрос и ответ.""" + + return int.from_bytes(os.urandom(4), "big", signed=False) + + +def _receive(sock: socket.socket, transaction_id: int, minimum_size: int) -> bytes: + """Проверить длину и ID ответа, чтобы не принять чужой UDP-пакет.""" + + packet, _ = sock.recvfrom(2048) + if len(packet) < minimum_size: + raise RuntimeError(f"Tracker response is too short: {len(packet)} bytes") + if struct.unpack_from("!I", packet, 4)[0] != transaction_id: + raise RuntimeError("Tracker response transaction ID does not match") + if struct.unpack_from("!I", packet, 0)[0] == 3: + raise RuntimeError(packet[8:].decode("utf-8", errors="replace")) + return packet + + +def scrape(host: str, port: int, info_hash: str, timeout: float = 8.0) -> ScrapeStats: + """Запросить статистику одного 40-символьного hex info hash у UDP-трекера.""" + + try: + encoded_hash = bytes.fromhex(info_hash) + except ValueError as exc: + raise ValueError("info_hash must be hexadecimal") from exc + if len(encoded_hash) != 20: + raise ValueError("info_hash must contain exactly 40 hexadecimal characters") + + address = (socket.gethostbyname(host), port) + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(timeout) + connect_tx = _transaction_id() + sock.sendto(struct.pack("!QII", _PROTOCOL_ID, 0, connect_tx), address) + connect_response = _receive(sock, connect_tx, 16) + connection_id = struct.unpack_from("!Q", connect_response, 8)[0] + + scrape_tx = _transaction_id() + sock.sendto(struct.pack("!QII20s", connection_id, 2, scrape_tx, encoded_hash), address) + response = _receive(sock, scrape_tx, 20) + seeders, completed, leechers = struct.unpack_from("!III", response, 8) + return ScrapeStats(seeders=seeders, completed=completed, leechers=leechers)