diff --git a/handlers/instagram.py b/handlers/instagram.py index 6a90d18..b455a4b 100644 --- a/handlers/instagram.py +++ b/handlers/instagram.py @@ -7,6 +7,7 @@ InputMediaDocument, InputMediaPhoto, Message, + ReactionTypeEmoji, ) from data.config import locale @@ -33,10 +34,19 @@ async def handle_instagram_link( lang: str, file_mode: bool, group_chat: bool, + status_message: Message | None = None, ) -> None: client = InstagramClient() media_info = await client.get_media(instagram_url) + if not status_message: + try: + await message.react( + [ReactionTypeEmoji(emoji="👨‍💻")], disable_notification=True + ) + except TelegramBadRequest: + logger.debug("Failed to set processing reaction") + if media_info.is_video: await bot.send_chat_action( chat_id=message.chat.id, action="upload_video" diff --git a/handlers/link_dispatcher.py b/handlers/link_dispatcher.py index 3f96bf9..d13b42f 100644 --- a/handlers/link_dispatcher.py +++ b/handlers/link_dispatcher.py @@ -89,7 +89,8 @@ async def handle_instagram_message( try: await handle_instagram_link( - message, instagram_url, lang, file_mode, group_chat + message, instagram_url, lang, file_mode, group_chat, + status_message=status_message, ) except InstagramError as e: if status_message: diff --git a/instagram_api/client.py b/instagram_api/client.py index 84396d3..ad0e2b5 100644 --- a/instagram_api/client.py +++ b/instagram_api/client.py @@ -1,8 +1,11 @@ from __future__ import annotations +import asyncio import logging import re +from aiohttp import ClientTimeout + from data.config import config from media_types.http_session import _get_http_session @@ -24,6 +27,10 @@ "instagram-downloader-download-instagram-stories-videos4.p.rapidapi.com" ) +_MAX_ATTEMPTS = 3 +_RETRY_DELAYS = (3, 5) +_REQUEST_TIMEOUT = ClientTimeout(total=10, connect=3) + class InstagramClient: async def get_media(self, url: str) -> InstagramMediaInfo: @@ -36,40 +43,71 @@ async def get_media(self, url: str) -> InstagramMediaInfo: } api_url = f"https://{_RAPIDAPI_HOST}/convert" - try: - async with session.get( - api_url, params={"url": url}, headers=headers - ) as response: - if response.status == 404: - raise InstagramNotFoundError("Post not found or private") - if response.status == 429: - raise InstagramRateLimitError("API rate limit exceeded") - if response.status != 200: - text = await response.text() - logger.error( - f"Instagram API error {response.status}: {text}" - ) - raise InstagramNetworkError( - f"API returned status {response.status}" - ) + last_exc: Exception | None = None + for attempt in range(1, _MAX_ATTEMPTS + 1): + try: + async with session.get( + api_url, + params={"url": url}, + headers=headers, + timeout=_REQUEST_TIMEOUT, + ) as response: + if response.status == 404: + raise InstagramNotFoundError("Post not found or private") + if response.status == 429: + raise InstagramRateLimitError("API rate limit exceeded") + if response.status >= 500: + raise InstagramNetworkError( + f"API returned status {response.status}" + ) + if response.status != 200: + text = await response.text() + logger.error( + f"Instagram API error {response.status}: {text}" + ) + raise InstagramNetworkError( + f"API returned status {response.status}" + ) - data = await response.json() - logger.debug(f"Instagram API response keys: {list(data.keys())}") - logger.debug( - f"Instagram API media count: {len(data.get('media', []))}" - ) - for i, item in enumerate(data.get("media", [])): + data = await response.json() + logger.debug(f"Instagram API response keys: {list(data.keys())}") logger.debug( - f" media[{i}]: type={item.get('type')}, " - f"url={item.get('url', '')[:120]}, " - f"thumbnail={str(item.get('thumbnail', ''))[:120]}, " - f"quality={item.get('quality')}" + f"Instagram API media count: {len(data.get('media', []))}" ) - except (InstagramNotFoundError, InstagramRateLimitError, InstagramNetworkError): - raise - except Exception as e: - logger.error(f"Instagram API request failed: {e}") - raise InstagramNetworkError(f"Request failed: {e}") from e + for i, item in enumerate(data.get("media", [])): + logger.debug( + f" media[{i}]: type={item.get('type')}, " + f"url={item.get('url', '')[:120]}, " + f"thumbnail={str(item.get('thumbnail', ''))[:120]}, " + f"quality={item.get('quality')}" + ) + break # success + except InstagramNotFoundError: + raise + except (InstagramRateLimitError, InstagramNetworkError) as e: + last_exc = e + except Exception as e: + last_exc = InstagramNetworkError(f"Request failed: {e}") + last_exc.__cause__ = e + + if attempt < _MAX_ATTEMPTS: + delay = _RETRY_DELAYS[attempt - 1] + logger.warning( + "Instagram API attempt %d/%d failed: %s — retrying in %ds", + attempt, + _MAX_ATTEMPTS, + last_exc, + delay, + ) + await asyncio.sleep(delay) + else: + logger.error( + "Instagram API attempt %d/%d failed: %s — giving up", + attempt, + _MAX_ATTEMPTS, + last_exc, + ) + raise last_exc # type: ignore[misc] media_items = [] for item in data.get("media", []): diff --git a/pyproject.toml b/pyproject.toml index 0db8112..6666ba5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,10 +23,6 @@ main = [ "APScheduler==3.11.2", "Pillow==12.1.0", "pillow-heif==1.1.1", - "yt-dlp==2026.02.04", - # curl_cffi version must be compatible with yt-dlp's BROWSER_TARGETS - # Check yt_dlp/networking/_curlcffi.py for supported versions when updating yt-dlp - "curl_cffi>=0.10.0,<0.15.0", ] [tool.uv] diff --git a/tt-scrap/Dockerfile b/tt-scrap/Dockerfile new file mode 100644 index 0000000..3eccc18 --- /dev/null +++ b/tt-scrap/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.13-slim + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /app + +# Install dependencies first (cache layer) +COPY pyproject.toml uv.lock* ./ +RUN uv sync --frozen --no-dev 2>/dev/null || uv sync --no-dev + +# Copy application code +COPY app/ app/ + +EXPOSE 8000 + +CMD ["uv", "run", "uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/tt-scrap/README.md b/tt-scrap/README.md new file mode 100644 index 0000000..1af835b --- /dev/null +++ b/tt-scrap/README.md @@ -0,0 +1,98 @@ +# Media Scraper API + +Standalone FastAPI server for extracting video, slideshow, and music metadata from social media platforms. Built with a service-based architecture — each platform is a self-contained plugin under `app/services/`. + +Currently supported: **TikTok** + +## Running with uv + +```bash +cd tt-scrap + +# Install dependencies +uv sync + +# Start the server +uv run uvicorn app.app:app --host 0.0.0.0 --port 8000 + +# With auto-reload for development +uv run uvicorn app.app:app --reload +``` + +## Running with Docker + +```bash +cd tt-scrap + +# Build +docker build -t tt-scrap . + +# Run +docker run -p 8000:8000 tt-scrap + +# Run with environment variables +docker run -p 8000:8000 \ + -e PROXY_FILE=/data/proxies.txt \ + -e LOG_LEVEL=DEBUG \ + -v /path/to/proxies.txt:/data/proxies.txt \ + tt-scrap +``` + +## API Endpoints + +Routes are namespaced per service: `/{service}/...` + +### TikTok + +#### `GET /tiktok/video` + +Extract video or slideshow metadata from a TikTok URL. + +| Parameter | Type | Description | +|-----------|--------|-----------------------------------| +| `url` | string | TikTok video or slideshow URL | +| `raw` | bool | Return raw TikTok API data (default: false) | + +#### `GET /tiktok/music` + +Extract music metadata from a TikTok video. + +| Parameter | Type | Description | +|------------|------|------------------------| +| `video_id` | int | TikTok video ID | +| `raw` | bool | Return raw data (default: false) | + +### Shared + +#### `GET /health` + +Health check. Returns `{"status": "ok"}`. + +#### `GET /docs` + +Interactive OpenAPI documentation (Swagger UI). + +## Environment Variables + +### Global + +| Variable | Default | Description | +|----------------------|---------|------------------------------------------| +| `PROXY_FILE` | `""` | Path to proxy file (one URL per line) | +| `PROXY_INCLUDE_HOST` | `false` | Include direct connection in proxy rotation | +| `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) | + +### TikTok (`TIKTOK_` prefix) + +| Variable | Default | Description | +|-----------------------------------|---------|------------------------------------------| +| `TIKTOK_URL_RESOLVE_MAX_RETRIES` | `3` | Max retries for short URL resolution | +| `TIKTOK_VIDEO_INFO_MAX_RETRIES` | `3` | Max retries for video info extraction | +| `YTDLP_COOKIES` | `""` | Path to Netscape-format cookies file | + +## Adding a New Service + +1. Create `app/services//` with `client.py`, `parser.py`, `routes.py` +2. Implement the `BaseClient` protocol (see `app/base_client.py`) +3. Create a factory function returning a `ServiceEntry` +4. Register it in `app/app.py` lifespan diff --git a/tt-scrap/app/__init__.py b/tt-scrap/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tt-scrap/app/app.py b/tt-scrap/app/app.py new file mode 100644 index 0000000..3a56068 --- /dev/null +++ b/tt-scrap/app/app.py @@ -0,0 +1,98 @@ +"""FastAPI REST API server for media scraping.""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.responses import JSONResponse + +from .config import settings +from .exceptions import ( + ContentDeletedError, + ContentPrivateError, + ContentTooLongError, + ExtractionError, + InvalidLinkError, + NetworkError, + RateLimitError, + RegionBlockedError, + ScraperError, + UnsupportedServiceError, +) +from .models import ErrorResponse +from .proxy_manager import ProxyManager +from .registry import ServiceRegistry +from .routes import router +from .services import create_tiktok_service + +logger = logging.getLogger(__name__) + +_ERROR_STATUS_MAP: dict[type[ScraperError], int] = { + ContentDeletedError: 404, + ContentPrivateError: 403, + InvalidLinkError: 400, + UnsupportedServiceError: 400, + ContentTooLongError: 413, + RateLimitError: 429, + NetworkError: 502, + RegionBlockedError: 451, + ExtractionError: 500, +} + + +@asynccontextmanager +async def lifespan(app: FastAPI): + log_level = getattr(logging, settings.log_level.upper(), logging.INFO) + logging.basicConfig( + level=log_level, + format="%(asctime)s %(name)s %(levelname)s %(message)s", + ) + + proxy_manager = ( + ProxyManager.initialize( + settings.proxy_file, + include_host=settings.proxy_include_host, + ) + if settings.proxy_file + else None + ) + + registry = ServiceRegistry() + tiktok = create_tiktok_service(proxy_manager=proxy_manager) + registry.register(tiktok) + app.include_router(tiktok.router) + + app.state.registry = registry + + logger.info("Scraper API started") + yield + + for service in registry.get_all(): + if service.shutdown: + await service.shutdown() + + logger.info("Scraper API stopped") + + +app = FastAPI( + title="Media Scraper API", + version="0.2.0", + lifespan=lifespan, +) + + +@app.exception_handler(ScraperError) +async def scraper_error_handler(request, exc: ScraperError): + status_code = _ERROR_STATUS_MAP.get(type(exc), 500) + return JSONResponse( + status_code=status_code, + content=ErrorResponse( + error=str(exc), + error_type=type(exc).__name__, + ).model_dump(), + ) + + +app.include_router(router) diff --git a/tt-scrap/app/base_client.py b/tt-scrap/app/base_client.py new file mode 100644 index 0000000..a70494c --- /dev/null +++ b/tt-scrap/app/base_client.py @@ -0,0 +1,25 @@ +"""Base client protocol for scraper services.""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class BaseClient(Protocol): + """Protocol that all service clients must implement. + + extract_video_info must return a dict with keys: + - video_data: dict (raw service-specific data) + - video_id: str (content identifier) + - resolved_url: str (canonical URL) + + extract_music_info must return None if not supported, or a dict with keys: + - video_data: dict (raw data) + - music_data: dict (music-specific data) + - video_id: int + """ + + async def extract_video_info(self, url: str) -> dict[str, Any]: ... + + async def extract_music_info(self, video_id: int) -> dict[str, Any] | None: ... diff --git a/tt-scrap/app/config.py b/tt-scrap/app/config.py new file mode 100644 index 0000000..9c177e1 --- /dev/null +++ b/tt-scrap/app/config.py @@ -0,0 +1,30 @@ +"""Configuration for scraper API using pydantic-settings.""" + +from __future__ import annotations + +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # Proxy + proxy_file: str = "" + proxy_include_host: bool = False + + # Logging + log_level: str = "INFO" + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/tt-scrap/app/exceptions.py b/tt-scrap/app/exceptions.py new file mode 100644 index 0000000..bf43eed --- /dev/null +++ b/tt-scrap/app/exceptions.py @@ -0,0 +1,41 @@ +"""Scraper exception classes (service-agnostic).""" + + +class ScraperError(Exception): + """Base exception for all scraper errors.""" + + +class ContentDeletedError(ScraperError): + """Content has been deleted by the creator.""" + + +class ContentPrivateError(ScraperError): + """Content is private and cannot be accessed.""" + + +class NetworkError(ScraperError): + """Network error occurred during request.""" + + +class RateLimitError(ScraperError): + """Too many requests - rate limited.""" + + +class RegionBlockedError(ScraperError): + """Content is not available in the user's region (geo-blocked).""" + + +class ExtractionError(ScraperError): + """Generic extraction/parsing error (invalid ID, unknown failure, etc.).""" + + +class ContentTooLongError(ScraperError): + """Content exceeds the maximum allowed duration.""" + + +class InvalidLinkError(ScraperError): + """Link is invalid or expired (failed URL resolution).""" + + +class UnsupportedServiceError(ScraperError): + """URL does not match any registered service.""" diff --git a/tt-scrap/app/models.py b/tt-scrap/app/models.py new file mode 100644 index 0000000..7fe96ec --- /dev/null +++ b/tt-scrap/app/models.py @@ -0,0 +1,59 @@ +"""Pydantic API response models for the scraper REST API.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +class MusicResponse(BaseModel): + url: str + title: str + author: str + duration: int + cover: str + + +class VideoResponse(BaseModel): + type: str # "video" or "images" + id: int + video_url: str | None = None + image_urls: list[str] = [] + cover: str | None = None + width: int | None = None + height: int | None = None + duration: int | None = None + likes: int | None = None + views: int | None = None + link: str + music: MusicResponse | None = None + + +class RawVideoResponse(BaseModel): + id: int + resolved_url: str + data: dict[str, Any] + + +class MusicDetailResponse(BaseModel): + id: int + title: str + author: str + duration: int + cover: str + url: str + + +class RawMusicResponse(BaseModel): + id: int + data: dict[str, Any] + + +class HealthResponse(BaseModel): + status: str = "ok" + + +class ErrorResponse(BaseModel): + error: str + error_type: str diff --git a/tt-scrap/app/proxy_manager.py b/tt-scrap/app/proxy_manager.py new file mode 100644 index 0000000..ab2c9ae --- /dev/null +++ b/tt-scrap/app/proxy_manager.py @@ -0,0 +1,175 @@ +"""Proxy manager with round-robin load balancing.""" + +import logging +import os +import re +import threading +from typing import Optional +from urllib.parse import quote + +logger = logging.getLogger(__name__) + + +class ProxyManager: + """Thread-safe round-robin proxy manager. + + Loads proxies from a file and rotates through them for each request. + Optionally includes direct host connection (None) in rotation. + + File format: one proxy URL per line (http://, https://, socks5://) + Lines starting with # are ignored (comments) + Empty lines are ignored + + Example: + >>> manager = ProxyManager("proxies.txt", include_host=True) + >>> manager.get_next_proxy() # Returns "http://proxy1:8080" + >>> manager.get_next_proxy() # Returns "http://proxy2:8080" + >>> manager.get_next_proxy() # Returns None (direct connection) + """ + + _instance: Optional["ProxyManager"] = None + _lock = threading.Lock() + + def __init__(self, proxy_file: str, include_host: bool = False): + """Initialize proxy manager. + + Args: + proxy_file: Path to file containing proxy URLs (one per line) + include_host: If True, include None (direct connection) in rotation + """ + self._proxies: list[str | None] = [] + self._index = 0 + self._rotation_lock = threading.Lock() + self._load_proxies(proxy_file, include_host) + + def _encode_proxy_auth(self, proxy_url: str) -> str: + """URL-encode username and password in proxy URL. + + Args: + proxy_url: Proxy URL (e.g., http://user:pass@host:port) + + Returns: + Proxy URL with encoded credentials + """ + # Pattern to match proxy URL with auth: protocol://user:pass@host:port + match = re.match(r"^(https?|socks5)://([^:@]+):([^@]+)@(.+)$", proxy_url) + if match: + protocol, username, password, host_port = match.groups() + # URL-encode username and password (safe characters: unreserved chars per RFC 3986) + encoded_username = quote(username, safe="") + encoded_password = quote(password, safe="") + return f"{protocol}://{encoded_username}:{encoded_password}@{host_port}" + # No auth or invalid format, return as-is + return proxy_url + + def _load_proxies(self, file_path: str, include_host: bool) -> None: + """Load proxies from file. + + Args: + file_path: Path to proxy file + include_host: Whether to include direct connection in rotation + """ + if not file_path: + logger.warning("No proxy file specified") + if include_host: + self._proxies = [None] + return + + # Handle relative paths + if not os.path.isabs(file_path): + file_path = os.path.abspath(file_path) + + if not os.path.isfile(file_path): + logger.error(f"Proxy file not found: {file_path}") + if include_host: + self._proxies = [None] + return + + try: + with open(file_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + # Skip empty lines and comments + if not line or line.startswith("#"): + continue + # URL-encode authentication credentials + encoded_proxy = self._encode_proxy_auth(line) + self._proxies.append(encoded_proxy) + except Exception as e: + logger.error(f"Failed to load proxy file {file_path}: {e}") + + # Add None for direct host connection if enabled + if include_host: + self._proxies.append(None) + + if not self._proxies: + logger.warning("No proxies loaded, will use direct connection") + self._proxies = [None] + else: + proxy_count = len(self._proxies) + host_included = None in self._proxies + logger.info( + f"Loaded {proxy_count} proxy entries (include_host={host_included})" + ) + + def get_next_proxy(self) -> str | None: + """Get next proxy in round-robin rotation. + + Returns: + Proxy URL string, or None for direct connection. + """ + with self._rotation_lock: + if not self._proxies: + return None + proxy = self._proxies[self._index] + self._index = (self._index + 1) % len(self._proxies) + return proxy + + def get_proxy_count(self) -> int: + """Get total number of proxies in rotation (including host if enabled).""" + return len(self._proxies) + + def peek_current(self) -> str | None: + """Peek at current proxy without rotating (for logging only). + + Returns: + Current proxy URL that would be returned by get_next_proxy(), + or None for direct connection. + """ + with self._rotation_lock: + if not self._proxies: + return None + return self._proxies[self._index] + + def has_proxies(self) -> bool: + """Check if any proxies are configured (excluding direct connection).""" + return any(p is not None for p in self._proxies) + + @classmethod + def initialize(cls, proxy_file: str, include_host: bool = False) -> "ProxyManager": + """Initialize the singleton instance. + + Should be called once at application startup. + + Args: + proxy_file: Path to proxy file + include_host: Whether to include direct connection in rotation + + Returns: + The initialized ProxyManager instance + """ + with cls._lock: + if cls._instance is None: + cls._instance = cls(proxy_file, include_host) + return cls._instance + + @classmethod + def get_instance(cls) -> Optional["ProxyManager"]: + """Get the singleton instance, or None if not initialized.""" + return cls._instance + + @classmethod + def reset(cls) -> None: + """Reset the singleton instance (mainly for testing).""" + with cls._lock: + cls._instance = None diff --git a/tt-scrap/app/registry.py b/tt-scrap/app/registry.py new file mode 100644 index 0000000..e1bcdf2 --- /dev/null +++ b/tt-scrap/app/registry.py @@ -0,0 +1,36 @@ +"""Service registry for mapping services to their clients and routes.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from fastapi import APIRouter + +from .base_client import BaseClient + + +@dataclass +class ServiceEntry: + """A registered scraper service.""" + + name: str + client: BaseClient + router: APIRouter + shutdown: Callable[[], Awaitable[None]] | None = None + + +class ServiceRegistry: + """Registry of scraper services.""" + + def __init__(self) -> None: + self._services: dict[str, ServiceEntry] = {} + + def register(self, entry: ServiceEntry) -> None: + self._services[entry.name] = entry + + def get(self, name: str) -> ServiceEntry | None: + return self._services.get(name) + + def get_all(self) -> list[ServiceEntry]: + return list(self._services.values()) diff --git a/tt-scrap/app/routes/__init__.py b/tt-scrap/app/routes/__init__.py new file mode 100644 index 0000000..da90c64 --- /dev/null +++ b/tt-scrap/app/routes/__init__.py @@ -0,0 +1,8 @@ +"""API route aggregation (shared routes only).""" + +from fastapi import APIRouter + +from .health import router as health_router + +router = APIRouter() +router.include_router(health_router) diff --git a/tt-scrap/app/routes/health.py b/tt-scrap/app/routes/health.py new file mode 100644 index 0000000..e3b1b2f --- /dev/null +++ b/tt-scrap/app/routes/health.py @@ -0,0 +1,15 @@ +"""Health check endpoint.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from ..models import HealthResponse + +router = APIRouter() + + +@router.get("/health", response_model=HealthResponse) +async def health(): + """Health check endpoint.""" + return HealthResponse() diff --git a/tt-scrap/app/services/__init__.py b/tt-scrap/app/services/__init__.py new file mode 100644 index 0000000..3372ffb --- /dev/null +++ b/tt-scrap/app/services/__init__.py @@ -0,0 +1,5 @@ +"""Scraper service implementations.""" + +from .tiktok import create_tiktok_service + +__all__ = ["create_tiktok_service"] diff --git a/tt-scrap/app/services/tiktok/__init__.py b/tt-scrap/app/services/tiktok/__init__.py new file mode 100644 index 0000000..fe9c5d3 --- /dev/null +++ b/tt-scrap/app/services/tiktok/__init__.py @@ -0,0 +1,36 @@ +"""TikTok scraper service.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ...registry import ServiceEntry +from .client import TikTokClient +from .routes import router, set_client + +if TYPE_CHECKING: + from ...proxy_manager import ProxyManager + +logger = logging.getLogger(__name__) + + +async def _shutdown_tiktok() -> None: + await TikTokClient.close_http_client() + TikTokClient.shutdown_executor() + + +def create_tiktok_service( + proxy_manager: "ProxyManager | None" = None, +) -> ServiceEntry: + client = TikTokClient(proxy_manager=proxy_manager) + set_client(client) + + logger.info("TikTok scraper service initialized") + + return ServiceEntry( + name="tiktok", + client=client, + router=router, + shutdown=_shutdown_tiktok, + ) diff --git a/tt-scrap/app/services/tiktok/client.py b/tt-scrap/app/services/tiktok/client.py new file mode 100644 index 0000000..57fb139 --- /dev/null +++ b/tt-scrap/app/services/tiktok/client.py @@ -0,0 +1,533 @@ +"""TikTok API client for extracting video and music metadata (no downloads).""" + +import asyncio +import logging +import os +import re +import threading +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +import httpx +import yt_dlp + +try: + from yt_dlp.networking.impersonate import ImpersonateTarget +except ImportError: + ImpersonateTarget = None + +from .config import tiktok_settings +from ...exceptions import ( + ContentDeletedError, + ContentPrivateError, + ExtractionError, + InvalidLinkError, + NetworkError, + RateLimitError, + RegionBlockedError, + ScraperError, +) + +# TikTok WAF blocks newer Chrome versions (136+) when used with proxies due to +# TLS fingerprint / User-Agent mismatches. Use Chrome 120 which is known to work. +TIKTOK_USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + +if TYPE_CHECKING: + from ...proxy_manager import ProxyManager + +logger = logging.getLogger(__name__) + + +def _strip_proxy_auth(proxy_url: str | None) -> str: + if proxy_url is None: + return "direct connection" + match = re.match(r"^(https?://)(?:[^@]+@)?(.+)$", proxy_url) + if match: + protocol, host_port = match.groups() + return f"{protocol}{host_port}" + return proxy_url + + +@dataclass +class ProxySession: + """Manages proxy state for a single request flow. + + Ensures the same proxy is used across URL resolution and info extraction + unless a retry rotates it. + """ + + proxy_manager: "ProxyManager | None" + _current_proxy: str | None = field(default=None, init=False) + _initialized: bool = field(default=False, init=False) + + def get_proxy(self) -> str | None: + if not self._initialized: + self._initialized = True + if self.proxy_manager: + self._current_proxy = self.proxy_manager.get_next_proxy() + logger.debug( + f"ProxySession initialized with proxy: " + f"{_strip_proxy_auth(self._current_proxy)}" + ) + else: + logger.debug("ProxySession initialized with direct connection") + return self._current_proxy + + def rotate_proxy(self) -> str | None: + if self.proxy_manager: + old_proxy = self._current_proxy + self._current_proxy = self.proxy_manager.get_next_proxy() + logger.debug( + f"ProxySession rotated: {_strip_proxy_auth(old_proxy)} -> " + f"{_strip_proxy_auth(self._current_proxy)}" + ) + self._initialized = True + return self._current_proxy + + +class TikTokClient: + """Client for extracting TikTok video and music metadata via yt-dlp. + + Extracts only metadata -- no media downloads. + """ + + _executor: ThreadPoolExecutor | None = None + _executor_lock = threading.Lock() + _executor_size: int = 500 + + _http_client: httpx.AsyncClient | None = None + _http_client_lock = threading.Lock() + _impersonate_available: bool | None = None + + @classmethod + def _get_executor(cls) -> ThreadPoolExecutor: + with cls._executor_lock: + if cls._executor is None: + cls._executor = ThreadPoolExecutor( + max_workers=cls._executor_size, + thread_name_prefix="tiktok_sync_", + ) + logger.info( + f"Created TikTokClient executor with {cls._executor_size} workers" + ) + return cls._executor + + @classmethod + def _get_http_client(cls) -> httpx.AsyncClient: + with cls._http_client_lock: + if cls._http_client is None or cls._http_client.is_closed: + cls._http_client = httpx.AsyncClient( + follow_redirects=True, + timeout=httpx.Timeout(15.0, connect=5.0, read=10.0), + limits=httpx.Limits( + max_connections=None, + max_keepalive_connections=None, + ), + ) + return cls._http_client + + @classmethod + def _can_impersonate(cls) -> bool: + if cls._impersonate_available is None: + if ImpersonateTarget is None: + cls._impersonate_available = False + else: + try: + ydl = yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True}) + targets = list(ydl._get_available_impersonate_targets()) + ydl.close() + cls._impersonate_available = bool(targets) + if not cls._impersonate_available: + logger.warning( + "No impersonate targets available (curl_cffi not installed?), " + "falling back to User-Agent header only" + ) + except Exception: + cls._impersonate_available = False + logger.warning("Failed to check impersonate targets, skipping impersonation") + return cls._impersonate_available + + @classmethod + async def close_http_client(cls) -> None: + with cls._http_client_lock: + client = cls._http_client + cls._http_client = None + if client and not client.is_closed: + await client.aclose() + + @classmethod + def shutdown_executor(cls) -> None: + with cls._executor_lock: + if cls._executor is not None: + cls._executor.shutdown(wait=False) + cls._executor = None + + def __init__( + self, + proxy_manager: "ProxyManager | None" = None, + cookies: str | None = None, + ): + self.proxy_manager = proxy_manager + + cookies_path = cookies or os.getenv("YTDLP_COOKIES") + if cookies_path: + if not os.path.isabs(cookies_path): + cookies_path = os.path.abspath(cookies_path) + if os.path.isfile(cookies_path): + self.cookies = cookies_path + else: + logger.warning( + f"Cookie file not found: {cookies_path} - cookies will not be used" + ) + self.cookies = None + else: + self.cookies = None + + async def _resolve_url( + self, + url: str, + proxy_session: ProxySession, + max_retries: int | None = None, + ) -> str: + if max_retries is None: + max_retries = tiktok_settings.url_resolve_max_retries + + is_short_url = ( + "vm.tiktok.com" in url + or "vt.tiktok.com" in url + or "/t/" in url + ) + + if not is_short_url: + return url + + last_error: Exception | None = None + + for attempt in range(1, max_retries + 1): + proxy = proxy_session.get_proxy() + logger.debug( + f"URL resolve attempt {attempt}/{max_retries} for {url} " + f"via {_strip_proxy_auth(proxy)}" + ) + + try: + if proxy: + async with httpx.AsyncClient( + follow_redirects=True, + timeout=httpx.Timeout(15.0, connect=5.0, read=10.0), + proxy=proxy, + ) as client: + response = await client.get(url) + else: + client = self._get_http_client() + response = await client.get(url) + + resolved_url = str(response.url) + if "tiktok.com" in resolved_url: + logger.debug(f"URL resolved: {url} -> {resolved_url}") + return resolved_url + + logger.warning( + f"URL resolution returned unexpected URL: {resolved_url}" + ) + last_error = ValueError(f"Unexpected redirect: {resolved_url}") + except Exception as e: + logger.warning( + f"URL resolve attempt {attempt}/{max_retries} failed for {url}: {e}" + ) + last_error = e + + if attempt < max_retries: + proxy_session.rotate_proxy() + + logger.error( + f"URL resolution failed after {max_retries} attempts for {url}: {last_error}" + ) + raise InvalidLinkError("Invalid or expired TikTok link") + + def _extract_video_id(self, url: str) -> str | None: + match = re.search(r"/(?:video|photo)/(\d+)", url) + return match.group(1) if match else None + + def _get_ydl_opts( + self, use_proxy: bool = True, explicit_proxy: Any = ... + ) -> dict[str, Any]: + opts: dict[str, Any] = { + "quiet": True, + "no_warnings": True, + } + + if self._can_impersonate(): + opts["impersonate"] = ImpersonateTarget("chrome", "120", "macos", None) + opts["http_headers"] = {"User-Agent": TIKTOK_USER_AGENT} + + if explicit_proxy is not ...: + if explicit_proxy is not None: + opts["proxy"] = explicit_proxy + logger.debug( + f"Using explicit proxy: {_strip_proxy_auth(explicit_proxy)}" + ) + else: + logger.debug("Using explicit direct connection (no proxy)") + elif use_proxy and self.proxy_manager: + proxy = self.proxy_manager.get_next_proxy() + if proxy is not None: + opts["proxy"] = proxy + logger.debug(f"Using proxy: {_strip_proxy_auth(proxy)}") + else: + logger.debug("Using direct connection (no proxy)") + + if self.cookies: + opts["cookiefile"] = self.cookies + logger.debug(f"yt-dlp using cookie file: {self.cookies}") + return opts + + def _extract_with_context_sync( + self, url: str, video_id: str, request_proxy: Any = ... + ) -> tuple[dict[str, Any] | None, str | None, dict[str, Any] | None]: + ydl_opts = self._get_ydl_opts(use_proxy=True, explicit_proxy=request_proxy) + ydl = None + + try: + ydl = yt_dlp.YoutubeDL(ydl_opts) + ie = ydl.get_info_extractor("TikTok") + ie.set_downloader(ydl) + + normalized_url = url.replace("/photo/", "/video/") + + if not hasattr(ie, "_extract_web_data_and_status"): + logger.error( + "yt-dlp's TikTok extractor is missing '_extract_web_data_and_status' method. " + f"Current yt-dlp version: {yt_dlp.version.__version__}. " + "Please update yt-dlp: pip install -U yt-dlp" + ) + raise ExtractionError( + "Incompatible yt-dlp version: missing required internal method." + ) + + try: + video_data, status = ie._extract_web_data_and_status( + normalized_url, video_id + ) + + if status in (10204, 10216): + return None, "deleted", None + if status == 10222: + return None, "private", None + + if not video_data: + logger.error(f"No video data returned for {video_id} (status={status})") + return None, "extraction", None + except AttributeError as e: + logger.error( + f"Failed to call yt-dlp internal method: {e}. " + f"Current yt-dlp version: {yt_dlp.version.__version__}." + ) + raise ExtractionError( + "Incompatible yt-dlp version." + ) from e + + download_context = { + "ydl": ydl, + "ie": ie, + "referer_url": url, + "proxy": request_proxy if request_proxy is not ... else None, + } + + ydl = None # Transfer ownership to caller + return video_data, status, download_context + + except yt_dlp.utils.DownloadError as e: + error_msg = str(e).lower() + if "unavailable" in error_msg or "removed" in error_msg or "deleted" in error_msg: + return None, "deleted", None + elif "private" in error_msg: + return None, "private", None + elif "rate" in error_msg or "too many" in error_msg or "429" in error_msg: + return None, "rate_limit", None + elif ( + "region" in error_msg + or "geo" in error_msg + or "country" in error_msg + or "not available in your" in error_msg + ): + return None, "region", None + logger.error(f"yt-dlp download error for video {video_id}: {e}") + return None, "extraction", None + except yt_dlp.utils.ExtractorError as e: + logger.error(f"yt-dlp extractor error for video {video_id}: {e}") + return None, "extraction", None + except ScraperError: + raise + except Exception as e: + logger.error(f"yt-dlp extraction failed for video {video_id}: {e}", exc_info=True) + return None, "extraction", None + finally: + if ydl is not None: + try: + ydl.close() + except Exception: + pass + + async def _run_sync(self, func: Any, *args: Any) -> Any: + loop = asyncio.get_running_loop() + return await loop.run_in_executor(self._get_executor(), func, *args) + + def _close_download_context( + self, download_context: dict[str, Any] | None + ) -> None: + if download_context and "ydl" in download_context: + try: + download_context["ydl"].close() + except Exception: + pass + + _STATUS_EXCEPTIONS: dict[str, type[ScraperError]] = { + "deleted": ContentDeletedError, + "private": ContentPrivateError, + "rate_limit": RateLimitError, + "network": NetworkError, + "region": RegionBlockedError, + } + + _STATUS_MESSAGES: dict[str, str] = { + "deleted": "Video {link} was deleted", + "private": "Video {link} is private", + "rate_limit": "Rate limited by TikTok", + "network": "Network error occurred", + "region": "Video {link} is not available in your region", + } + + def _raise_for_status(self, status: str, video_link: str) -> None: + exc_cls = self._STATUS_EXCEPTIONS.get(status) + if exc_cls: + message = self._STATUS_MESSAGES[status].format(link=video_link) + raise exc_cls(message) + raise ExtractionError(f"Failed to extract video {video_link}") + + async def _extract_video_info_with_retry( + self, + url: str, + video_id: str, + proxy_session: ProxySession, + max_retries: int | None = None, + ) -> tuple[dict[str, Any], dict[str, Any]]: + if max_retries is None: + max_retries = tiktok_settings.video_info_max_retries + + last_error: Exception | None = None + download_context: dict[str, Any] | None = None + + for attempt in range(1, max_retries + 1): + proxy = proxy_session.get_proxy() + logger.debug( + f"Video info extraction attempt {attempt}/{max_retries} for {video_id} " + f"via {_strip_proxy_auth(proxy)}" + ) + + try: + video_data, status, download_context = await self._run_sync( + self._extract_with_context_sync, url, video_id, proxy + ) + + if status in ("deleted", "private", "region"): + self._raise_for_status(status, url) + + if status and status not in ("ok", None): + raise ExtractionError(f"Extraction failed with status: {status}") + + if video_data is None: + raise ExtractionError("No video data returned") + + if download_context is None: + raise ExtractionError("No download context returned") + + return video_data, download_context + + except (ContentDeletedError, ContentPrivateError, RegionBlockedError): + self._close_download_context(download_context) + raise + + except Exception as e: + last_error = e + self._close_download_context(download_context) + download_context = None + + if attempt < max_retries: + proxy_session.rotate_proxy() + logger.warning( + f"Video info extraction attempt {attempt}/{max_retries} failed, " + f"rotating proxy: {last_error}" + ) + + logger.error( + f"Video info extraction failed after {max_retries} attempts " + f"for {video_id}: {last_error}" + ) + raise ExtractionError( + f"Failed to extract video info after {max_retries} attempts" + ) + + async def extract_video_info(self, video_link: str) -> dict[str, Any]: + proxy_session = ProxySession(self.proxy_manager) + download_context: dict[str, Any] | None = None + + try: + full_url = await self._resolve_url(video_link, proxy_session) + video_id = self._extract_video_id(full_url) + + if not video_id: + raise InvalidLinkError("Invalid or expired TikTok link") + + extraction_url = f"https://www.tiktok.com/@_/video/{video_id}" + video_data, download_context = await self._extract_video_info_with_retry( + extraction_url, video_id, proxy_session + ) + + return { + "video_data": video_data, + "video_id": video_id, + "resolved_url": full_url, + } + + except (ScraperError, asyncio.CancelledError): + raise + except httpx.HTTPError as e: + raise NetworkError(f"Network error: {e}") from e + except Exception as e: + raise ExtractionError(f"Failed to extract video info: {e}") from e + finally: + self._close_download_context(download_context) + + async def extract_music_info(self, video_id: int) -> dict[str, Any] | None: + proxy_session = ProxySession(self.proxy_manager) + download_context: dict[str, Any] | None = None + + try: + url = f"https://www.tiktok.com/@_/video/{video_id}" + video_data, download_context = await self._extract_video_info_with_retry( + url, str(video_id), proxy_session + ) + + music_info = video_data.get("music") + if not music_info: + raise ExtractionError(f"No music info found for video {video_id}") + + return { + "video_data": video_data, + "music_data": music_info, + "video_id": video_id, + } + + except (ScraperError, asyncio.CancelledError): + raise + except httpx.HTTPError as e: + raise NetworkError(f"Network error: {e}") from e + except Exception as e: + raise ExtractionError(f"Failed to extract music info: {e}") from e + finally: + self._close_download_context(download_context) diff --git a/tt-scrap/app/services/tiktok/config.py b/tt-scrap/app/services/tiktok/config.py new file mode 100644 index 0000000..c25b6ea --- /dev/null +++ b/tt-scrap/app/services/tiktok/config.py @@ -0,0 +1,27 @@ +"""TikTok-specific configuration.""" + +from __future__ import annotations + +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class TikTokSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="TIKTOK_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + url_resolve_max_retries: int = 3 + video_info_max_retries: int = 3 + + +@lru_cache +def get_tiktok_settings() -> TikTokSettings: + return TikTokSettings() + + +tiktok_settings = get_tiktok_settings() diff --git a/tt-scrap/app/services/tiktok/parser.py b/tt-scrap/app/services/tiktok/parser.py new file mode 100644 index 0000000..0b2e70f --- /dev/null +++ b/tt-scrap/app/services/tiktok/parser.py @@ -0,0 +1,100 @@ +"""TikTok-specific response parsing.""" + +from __future__ import annotations + +from typing import Any + +from ...models import MusicDetailResponse, MusicResponse, VideoResponse + + +def build_video_response( + video_data: dict[str, Any], + video_id: int, + link: str, +) -> VideoResponse: + image_post = video_data.get("imagePost") + video_info = video_data.get("video", {}) + stats = video_data.get("stats", {}) + + music_data = video_data.get("music") + music = None + if music_data: + music_url = music_data.get("playUrl") + if music_url: + music = MusicResponse( + url=music_url, + title=music_data.get("title", ""), + author=music_data.get("authorName", ""), + duration=int(music_data.get("duration", 0)), + cover=( + music_data.get("coverLarge") + or music_data.get("coverMedium") + or music_data.get("coverThumb") + or "" + ), + ) + + if image_post: + image_urls = [ + url_list[0] + for img in image_post.get("images", []) + if (url_list := img.get("imageURL", {}).get("urlList", [])) + ] + + return VideoResponse( + type="images", + id=video_id, + image_urls=image_urls, + likes=stats.get("diggCount"), + views=stats.get("playCount"), + link=link, + music=music, + ) + + video_url = video_info.get("playAddr") or video_info.get("downloadAddr") + if not video_url: + for br in video_info.get("bitrateInfo", []): + url_list = br.get("PlayAddr", {}).get("UrlList", []) + if url_list: + video_url = url_list[0] + break + + raw_duration = video_info.get("duration") + raw_width = video_info.get("width") + raw_height = video_info.get("height") + cover = video_info.get("cover") or video_info.get("originCover") + + return VideoResponse( + type="video", + id=video_id, + video_url=video_url, + cover=cover, + width=int(raw_width) if raw_width else None, + height=int(raw_height) if raw_height else None, + duration=int(raw_duration) if raw_duration else None, + likes=stats.get("diggCount"), + views=stats.get("playCount"), + link=link, + music=music, + ) + + +def build_music_response( + music_data: dict[str, Any], + video_id: int, +) -> MusicDetailResponse: + cover = ( + music_data.get("coverLarge") + or music_data.get("coverMedium") + or music_data.get("coverThumb") + or "" + ) + + return MusicDetailResponse( + id=video_id, + title=music_data.get("title", ""), + author=music_data.get("authorName", ""), + duration=int(music_data.get("duration", 0)), + cover=cover, + url=music_data.get("playUrl", ""), + ) diff --git a/tt-scrap/app/services/tiktok/routes.py b/tt-scrap/app/services/tiktok/routes.py new file mode 100644 index 0000000..b4bf80b --- /dev/null +++ b/tt-scrap/app/services/tiktok/routes.py @@ -0,0 +1,78 @@ +"""TikTok API routes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import APIRouter, Query + +from ...exceptions import ExtractionError +from ...models import ( + MusicDetailResponse, + RawMusicResponse, + RawVideoResponse, + VideoResponse, +) +from .parser import build_music_response, build_video_response + +if TYPE_CHECKING: + from .client import TikTokClient + +router = APIRouter(prefix="/tiktok", tags=["tiktok"]) + +_client: "TikTokClient | None" = None + + +def set_client(client: "TikTokClient") -> None: + global _client + _client = client + + +def _get_client() -> "TikTokClient": + assert _client is not None, "TikTok client not initialized" + return _client + + +@router.get("/video", response_model=VideoResponse | RawVideoResponse) +async def get_video( + url: str = Query(..., description="TikTok video or slideshow URL"), + raw: bool = Query(False, description="Return raw TikTok API data"), +): + """Extract video/slideshow info from a TikTok URL.""" + client = _get_client() + result = await client.extract_video_info(url) + video_data = result["video_data"] + video_id = int(result["video_id"]) + resolved_url = result["resolved_url"] + + if raw: + return RawVideoResponse( + id=video_id, + resolved_url=resolved_url, + data=video_data, + ) + + return build_video_response(video_data, video_id, url) + + +@router.get("/music", response_model=MusicDetailResponse | RawMusicResponse) +async def get_music( + video_id: int = Query(..., description="TikTok video ID"), + raw: bool = Query(False, description="Return raw TikTok API data"), +): + """Extract music info from a TikTok video.""" + client = _get_client() + result = await client.extract_music_info(video_id) + + if result is None: + raise ExtractionError("Music extraction not available") + + music_data = result["music_data"] + + if raw: + return RawMusicResponse( + id=video_id, + data=result["video_data"], + ) + + return build_music_response(music_data, video_id) diff --git a/tt-scrap/pyproject.toml b/tt-scrap/pyproject.toml new file mode 100644 index 0000000..cea05a4 --- /dev/null +++ b/tt-scrap/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "tt-scrap" +version = "0.1.0" +description = "TikTok video/music/slideshow metadata extraction REST API" +requires-python = "==3.13.*" +dependencies = [ + "fastapi>=0.135.1", + "uvicorn>=0.41.0", + "yt-dlp>=2026.3.3", + "httpx>=0.28.1", + "curl-cffi>=0.14.0", + "pydantic-settings>=2.13.1", +] diff --git a/tt-scrap/uv.lock b/tt-scrap/uv.lock new file mode 100644 index 0000000..b64accd --- /dev/null +++ b/tt-scrap/uv.lock @@ -0,0 +1,321 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "curl-cffi" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633, upload-time = "2025-12-16T03:25:07.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277, upload-time = "2025-12-16T03:24:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650, upload-time = "2025-12-16T03:24:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918, upload-time = "2025-12-16T03:24:52.862Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624, upload-time = "2025-12-16T03:24:54.579Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654, upload-time = "2025-12-16T03:24:56.507Z" }, + { url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969, upload-time = "2025-12-16T03:24:57.885Z" }, + { url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133, upload-time = "2025-12-16T03:24:59.261Z" }, + { url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167, upload-time = "2025-12-16T03:25:00.946Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464, upload-time = "2025-12-16T03:25:02.922Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416, upload-time = "2025-12-16T03:25:04.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "tt-scrap" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "curl-cffi" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic-settings" }, + { name = "uvicorn" }, + { name = "yt-dlp" }, +] + +[package.metadata] +requires-dist = [ + { name = "curl-cffi", specifier = ">=0.14.0" }, + { name = "fastapi", specifier = ">=0.135.1" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic-settings", specifier = ">=2.13.1" }, + { name = "uvicorn", specifier = ">=0.41.0" }, + { name = "yt-dlp", specifier = ">=2026.3.3" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] + +[[package]] +name = "yt-dlp" +version = "2026.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/6f/7427d23609353e5ef3470ff43ef551b8bd7b166dd4fef48957f0d0e040fe/yt_dlp-2026.3.3.tar.gz", hash = "sha256:3db7969e3a8964dc786bdebcffa2653f31123bf2a630f04a17bdafb7bbd39952", size = 3118658, upload-time = "2026-03-03T16:54:53.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/a4/8b5cd28ab87aef48ef15e74241befec3445496327db028f34147a9e0f14f/yt_dlp-2026.3.3-py3-none-any.whl", hash = "sha256:166c6e68c49ba526474bd400e0129f58aa522c2896204aa73be669c3d2f15e63", size = 3315599, upload-time = "2026-03-03T16:54:51.899Z" }, +] diff --git a/uv.lock b/uv.lock index a6f6848..5ebbeb5 100644 --- a/uv.lock +++ b/uv.lock @@ -138,29 +138,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, -] - [[package]] name = "contourpy" version = "1.3.3" @@ -194,27 +171,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, ] -[[package]] -name = "curl-cffi" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" }, - { url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" }, - { url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" }, - { url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" }, - { url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" }, - { url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, -] - [[package]] name = "cycler" version = "0.12.1" @@ -582,15 +538,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] -[[package]] -name = "pycparser" -version = "2.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, -] - [[package]] name = "pydantic" version = "2.12.5" @@ -715,10 +662,8 @@ dependencies = [ [package.optional-dependencies] main = [ { name = "apscheduler" }, - { name = "curl-cffi" }, { name = "pillow" }, { name = "pillow-heif" }, - { name = "yt-dlp" }, ] stats = [ { name = "apscheduler" }, @@ -733,14 +678,12 @@ requires-dist = [ { name = "apscheduler", marker = "extra == 'main'", specifier = "==3.11.2" }, { name = "apscheduler", marker = "extra == 'stats'", specifier = "==3.11.2" }, { name = "asyncpg", specifier = "==0.31.0" }, - { name = "curl-cffi", marker = "extra == 'main'", specifier = ">=0.10.0,<0.15.0" }, { name = "matplotlib", marker = "extra == 'stats'" }, { name = "pandas", marker = "extra == 'stats'", specifier = "==2.3.3" }, { name = "pillow", marker = "extra == 'main'", specifier = "==12.1.0" }, { name = "pillow-heif", marker = "extra == 'main'", specifier = "==1.1.1" }, { name = "python-dotenv", specifier = "==1.2.1" }, { name = "sqlalchemy", specifier = "==2.0.45" }, - { name = "yt-dlp", marker = "extra == 'main'", specifier = "==2026.2.4" }, ] provides-extras = ["stats", "main"] @@ -831,12 +774,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ] - -[[package]] -name = "yt-dlp" -version = "2026.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/be/8e099f3f34bac6851490525fb1a8b62d525a95fcb5af082e8c52ba884fb5/yt_dlp-2026.2.4.tar.gz", hash = "sha256:24733ef081116f29d8ee6eae7a48127101e6c56eb7aa228dd604a60654760022", size = 3100305, upload-time = "2026-02-04T00:49:27.043Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/38/b17cbeaf6712a4c1b97f7f9ec3a55f3a8ddee678cc88742af47dca0315b7/yt_dlp-2026.2.4-py3-none-any.whl", hash = "sha256:d6ea83257e8127a0097b1d37ee36201f99a292067e4616b2e5d51ab153b3dbb9", size = 3299165, upload-time = "2026-02-04T00:49:25.31Z" }, -]