From 190771daa60d63c5f4f1a3820e056cb321ee60f9 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Wed, 11 Mar 2026 16:49:48 -0700 Subject: [PATCH 01/10] Add retry mechanism for Instagram RapidAPI requests Transient failures (network hiccups, 429 rate limits, 5xx errors) now retry up to 3 attempts with 3s/5s delays, using a tighter per-request timeout (10s total, 3s connect). Non-retryable errors (404) still fail immediately. --- instagram_api/client.py | 100 +++++++++++++++++++++++++++------------- 1 file changed, 69 insertions(+), 31 deletions(-) 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", []): From 3f034e90f78c7378333b5f4262f75e85692b5505 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Wed, 11 Mar 2026 16:55:02 -0700 Subject: [PATCH 02/10] Add processing emoji reaction to Instagram uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show 👨‍💻 reaction after fetching media info and before uploading, matching the existing TikTok download behavior. --- handlers/instagram.py | 10 ++++++++++ handlers/link_dispatcher.py | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/handlers/instagram.py b/handlers/instagram.py index 6a90d18..b10d90d 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: + logging.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: From dabe2106c4e9f623121c2c450d14e4bd0ba54b1c Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Wed, 11 Mar 2026 16:58:23 -0700 Subject: [PATCH 03/10] Use module-level logger instead of root logging in Instagram handler --- handlers/instagram.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handlers/instagram.py b/handlers/instagram.py index b10d90d..b455a4b 100644 --- a/handlers/instagram.py +++ b/handlers/instagram.py @@ -45,7 +45,7 @@ async def handle_instagram_link( [ReactionTypeEmoji(emoji="👨‍💻")], disable_notification=True ) except TelegramBadRequest: - logging.debug("Failed to set processing reaction") + logger.debug("Failed to set processing reaction") if media_info.is_video: await bot.send_chat_action( From 735c53d7f2ea5711c497474c6730179a735f1841 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Thu, 12 Mar 2026 20:42:22 -0700 Subject: [PATCH 04/10] refactor(tiktok): extract scraper into standalone package with REST API Reorganize TikTok scraper functionality into a reusable `tiktok_scrapper` package with the following changes: - Move client, models, exceptions, and proxy_manager from tiktok_api/ to tiktok_scrapper/ - Create standalone config system based on environment variables - Add FastAPI REST API server (app.py) with endpoints: * GET /video - Extract video/slideshow metadata and CDN URLs * GET /music - Extract music metadata * GET /check - Validate TikTok URLs via regex * GET /health - Health check - Add two new client methods for metadata extraction without downloading: * extract_video_info() - Get raw video data from TikTok API * extract_music_info() - Get raw music data from TikTok API - Add Pydantic models for JSON API responses - Add Dockerfile for containerized API deployment - Keep tiktok_api as backward-compatible shim re-exporting from tiktok_scrapper - Move yt-dlp and curl_cffi dependencies to tiktok_scrapper package --- pyproject.toml | 8 +- tiktok_api/__init__.py | 37 +- tiktok_scrapper/Dockerfile | 16 + tiktok_scrapper/__init__.py | 45 ++ tiktok_scrapper/app.py | 269 +++++++++ {tiktok_api => tiktok_scrapper}/client.py | 97 +++- tiktok_scrapper/config.py | 67 +++ {tiktok_api => tiktok_scrapper}/exceptions.py | 0 {tiktok_api => tiktok_scrapper}/models.py | 92 +++- .../proxy_manager.py | 0 tiktok_scrapper/pyproject.toml | 18 + tiktok_scrapper/uv.lock | 518 ++++++++++++++++++ uv.lock | 121 +++- 13 files changed, 1249 insertions(+), 39 deletions(-) create mode 100644 tiktok_scrapper/Dockerfile create mode 100644 tiktok_scrapper/__init__.py create mode 100644 tiktok_scrapper/app.py rename {tiktok_api => tiktok_scrapper}/client.py (95%) create mode 100644 tiktok_scrapper/config.py rename {tiktok_api => tiktok_scrapper}/exceptions.py (100%) rename {tiktok_api => tiktok_scrapper}/models.py (70%) rename {tiktok_api => tiktok_scrapper}/proxy_manager.py (100%) create mode 100644 tiktok_scrapper/pyproject.toml create mode 100644 tiktok_scrapper/uv.lock diff --git a/pyproject.toml b/pyproject.toml index 0db8112..059de98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,12 +23,12 @@ 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", + "tiktok-scrapper", ] [tool.uv] # This is an application, not a library package package = false + +[tool.uv.sources] +tiktok-scrapper = { path = "tiktok_scrapper", editable = true } diff --git a/tiktok_api/__init__.py b/tiktok_api/__init__.py index 91665a2..ac23664 100644 --- a/tiktok_api/__init__.py +++ b/tiktok_api/__init__.py @@ -1,26 +1,9 @@ -"""TikTok API client for extracting video and music information. +"""Compatibility shim - re-exports from tiktok_scrapper package.""" -This module provides a clean interface to extract TikTok video/slideshow data -and music information using yt-dlp internally. - -Example: - >>> from tiktok_api import TikTokClient, ProxyManager, VideoInfo, TikTokDeletedError - >>> - >>> # Initialize proxy manager (optional) - >>> proxy_manager = ProxyManager.initialize("proxies.txt", include_host=True) - >>> - >>> client = TikTokClient(proxy_manager=proxy_manager, data_only_proxy=True) - >>> try: - ... video_info = await client.video("https://www.tiktok.com/@user/video/123") - ... print(video_info.id) - ... if video_info.is_video: - ... print(f"Duration: {video_info.duration}s") - ... except TikTokDeletedError: - ... print("Video was deleted") -""" - -from .client import TikTokClient, ttapi -from .exceptions import ( +from tiktok_scrapper import ( + MusicInfo, + ProxyManager, + TikTokClient, TikTokDeletedError, TikTokError, TikTokExtractionError, @@ -30,20 +13,16 @@ TikTokRateLimitError, TikTokRegionError, TikTokVideoTooLongError, + VideoInfo, + ttapi, ) -from .models import MusicInfo, VideoInfo -from .proxy_manager import ProxyManager __all__ = [ - # Client "TikTokClient", - "ttapi", # Backwards compatibility alias - # Proxy + "ttapi", "ProxyManager", - # Models "VideoInfo", "MusicInfo", - # Exceptions "TikTokError", "TikTokDeletedError", "TikTokInvalidLinkError", diff --git a/tiktok_scrapper/Dockerfile b/tiktok_scrapper/Dockerfile new file mode 100644 index 0000000..bb88d46 --- /dev/null +++ b/tiktok_scrapper/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/tiktok_scrapper + +# Install dependencies first (cache layer) +COPY pyproject.toml ./ +RUN uv sync --frozen --no-dev 2>/dev/null || uv sync --no-dev + +# Copy application code +COPY *.py ./ + +EXPOSE 8000 + +CMD ["uv", "run", "uvicorn", "tiktok_scrapper.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/tiktok_scrapper/__init__.py b/tiktok_scrapper/__init__.py new file mode 100644 index 0000000..4a2e1ff --- /dev/null +++ b/tiktok_scrapper/__init__.py @@ -0,0 +1,45 @@ +"""TikTok scrapper - standalone TikTok video/music/slideshow extraction. + +Example: + >>> from tiktok_scrapper import TikTokClient, ProxyManager, VideoInfo + >>> + >>> proxy_manager = ProxyManager.initialize("proxies.txt", include_host=True) + >>> client = TikTokClient(proxy_manager=proxy_manager, data_only_proxy=True) + >>> video_info = await client.video("https://www.tiktok.com/@user/video/123") +""" + +from .client import TikTokClient, ttapi +from .exceptions import ( + TikTokDeletedError, + TikTokError, + TikTokExtractionError, + TikTokInvalidLinkError, + TikTokNetworkError, + TikTokPrivateError, + TikTokRateLimitError, + TikTokRegionError, + TikTokVideoTooLongError, +) +from .models import MusicInfo, VideoInfo +from .proxy_manager import ProxyManager + +__all__ = [ + # Client + "TikTokClient", + "ttapi", + # Proxy + "ProxyManager", + # Models + "VideoInfo", + "MusicInfo", + # Exceptions + "TikTokError", + "TikTokDeletedError", + "TikTokInvalidLinkError", + "TikTokPrivateError", + "TikTokNetworkError", + "TikTokRateLimitError", + "TikTokRegionError", + "TikTokExtractionError", + "TikTokVideoTooLongError", +] diff --git a/tiktok_scrapper/app.py b/tiktok_scrapper/app.py new file mode 100644 index 0000000..d36c2ac --- /dev/null +++ b/tiktok_scrapper/app.py @@ -0,0 +1,269 @@ +"""FastAPI REST API server for TikTok scrapping.""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, Query +from fastapi.responses import JSONResponse + +from .client import TikTokClient +from .config import config +from .exceptions import ( + TikTokDeletedError, + TikTokError, + TikTokExtractionError, + TikTokInvalidLinkError, + TikTokNetworkError, + TikTokPrivateError, + TikTokRateLimitError, + TikTokRegionError, + TikTokVideoTooLongError, +) +from .models import ( + CheckResponse, + ErrorResponse, + HealthResponse, + MusicDetailResponse, + MusicResponse, + RawMusicResponse, + RawVideoResponse, + VideoResponse, +) +from .proxy_manager import ProxyManager + +logger = logging.getLogger(__name__) + +# Map TikTok exceptions to HTTP status codes +_ERROR_STATUS_MAP: dict[type[TikTokError], int] = { + TikTokDeletedError: 404, + TikTokPrivateError: 403, + TikTokInvalidLinkError: 400, + TikTokVideoTooLongError: 413, + TikTokRateLimitError: 429, + TikTokNetworkError: 502, + TikTokRegionError: 451, + TikTokExtractionError: 500, +} + +_client: TikTokClient | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global _client + + # Configure logging + log_level = config.get("logging", {}).get("log_level", logging.INFO) + logging.basicConfig(level=log_level, format="%(asctime)s %(name)s %(levelname)s %(message)s") + + # Initialize proxy manager + proxy_config = config.get("proxy", {}) + proxy_file = proxy_config.get("proxy_file", "") + proxy_manager = None + if proxy_file: + proxy_manager = ProxyManager.initialize( + proxy_file, + include_host=proxy_config.get("include_host", False), + ) + + # Initialize client + _client = TikTokClient( + proxy_manager=proxy_manager, + data_only_proxy=proxy_config.get("data_only", False), + ) + + logger.info("TikTok scrapper API started") + yield + + # Cleanup + await TikTokClient.close_connector() + await TikTokClient.close_curl_session() + TikTokClient.shutdown_executor() + logger.info("TikTok scrapper API stopped") + + +app = FastAPI( + title="TikTok Scrapper API", + version="0.1.0", + lifespan=lifespan, +) + + +@app.exception_handler(TikTokError) +async def tiktok_error_handler(request, exc: TikTokError): + 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(), + ) + + +def _build_filtered_video_response( + video_data: dict[str, Any], + video_id: int, + link: str, +) -> VideoResponse: + """Build a filtered VideoResponse from raw TikTok API data.""" + image_post = video_data.get("imagePost") + video_info = video_data.get("video", {}) + stats = video_data.get("stats", {}) + + # Extract music info if available + 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: + # Slideshow + images = image_post.get("images", []) + image_urls = [] + for img in images: + url_list = img.get("imageURL", {}).get("urlList", []) + if url_list: + image_urls.append(url_list[0]) + + return VideoResponse( + type="images", + id=video_id, + image_urls=image_urls, + likes=stats.get("diggCount"), + views=stats.get("playCount"), + link=link, + music=music, + ) + + # Video + video_url = ( + video_info.get("playAddr") + or video_info.get("downloadAddr") + ) + if not video_url: + # Try bitrateInfo + for br in video_info.get("bitrateInfo", []): + url_list = br.get("PlayAddr", {}).get("UrlList", []) + if url_list: + video_url = url_list[0] + break + + duration = video_info.get("duration") + if duration: + duration = int(duration) + + width = video_info.get("width") + 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(width) if width else None, + height=int(height) if height else None, + duration=duration, + likes=stats.get("diggCount"), + views=stats.get("playCount"), + link=link, + music=music, + ) + + +@app.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. + + With raw=false (default): returns filtered metadata with CDN URLs. + With raw=true: returns the full TikTok API response dict. + """ + 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_filtered_video_response(video_data, video_id, url) + + +@app.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. + + With raw=false (default): returns filtered music metadata with CDN URL. + With raw=true: returns the full music data from TikTok API. + """ + result = await _client.extract_music_info(video_id) + music_data = result["music_data"] + + if raw: + return RawMusicResponse( + id=video_id, + data=result["video_data"], + ) + + music_url = music_data.get("playUrl", "") + 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_url, + ) + + +@app.get("/check", response_model=CheckResponse) +async def check_url( + url: str = Query(..., description="URL to validate"), +): + """Quick regex validation of a TikTok URL (no network calls).""" + matched_url, is_mobile = await _client.regex_check(url) + return CheckResponse( + valid=matched_url is not None, + url=matched_url, + is_mobile=is_mobile, + ) + + +@app.get("/health", response_model=HealthResponse) +async def health(): + """Health check endpoint.""" + return HealthResponse() diff --git a/tiktok_api/client.py b/tiktok_scrapper/client.py similarity index 95% rename from tiktok_api/client.py rename to tiktok_scrapper/client.py index 7085b85..4242c12 100644 --- a/tiktok_api/client.py +++ b/tiktok_scrapper/client.py @@ -58,7 +58,7 @@ TikTokVideoTooLongError, ) from .models import MusicInfo, VideoInfo -from data.config import config +from .config import config if TYPE_CHECKING: from .proxy_manager import ProxyManager @@ -1967,6 +1967,101 @@ async def update_status(attempt: int): f"Failed to extract music after {max_attempts} attempts" ) + async def extract_video_info(self, video_link: str) -> dict[str, Any]: + """Extract video/slideshow metadata without downloading media. + + Performs Parts 1 & 2 only (URL resolution + info extraction). + Returns the raw TikTok API data dict for the caller to process. + + Args: + video_link: TikTok video or slideshow URL + + Returns: + Dict with keys: + - video_data: Raw TikTok API response dict + - video_id: Extracted video ID string + - resolved_url: Full resolved URL + + Raises: + Same exceptions as video() + """ + proxy_session = ProxySession(self.proxy_manager) + download_context: Optional[dict[str, Any]] = None + + try: + # Part 1: URL Resolution + full_url = await self._resolve_url(video_link, proxy_session) + video_id = self._extract_video_id(full_url) + + if not video_id: + raise TikTokInvalidLinkError("Invalid or expired TikTok link") + + # Part 2: Video Info Extraction + 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 TikTokError: + raise + except asyncio.CancelledError: + raise + except aiohttp.ClientError as e: + raise TikTokNetworkError(f"Network error: {e}") from e + except Exception as e: + raise TikTokExtractionError(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]: + """Extract music metadata without downloading audio. + + Performs Part 2 only (info extraction). + Returns the raw music data dict. + + Args: + video_id: TikTok video ID + + Returns: + Dict with the raw music info from TikTok API. + + Raises: + Same exceptions as music() + """ + proxy_session = ProxySession(self.proxy_manager) + download_context: Optional[dict[str, Any]] = 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 TikTokExtractionError(f"No music info found for video {video_id}") + + return { + "video_data": video_data, + "music_data": music_info, + "video_id": video_id, + } + + except TikTokError: + raise + except aiohttp.ClientError as e: + raise TikTokNetworkError(f"Network error: {e}") from e + except Exception as e: + raise TikTokExtractionError(f"Failed to extract music info: {e}") from e + finally: + self._close_download_context(download_context) + # Backwards compatibility alias ttapi = TikTokClient diff --git a/tiktok_scrapper/config.py b/tiktok_scrapper/config.py new file mode 100644 index 0000000..8ca6cff --- /dev/null +++ b/tiktok_scrapper/config.py @@ -0,0 +1,67 @@ +"""Standalone configuration for TikTok scrapper. + +Reads all settings from environment variables. No dependency on the bot's config. +""" + +from __future__ import annotations + +import logging +import os + +from dotenv import load_dotenv, find_dotenv + +load_dotenv(find_dotenv()) + + +def _parse_int_env(key: str, default: int) -> int: + value = os.getenv(key, "") + if value.strip(): + try: + return int(value) + except ValueError: + return default + return default + + +def _parse_bool_env(key: str, default: bool = False) -> bool: + value = os.getenv(key, "") + if value.strip(): + return value.strip().lower() == "true" + return default + + +def _parse_log_level(key: str, default: str = "INFO") -> int: + value = os.getenv(key, default).upper().strip() + level_map = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, + } + return level_map.get(value, logging.INFO) + + +config = { + "retry": { + "url_resolve_max_retries": _parse_int_env("URL_RESOLVE_MAX_RETRIES", 3), + "video_info_max_retries": _parse_int_env("VIDEO_INFO_MAX_RETRIES", 3), + "download_max_retries": _parse_int_env("DOWNLOAD_MAX_RETRIES", 3), + }, + "proxy": { + "proxy_file": os.getenv("PROXY_FILE", ""), + "data_only": _parse_bool_env("PROXY_DATA_ONLY"), + "include_host": _parse_bool_env("PROXY_INCLUDE_HOST"), + }, + "performance": { + "streaming_duration_threshold": _parse_int_env("STREAMING_DURATION_THRESHOLD", 300), + "max_video_duration": _parse_int_env("MAX_VIDEO_DURATION", 0), + }, + "logging": { + "log_level": _parse_log_level("LOG_LEVEL", "INFO"), + }, + "server": { + "host": os.getenv("HOST", "0.0.0.0"), + "port": _parse_int_env("PORT", 8000), + }, +} diff --git a/tiktok_api/exceptions.py b/tiktok_scrapper/exceptions.py similarity index 100% rename from tiktok_api/exceptions.py rename to tiktok_scrapper/exceptions.py diff --git a/tiktok_api/models.py b/tiktok_scrapper/models.py similarity index 70% rename from tiktok_api/models.py rename to tiktok_scrapper/models.py index 9aa73ef..cc13686 100644 --- a/tiktok_api/models.py +++ b/tiktok_scrapper/models.py @@ -1,4 +1,8 @@ -"""Data models for TikTok API responses.""" +"""Data models for TikTok scrapper. + +Internal dataclasses (VideoInfo, MusicInfo) are used by the client library. +Pydantic models (*Response) are used by the REST API for JSON serialization. +""" from __future__ import annotations @@ -6,12 +10,19 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, List, Optional, Union +from pydantic import BaseModel + if TYPE_CHECKING: from .client import ProxySession logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Internal dataclasses (used by TikTokClient) +# --------------------------------------------------------------------------- + + @dataclass class VideoInfo: """Information about a TikTok video or slideshow. @@ -145,3 +156,82 @@ class MusicInfo: author: str duration: int cover: str + + +# --------------------------------------------------------------------------- +# Pydantic API response models (used by FastAPI endpoints) +# --------------------------------------------------------------------------- + + +class MusicResponse(BaseModel): + """Music metadata returned by the API.""" + + url: str + title: str + author: str + duration: int + cover: str + + +class VideoResponse(BaseModel): + """Filtered video/slideshow response.""" + + type: str # "video" or "images" + id: int + video_url: Optional[str] = None + image_urls: list[str] = [] + cover: Optional[str] = None + width: Optional[int] = None + height: Optional[int] = None + duration: Optional[int] = None + likes: Optional[int] = None + views: Optional[int] = None + link: str + music: Optional[MusicResponse] = None + + +class RawVideoResponse(BaseModel): + """Raw TikTok API response (full yt-dlp extraction data).""" + + id: int + resolved_url: str + data: dict[str, Any] + + +class MusicDetailResponse(BaseModel): + """Filtered music response for the /music endpoint.""" + + id: int + title: str + author: str + duration: int + cover: str + url: str + + +class RawMusicResponse(BaseModel): + """Raw music data from TikTok API.""" + + id: int + data: dict[str, Any] + + +class CheckResponse(BaseModel): + """URL validation response.""" + + valid: bool + url: Optional[str] = None + is_mobile: Optional[bool] = None + + +class HealthResponse(BaseModel): + """Health check response.""" + + status: str = "ok" + + +class ErrorResponse(BaseModel): + """Error response body.""" + + error: str + error_type: str diff --git a/tiktok_api/proxy_manager.py b/tiktok_scrapper/proxy_manager.py similarity index 100% rename from tiktok_api/proxy_manager.py rename to tiktok_scrapper/proxy_manager.py diff --git a/tiktok_scrapper/pyproject.toml b/tiktok_scrapper/pyproject.toml new file mode 100644 index 0000000..f567516 --- /dev/null +++ b/tiktok_scrapper/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "tiktok-scrapper" +version = "0.1.0" +description = "TikTok video/music/slideshow scraper REST API" +requires-python = "==3.13.*" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn>=0.34.0", + "yt-dlp==2026.02.04", + # curl_cffi version must be compatible with yt-dlp's BROWSER_TARGETS + "curl_cffi>=0.10.0,<0.15.0", + "aiohttp>=3.9.0", + "python-dotenv>=1.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tiktok_scrapper/uv.lock b/tiktok_scrapper/uv.lock new file mode 100644 index 0000000..6dd353c --- /dev/null +++ b/tiktok_scrapper/uv.lock @@ -0,0 +1,518 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[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 = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[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 = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[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 = "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 = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { 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 = "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 = "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 = "tiktok-scrapper" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "curl-cffi" }, + { name = "fastapi" }, + { name = "python-dotenv" }, + { name = "uvicorn" }, + { name = "yt-dlp" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.9.0" }, + { name = "curl-cffi", specifier = ">=0.10.0,<0.15.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "uvicorn", specifier = ">=0.34.0" }, + { name = "yt-dlp", specifier = "==2026.2.4" }, +] + +[[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 = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] + +[[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" }, +] diff --git a/uv.lock b/uv.lock index a6f6848..19592fc 100644 --- a/uv.lock +++ b/uv.lock @@ -83,6 +83,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[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" @@ -92,6 +101,18 @@ 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 = "apscheduler" version = "3.11.2" @@ -161,6 +182,27 @@ wheels = [ { 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 = "contourpy" version = "1.3.3" @@ -224,6 +266,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[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 = "fonttools" version = "4.61.1" @@ -298,6 +356,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, ] +[[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 = "idna" version = "3.11" @@ -700,6 +767,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, ] +[[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 = "tiktok-scrapper" +version = "0.1.0" +source = { editable = "tiktok_scrapper" } +dependencies = [ + { name = "aiohttp" }, + { name = "curl-cffi" }, + { name = "fastapi" }, + { name = "python-dotenv" }, + { name = "uvicorn" }, + { name = "yt-dlp" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.9.0" }, + { name = "curl-cffi", specifier = ">=0.10.0,<0.15.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "uvicorn", specifier = ">=0.34.0" }, + { name = "yt-dlp", specifier = "==2026.2.4" }, +] + [[package]] name = "tt-bot" version = "4.5.0" @@ -715,10 +817,9 @@ dependencies = [ [package.optional-dependencies] main = [ { name = "apscheduler" }, - { name = "curl-cffi" }, { name = "pillow" }, { name = "pillow-heif" }, - { name = "yt-dlp" }, + { name = "tiktok-scrapper" }, ] stats = [ { name = "apscheduler" }, @@ -733,14 +834,13 @@ 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" }, + { name = "tiktok-scrapper", marker = "extra == 'main'", editable = "tiktok_scrapper" }, ] provides-extras = ["stats", "main"] @@ -786,6 +886,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] +[[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 = "yarl" version = "1.22.0" From a1f928304ac35b05e75e209ea4e6604d391f2133 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Thu, 12 Mar 2026 22:50:12 -0700 Subject: [PATCH 05/10] refactor(tiktok): extract API client into standalone library package Extract core TikTok client functionality into a new tiktok_api library package. The tiktok_scrapper service now uses tiktok_api as a dependency, separating library code from REST API implementation. - Move client, models, exceptions, proxy_manager to tiktok_api/ - Remove Pydantic response models from core library (API-specific) - Restructure tiktok_scrapper/ as standalone FastAPI service - Update package imports and module structure --- tiktok_api/__init__.py | 37 +- {tiktok_scrapper => tiktok_api}/client.py | 97 +-- {tiktok_scrapper => tiktok_api}/exceptions.py | 0 {tiktok_scrapper => tiktok_api}/models.py | 92 +-- .../proxy_manager.py | 0 tiktok_scrapper/Dockerfile | 6 +- tiktok_scrapper/README.md | 81 +++ tiktok_scrapper/__init__.py | 45 -- tiktok_scrapper/app.py | 269 -------- tiktok_scrapper/config.py | 67 -- tiktok_scrapper/pyproject.toml | 8 +- tiktok_scrapper/tiktok_scrapper/__init__.py | 29 + tiktok_scrapper/tiktok_scrapper/app.py | 91 +++ tiktok_scrapper/tiktok_scrapper/client.py | 575 ++++++++++++++++++ tiktok_scrapper/tiktok_scrapper/config.py | 43 ++ .../tiktok_scrapper/dependencies.py | 12 + tiktok_scrapper/tiktok_scrapper/exceptions.py | 37 ++ tiktok_scrapper/tiktok_scrapper/models.py | 73 +++ .../tiktok_scrapper/proxy_manager.py | 175 ++++++ .../tiktok_scrapper/routes/__init__.py | 12 + .../tiktok_scrapper/routes/health.py | 15 + .../tiktok_scrapper/routes/music.py | 44 ++ .../tiktok_scrapper/routes/video.py | 108 ++++ tiktok_scrapper/uv.lock | 277 ++------- uv.lock | 103 ++-- 25 files changed, 1420 insertions(+), 876 deletions(-) rename {tiktok_scrapper => tiktok_api}/client.py (95%) rename {tiktok_scrapper => tiktok_api}/exceptions.py (100%) rename {tiktok_scrapper => tiktok_api}/models.py (70%) rename {tiktok_scrapper => tiktok_api}/proxy_manager.py (100%) create mode 100644 tiktok_scrapper/README.md delete mode 100644 tiktok_scrapper/__init__.py delete mode 100644 tiktok_scrapper/app.py delete mode 100644 tiktok_scrapper/config.py create mode 100644 tiktok_scrapper/tiktok_scrapper/__init__.py create mode 100644 tiktok_scrapper/tiktok_scrapper/app.py create mode 100644 tiktok_scrapper/tiktok_scrapper/client.py create mode 100644 tiktok_scrapper/tiktok_scrapper/config.py create mode 100644 tiktok_scrapper/tiktok_scrapper/dependencies.py create mode 100644 tiktok_scrapper/tiktok_scrapper/exceptions.py create mode 100644 tiktok_scrapper/tiktok_scrapper/models.py create mode 100644 tiktok_scrapper/tiktok_scrapper/proxy_manager.py create mode 100644 tiktok_scrapper/tiktok_scrapper/routes/__init__.py create mode 100644 tiktok_scrapper/tiktok_scrapper/routes/health.py create mode 100644 tiktok_scrapper/tiktok_scrapper/routes/music.py create mode 100644 tiktok_scrapper/tiktok_scrapper/routes/video.py diff --git a/tiktok_api/__init__.py b/tiktok_api/__init__.py index ac23664..91665a2 100644 --- a/tiktok_api/__init__.py +++ b/tiktok_api/__init__.py @@ -1,9 +1,26 @@ -"""Compatibility shim - re-exports from tiktok_scrapper package.""" +"""TikTok API client for extracting video and music information. -from tiktok_scrapper import ( - MusicInfo, - ProxyManager, - TikTokClient, +This module provides a clean interface to extract TikTok video/slideshow data +and music information using yt-dlp internally. + +Example: + >>> from tiktok_api import TikTokClient, ProxyManager, VideoInfo, TikTokDeletedError + >>> + >>> # Initialize proxy manager (optional) + >>> proxy_manager = ProxyManager.initialize("proxies.txt", include_host=True) + >>> + >>> client = TikTokClient(proxy_manager=proxy_manager, data_only_proxy=True) + >>> try: + ... video_info = await client.video("https://www.tiktok.com/@user/video/123") + ... print(video_info.id) + ... if video_info.is_video: + ... print(f"Duration: {video_info.duration}s") + ... except TikTokDeletedError: + ... print("Video was deleted") +""" + +from .client import TikTokClient, ttapi +from .exceptions import ( TikTokDeletedError, TikTokError, TikTokExtractionError, @@ -13,16 +30,20 @@ TikTokRateLimitError, TikTokRegionError, TikTokVideoTooLongError, - VideoInfo, - ttapi, ) +from .models import MusicInfo, VideoInfo +from .proxy_manager import ProxyManager __all__ = [ + # Client "TikTokClient", - "ttapi", + "ttapi", # Backwards compatibility alias + # Proxy "ProxyManager", + # Models "VideoInfo", "MusicInfo", + # Exceptions "TikTokError", "TikTokDeletedError", "TikTokInvalidLinkError", diff --git a/tiktok_scrapper/client.py b/tiktok_api/client.py similarity index 95% rename from tiktok_scrapper/client.py rename to tiktok_api/client.py index 4242c12..7085b85 100644 --- a/tiktok_scrapper/client.py +++ b/tiktok_api/client.py @@ -58,7 +58,7 @@ TikTokVideoTooLongError, ) from .models import MusicInfo, VideoInfo -from .config import config +from data.config import config if TYPE_CHECKING: from .proxy_manager import ProxyManager @@ -1967,101 +1967,6 @@ async def update_status(attempt: int): f"Failed to extract music after {max_attempts} attempts" ) - async def extract_video_info(self, video_link: str) -> dict[str, Any]: - """Extract video/slideshow metadata without downloading media. - - Performs Parts 1 & 2 only (URL resolution + info extraction). - Returns the raw TikTok API data dict for the caller to process. - - Args: - video_link: TikTok video or slideshow URL - - Returns: - Dict with keys: - - video_data: Raw TikTok API response dict - - video_id: Extracted video ID string - - resolved_url: Full resolved URL - - Raises: - Same exceptions as video() - """ - proxy_session = ProxySession(self.proxy_manager) - download_context: Optional[dict[str, Any]] = None - - try: - # Part 1: URL Resolution - full_url = await self._resolve_url(video_link, proxy_session) - video_id = self._extract_video_id(full_url) - - if not video_id: - raise TikTokInvalidLinkError("Invalid or expired TikTok link") - - # Part 2: Video Info Extraction - 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 TikTokError: - raise - except asyncio.CancelledError: - raise - except aiohttp.ClientError as e: - raise TikTokNetworkError(f"Network error: {e}") from e - except Exception as e: - raise TikTokExtractionError(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]: - """Extract music metadata without downloading audio. - - Performs Part 2 only (info extraction). - Returns the raw music data dict. - - Args: - video_id: TikTok video ID - - Returns: - Dict with the raw music info from TikTok API. - - Raises: - Same exceptions as music() - """ - proxy_session = ProxySession(self.proxy_manager) - download_context: Optional[dict[str, Any]] = 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 TikTokExtractionError(f"No music info found for video {video_id}") - - return { - "video_data": video_data, - "music_data": music_info, - "video_id": video_id, - } - - except TikTokError: - raise - except aiohttp.ClientError as e: - raise TikTokNetworkError(f"Network error: {e}") from e - except Exception as e: - raise TikTokExtractionError(f"Failed to extract music info: {e}") from e - finally: - self._close_download_context(download_context) - # Backwards compatibility alias ttapi = TikTokClient diff --git a/tiktok_scrapper/exceptions.py b/tiktok_api/exceptions.py similarity index 100% rename from tiktok_scrapper/exceptions.py rename to tiktok_api/exceptions.py diff --git a/tiktok_scrapper/models.py b/tiktok_api/models.py similarity index 70% rename from tiktok_scrapper/models.py rename to tiktok_api/models.py index cc13686..9aa73ef 100644 --- a/tiktok_scrapper/models.py +++ b/tiktok_api/models.py @@ -1,8 +1,4 @@ -"""Data models for TikTok scrapper. - -Internal dataclasses (VideoInfo, MusicInfo) are used by the client library. -Pydantic models (*Response) are used by the REST API for JSON serialization. -""" +"""Data models for TikTok API responses.""" from __future__ import annotations @@ -10,19 +6,12 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, List, Optional, Union -from pydantic import BaseModel - if TYPE_CHECKING: from .client import ProxySession logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# Internal dataclasses (used by TikTokClient) -# --------------------------------------------------------------------------- - - @dataclass class VideoInfo: """Information about a TikTok video or slideshow. @@ -156,82 +145,3 @@ class MusicInfo: author: str duration: int cover: str - - -# --------------------------------------------------------------------------- -# Pydantic API response models (used by FastAPI endpoints) -# --------------------------------------------------------------------------- - - -class MusicResponse(BaseModel): - """Music metadata returned by the API.""" - - url: str - title: str - author: str - duration: int - cover: str - - -class VideoResponse(BaseModel): - """Filtered video/slideshow response.""" - - type: str # "video" or "images" - id: int - video_url: Optional[str] = None - image_urls: list[str] = [] - cover: Optional[str] = None - width: Optional[int] = None - height: Optional[int] = None - duration: Optional[int] = None - likes: Optional[int] = None - views: Optional[int] = None - link: str - music: Optional[MusicResponse] = None - - -class RawVideoResponse(BaseModel): - """Raw TikTok API response (full yt-dlp extraction data).""" - - id: int - resolved_url: str - data: dict[str, Any] - - -class MusicDetailResponse(BaseModel): - """Filtered music response for the /music endpoint.""" - - id: int - title: str - author: str - duration: int - cover: str - url: str - - -class RawMusicResponse(BaseModel): - """Raw music data from TikTok API.""" - - id: int - data: dict[str, Any] - - -class CheckResponse(BaseModel): - """URL validation response.""" - - valid: bool - url: Optional[str] = None - is_mobile: Optional[bool] = None - - -class HealthResponse(BaseModel): - """Health check response.""" - - status: str = "ok" - - -class ErrorResponse(BaseModel): - """Error response body.""" - - error: str - error_type: str diff --git a/tiktok_scrapper/proxy_manager.py b/tiktok_api/proxy_manager.py similarity index 100% rename from tiktok_scrapper/proxy_manager.py rename to tiktok_api/proxy_manager.py diff --git a/tiktok_scrapper/Dockerfile b/tiktok_scrapper/Dockerfile index bb88d46..a58c266 100644 --- a/tiktok_scrapper/Dockerfile +++ b/tiktok_scrapper/Dockerfile @@ -2,14 +2,14 @@ FROM python:3.13-slim COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv -WORKDIR /app/tiktok_scrapper +WORKDIR /app # Install dependencies first (cache layer) -COPY pyproject.toml ./ +COPY pyproject.toml uv.lock* ./ RUN uv sync --frozen --no-dev 2>/dev/null || uv sync --no-dev # Copy application code -COPY *.py ./ +COPY tiktok_scrapper/ tiktok_scrapper/ EXPOSE 8000 diff --git a/tiktok_scrapper/README.md b/tiktok_scrapper/README.md new file mode 100644 index 0000000..ec5c98c --- /dev/null +++ b/tiktok_scrapper/README.md @@ -0,0 +1,81 @@ +# TikTok Scrapper API + +Standalone FastAPI server for extracting TikTok video, slideshow, and music metadata. + +## Running with uv + +```bash +cd tiktok_scrapper + +# Install dependencies +uv sync + +# Start the server +uv run uvicorn tiktok_scrapper.app:app --host 0.0.0.0 --port 8000 + +# With auto-reload for development +uv run uvicorn tiktok_scrapper.app:app --reload +``` + +## Running with Docker + +```bash +cd tiktok_scrapper + +# Build +docker build -t tiktok-scrapper . + +# Run +docker run -p 8000:8000 tiktok-scrapper + +# 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 \ + tiktok-scrapper +``` + +## API Endpoints + +### `GET /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 /music` + +Extract music metadata from a TikTok video. + +| Parameter | Type | Description | +|------------|------|------------------------| +| `video_id` | int | TikTok video ID | +| `raw` | bool | Return raw data (default: false) | + +### `GET /health` + +Health check. Returns `{"status": "ok"}`. + +### `GET /docs` + +Interactive OpenAPI documentation (Swagger UI). + +## Environment Variables + +| Variable | Default | Description | +|-------------------------------|---------|------------------------------------------| +| `URL_RESOLVE_MAX_RETRIES` | `3` | Max retries for short URL resolution | +| `VIDEO_INFO_MAX_RETRIES` | `3` | Max retries for video info extraction | +| `PROXY_FILE` | `""` | Path to proxy file (one URL per line) | +| `PROXY_DATA_ONLY` | `false` | Use proxy only for API extraction | +| `PROXY_INCLUDE_HOST` | `false` | Include direct connection in proxy rotation | +| `MAX_VIDEO_DURATION` | `0` | Max video duration in seconds (0 = no limit) | +| `STREAMING_DURATION_THRESHOLD`| `300` | Duration threshold for streaming downloads | +| `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) | +| `HOST` | `0.0.0.0` | Server bind address | +| `PORT` | `8000` | Server port | +| `YTDLP_COOKIES` | `""` | Path to Netscape-format cookies file | diff --git a/tiktok_scrapper/__init__.py b/tiktok_scrapper/__init__.py deleted file mode 100644 index 4a2e1ff..0000000 --- a/tiktok_scrapper/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -"""TikTok scrapper - standalone TikTok video/music/slideshow extraction. - -Example: - >>> from tiktok_scrapper import TikTokClient, ProxyManager, VideoInfo - >>> - >>> proxy_manager = ProxyManager.initialize("proxies.txt", include_host=True) - >>> client = TikTokClient(proxy_manager=proxy_manager, data_only_proxy=True) - >>> video_info = await client.video("https://www.tiktok.com/@user/video/123") -""" - -from .client import TikTokClient, ttapi -from .exceptions import ( - TikTokDeletedError, - TikTokError, - TikTokExtractionError, - TikTokInvalidLinkError, - TikTokNetworkError, - TikTokPrivateError, - TikTokRateLimitError, - TikTokRegionError, - TikTokVideoTooLongError, -) -from .models import MusicInfo, VideoInfo -from .proxy_manager import ProxyManager - -__all__ = [ - # Client - "TikTokClient", - "ttapi", - # Proxy - "ProxyManager", - # Models - "VideoInfo", - "MusicInfo", - # Exceptions - "TikTokError", - "TikTokDeletedError", - "TikTokInvalidLinkError", - "TikTokPrivateError", - "TikTokNetworkError", - "TikTokRateLimitError", - "TikTokRegionError", - "TikTokExtractionError", - "TikTokVideoTooLongError", -] diff --git a/tiktok_scrapper/app.py b/tiktok_scrapper/app.py deleted file mode 100644 index d36c2ac..0000000 --- a/tiktok_scrapper/app.py +++ /dev/null @@ -1,269 +0,0 @@ -"""FastAPI REST API server for TikTok scrapping.""" - -from __future__ import annotations - -import logging -from contextlib import asynccontextmanager -from typing import Any - -from fastapi import FastAPI, Query -from fastapi.responses import JSONResponse - -from .client import TikTokClient -from .config import config -from .exceptions import ( - TikTokDeletedError, - TikTokError, - TikTokExtractionError, - TikTokInvalidLinkError, - TikTokNetworkError, - TikTokPrivateError, - TikTokRateLimitError, - TikTokRegionError, - TikTokVideoTooLongError, -) -from .models import ( - CheckResponse, - ErrorResponse, - HealthResponse, - MusicDetailResponse, - MusicResponse, - RawMusicResponse, - RawVideoResponse, - VideoResponse, -) -from .proxy_manager import ProxyManager - -logger = logging.getLogger(__name__) - -# Map TikTok exceptions to HTTP status codes -_ERROR_STATUS_MAP: dict[type[TikTokError], int] = { - TikTokDeletedError: 404, - TikTokPrivateError: 403, - TikTokInvalidLinkError: 400, - TikTokVideoTooLongError: 413, - TikTokRateLimitError: 429, - TikTokNetworkError: 502, - TikTokRegionError: 451, - TikTokExtractionError: 500, -} - -_client: TikTokClient | None = None - - -@asynccontextmanager -async def lifespan(app: FastAPI): - global _client - - # Configure logging - log_level = config.get("logging", {}).get("log_level", logging.INFO) - logging.basicConfig(level=log_level, format="%(asctime)s %(name)s %(levelname)s %(message)s") - - # Initialize proxy manager - proxy_config = config.get("proxy", {}) - proxy_file = proxy_config.get("proxy_file", "") - proxy_manager = None - if proxy_file: - proxy_manager = ProxyManager.initialize( - proxy_file, - include_host=proxy_config.get("include_host", False), - ) - - # Initialize client - _client = TikTokClient( - proxy_manager=proxy_manager, - data_only_proxy=proxy_config.get("data_only", False), - ) - - logger.info("TikTok scrapper API started") - yield - - # Cleanup - await TikTokClient.close_connector() - await TikTokClient.close_curl_session() - TikTokClient.shutdown_executor() - logger.info("TikTok scrapper API stopped") - - -app = FastAPI( - title="TikTok Scrapper API", - version="0.1.0", - lifespan=lifespan, -) - - -@app.exception_handler(TikTokError) -async def tiktok_error_handler(request, exc: TikTokError): - 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(), - ) - - -def _build_filtered_video_response( - video_data: dict[str, Any], - video_id: int, - link: str, -) -> VideoResponse: - """Build a filtered VideoResponse from raw TikTok API data.""" - image_post = video_data.get("imagePost") - video_info = video_data.get("video", {}) - stats = video_data.get("stats", {}) - - # Extract music info if available - 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: - # Slideshow - images = image_post.get("images", []) - image_urls = [] - for img in images: - url_list = img.get("imageURL", {}).get("urlList", []) - if url_list: - image_urls.append(url_list[0]) - - return VideoResponse( - type="images", - id=video_id, - image_urls=image_urls, - likes=stats.get("diggCount"), - views=stats.get("playCount"), - link=link, - music=music, - ) - - # Video - video_url = ( - video_info.get("playAddr") - or video_info.get("downloadAddr") - ) - if not video_url: - # Try bitrateInfo - for br in video_info.get("bitrateInfo", []): - url_list = br.get("PlayAddr", {}).get("UrlList", []) - if url_list: - video_url = url_list[0] - break - - duration = video_info.get("duration") - if duration: - duration = int(duration) - - width = video_info.get("width") - 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(width) if width else None, - height=int(height) if height else None, - duration=duration, - likes=stats.get("diggCount"), - views=stats.get("playCount"), - link=link, - music=music, - ) - - -@app.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. - - With raw=false (default): returns filtered metadata with CDN URLs. - With raw=true: returns the full TikTok API response dict. - """ - 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_filtered_video_response(video_data, video_id, url) - - -@app.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. - - With raw=false (default): returns filtered music metadata with CDN URL. - With raw=true: returns the full music data from TikTok API. - """ - result = await _client.extract_music_info(video_id) - music_data = result["music_data"] - - if raw: - return RawMusicResponse( - id=video_id, - data=result["video_data"], - ) - - music_url = music_data.get("playUrl", "") - 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_url, - ) - - -@app.get("/check", response_model=CheckResponse) -async def check_url( - url: str = Query(..., description="URL to validate"), -): - """Quick regex validation of a TikTok URL (no network calls).""" - matched_url, is_mobile = await _client.regex_check(url) - return CheckResponse( - valid=matched_url is not None, - url=matched_url, - is_mobile=is_mobile, - ) - - -@app.get("/health", response_model=HealthResponse) -async def health(): - """Health check endpoint.""" - return HealthResponse() diff --git a/tiktok_scrapper/config.py b/tiktok_scrapper/config.py deleted file mode 100644 index 8ca6cff..0000000 --- a/tiktok_scrapper/config.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Standalone configuration for TikTok scrapper. - -Reads all settings from environment variables. No dependency on the bot's config. -""" - -from __future__ import annotations - -import logging -import os - -from dotenv import load_dotenv, find_dotenv - -load_dotenv(find_dotenv()) - - -def _parse_int_env(key: str, default: int) -> int: - value = os.getenv(key, "") - if value.strip(): - try: - return int(value) - except ValueError: - return default - return default - - -def _parse_bool_env(key: str, default: bool = False) -> bool: - value = os.getenv(key, "") - if value.strip(): - return value.strip().lower() == "true" - return default - - -def _parse_log_level(key: str, default: str = "INFO") -> int: - value = os.getenv(key, default).upper().strip() - level_map = { - "DEBUG": logging.DEBUG, - "INFO": logging.INFO, - "WARNING": logging.WARNING, - "ERROR": logging.ERROR, - "CRITICAL": logging.CRITICAL, - } - return level_map.get(value, logging.INFO) - - -config = { - "retry": { - "url_resolve_max_retries": _parse_int_env("URL_RESOLVE_MAX_RETRIES", 3), - "video_info_max_retries": _parse_int_env("VIDEO_INFO_MAX_RETRIES", 3), - "download_max_retries": _parse_int_env("DOWNLOAD_MAX_RETRIES", 3), - }, - "proxy": { - "proxy_file": os.getenv("PROXY_FILE", ""), - "data_only": _parse_bool_env("PROXY_DATA_ONLY"), - "include_host": _parse_bool_env("PROXY_INCLUDE_HOST"), - }, - "performance": { - "streaming_duration_threshold": _parse_int_env("STREAMING_DURATION_THRESHOLD", 300), - "max_video_duration": _parse_int_env("MAX_VIDEO_DURATION", 0), - }, - "logging": { - "log_level": _parse_log_level("LOG_LEVEL", "INFO"), - }, - "server": { - "host": os.getenv("HOST", "0.0.0.0"), - "port": _parse_int_env("PORT", 8000), - }, -} diff --git a/tiktok_scrapper/pyproject.toml b/tiktok_scrapper/pyproject.toml index f567516..9b69f8f 100644 --- a/tiktok_scrapper/pyproject.toml +++ b/tiktok_scrapper/pyproject.toml @@ -1,15 +1,15 @@ [project] name = "tiktok-scrapper" version = "0.1.0" -description = "TikTok video/music/slideshow scraper REST API" +description = "TikTok video/music/slideshow metadata extraction REST API" requires-python = "==3.13.*" dependencies = [ "fastapi>=0.115.0", "uvicorn>=0.34.0", "yt-dlp==2026.02.04", - # curl_cffi version must be compatible with yt-dlp's BROWSER_TARGETS - "curl_cffi>=0.10.0,<0.15.0", - "aiohttp>=3.9.0", + "httpx>=0.28.0", + "curl-cffi>=0.7.0", + "pydantic-settings>=2.0.0", "python-dotenv>=1.0.0", ] diff --git a/tiktok_scrapper/tiktok_scrapper/__init__.py b/tiktok_scrapper/tiktok_scrapper/__init__.py new file mode 100644 index 0000000..90e2ff8 --- /dev/null +++ b/tiktok_scrapper/tiktok_scrapper/__init__.py @@ -0,0 +1,29 @@ +"""TikTok scrapper - standalone REST API for TikTok metadata extraction.""" + +from .client import TikTokClient +from .exceptions import ( + TikTokDeletedError, + TikTokError, + TikTokExtractionError, + TikTokInvalidLinkError, + TikTokNetworkError, + TikTokPrivateError, + TikTokRateLimitError, + TikTokRegionError, + TikTokVideoTooLongError, +) +from .proxy_manager import ProxyManager + +__all__ = [ + "TikTokClient", + "ProxyManager", + "TikTokError", + "TikTokDeletedError", + "TikTokInvalidLinkError", + "TikTokPrivateError", + "TikTokNetworkError", + "TikTokRateLimitError", + "TikTokRegionError", + "TikTokExtractionError", + "TikTokVideoTooLongError", +] diff --git a/tiktok_scrapper/tiktok_scrapper/app.py b/tiktok_scrapper/tiktok_scrapper/app.py new file mode 100644 index 0000000..1558030 --- /dev/null +++ b/tiktok_scrapper/tiktok_scrapper/app.py @@ -0,0 +1,91 @@ +"""FastAPI REST API server for TikTok scrapping.""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.responses import JSONResponse + +from .client import TikTokClient +from .config import settings +from .exceptions import ( + TikTokDeletedError, + TikTokError, + TikTokExtractionError, + TikTokInvalidLinkError, + TikTokNetworkError, + TikTokPrivateError, + TikTokRateLimitError, + TikTokRegionError, + TikTokVideoTooLongError, +) +from .models import ErrorResponse +from .proxy_manager import ProxyManager +from .routes import router + +logger = logging.getLogger(__name__) + +_ERROR_STATUS_MAP: dict[type[TikTokError], int] = { + TikTokDeletedError: 404, + TikTokPrivateError: 403, + TikTokInvalidLinkError: 400, + TikTokVideoTooLongError: 413, + TikTokRateLimitError: 429, + TikTokNetworkError: 502, + TikTokRegionError: 451, + TikTokExtractionError: 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 + ) + + app.state.client = TikTokClient( + proxy_manager=proxy_manager, + data_only_proxy=settings.proxy_data_only, + ) + + logger.info("TikTok scrapper API started") + yield + + await TikTokClient.close_http_client() + TikTokClient.shutdown_executor() + logger.info("TikTok scrapper API stopped") + + +app = FastAPI( + title="TikTok Scrapper API", + version="0.1.0", + lifespan=lifespan, +) + + +@app.exception_handler(TikTokError) +async def tiktok_error_handler(request, exc: TikTokError): + 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/tiktok_scrapper/tiktok_scrapper/client.py b/tiktok_scrapper/tiktok_scrapper/client.py new file mode 100644 index 0000000..c806187 --- /dev/null +++ b/tiktok_scrapper/tiktok_scrapper/client.py @@ -0,0 +1,575 @@ +"""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 settings +from .exceptions import ( + TikTokDeletedError, + TikTokError, + TikTokExtractionError, + TikTokInvalidLinkError, + TikTokNetworkError, + TikTokPrivateError, + TikTokRateLimitError, + TikTokRegionError, +) + +# 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: + """Strip authentication info from proxy URL for safe logging.""" + 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: + """Get the current proxy (lazily initialized on first call).""" + 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: + """Rotate to the next proxy in the rotation (for retries).""" + 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. + + This client uses yt-dlp internally to extract video/slideshow data and music + from TikTok URLs. It only extracts metadata — no media downloads. + + Args: + proxy_manager: Optional ProxyManager instance for round-robin proxy rotation. + data_only_proxy: If True, proxy is used only for API extraction. + cookies: Optional path to a Netscape-format cookies file. + """ + + _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: + """Get or create the shared 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: + """Get or create shared httpx client for URL resolution.""" + 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: + """Check if browser impersonation is available (cached).""" + 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 = len(targets) > 0 + 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: + """Close shared httpx client. Call on application shutdown.""" + 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: + """Shutdown the shared executor. Call on application shutdown.""" + 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, + data_only_proxy: bool = False, + cookies: str | None = None, + ): + self.proxy_manager = proxy_manager + self.data_only_proxy = data_only_proxy + + 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: + """Resolve short URLs to full URLs with retry and proxy rotation.""" + if max_retries is None: + max_retries = 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 TikTokInvalidLinkError("Invalid or expired TikTok link") + + def _extract_video_id(self, url: str) -> str | None: + """Extract video ID from TikTok URL.""" + 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]: + """Get base yt-dlp options.""" + 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]: + """Extract TikTok data synchronously via yt-dlp. + + Returns: + Tuple of (video_data, status, download_context) + """ + 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 TikTokExtractionError( + "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 TikTokExtractionError( + "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 TikTokError: + 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: + """Run synchronous function in executor.""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(self._get_executor(), func, *args) + + def _close_download_context( + self, download_context: dict[str, Any] | None + ) -> None: + """Close the YoutubeDL instance in a download context if present.""" + if download_context and "ydl" in download_context: + try: + download_context["ydl"].close() + except Exception: + pass + + _STATUS_EXCEPTIONS: dict[str, type[TikTokError]] = { + "deleted": TikTokDeletedError, + "private": TikTokPrivateError, + "rate_limit": TikTokRateLimitError, + "network": TikTokNetworkError, + "region": TikTokRegionError, + } + + _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: + """Raise appropriate exception based on status string.""" + exc_cls = self._STATUS_EXCEPTIONS.get(status) + if exc_cls: + message = self._STATUS_MESSAGES[status].format(link=video_link) + raise exc_cls(message) + raise TikTokExtractionError(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]]: + """Extract video info with retry and proxy rotation. + + Returns: + Tuple of (video_data, download_context) + """ + if max_retries is None: + max_retries = 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 TikTokExtractionError(f"Extraction failed with status: {status}") + + if video_data is None: + raise TikTokExtractionError("No video data returned") + + if download_context is None: + raise TikTokExtractionError("No download context returned") + + return video_data, download_context + + except (TikTokDeletedError, TikTokPrivateError, TikTokRegionError): + 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 TikTokExtractionError( + f"Failed to extract video info after {max_retries} attempts" + ) + + async def extract_video_info(self, video_link: str) -> dict[str, Any]: + """Extract video/slideshow metadata without downloading media. + + Returns: + Dict with keys: video_data, video_id, resolved_url + """ + 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 TikTokInvalidLinkError("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 (TikTokError, asyncio.CancelledError): + raise + except httpx.HTTPError as e: + raise TikTokNetworkError(f"Network error: {e}") from e + except Exception as e: + raise TikTokExtractionError(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]: + """Extract music metadata without downloading audio. + + Returns: + Dict with keys: video_data, music_data, video_id + """ + 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 TikTokExtractionError(f"No music info found for video {video_id}") + + return { + "video_data": video_data, + "music_data": music_info, + "video_id": video_id, + } + + except (TikTokError, asyncio.CancelledError): + raise + except httpx.HTTPError as e: + raise TikTokNetworkError(f"Network error: {e}") from e + except Exception as e: + raise TikTokExtractionError(f"Failed to extract music info: {e}") from e + finally: + self._close_download_context(download_context) diff --git a/tiktok_scrapper/tiktok_scrapper/config.py b/tiktok_scrapper/tiktok_scrapper/config.py new file mode 100644 index 0000000..58b31a4 --- /dev/null +++ b/tiktok_scrapper/tiktok_scrapper/config.py @@ -0,0 +1,43 @@ +"""Configuration for TikTok scrapper 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", + ) + + # Retry + url_resolve_max_retries: int = 3 + video_info_max_retries: int = 3 + + # Proxy + proxy_file: str = "" + proxy_data_only: bool = False + proxy_include_host: bool = False + + # Performance + max_video_duration: int = 0 + streaming_duration_threshold: int = 300 + + # Logging + log_level: str = "INFO" + + # Server + host: str = "0.0.0.0" + port: int = 8000 + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/tiktok_scrapper/tiktok_scrapper/dependencies.py b/tiktok_scrapper/tiktok_scrapper/dependencies.py new file mode 100644 index 0000000..76f1921 --- /dev/null +++ b/tiktok_scrapper/tiktok_scrapper/dependencies.py @@ -0,0 +1,12 @@ +"""FastAPI dependencies for TikTok scrapper API.""" + +from __future__ import annotations + +from fastapi import Request + +from .client import TikTokClient + + +def get_client(request: Request) -> TikTokClient: + """Get the TikTokClient instance from application state.""" + return request.app.state.client diff --git a/tiktok_scrapper/tiktok_scrapper/exceptions.py b/tiktok_scrapper/tiktok_scrapper/exceptions.py new file mode 100644 index 0000000..06b00e5 --- /dev/null +++ b/tiktok_scrapper/tiktok_scrapper/exceptions.py @@ -0,0 +1,37 @@ +"""TikTok API exception classes.""" + + +class TikTokError(Exception): + """Base exception for TikTok API errors.""" + + +class TikTokDeletedError(TikTokError): + """Video has been deleted by the creator.""" + + +class TikTokPrivateError(TikTokError): + """Video is private and cannot be accessed.""" + + +class TikTokNetworkError(TikTokError): + """Network error occurred during request.""" + + +class TikTokRateLimitError(TikTokError): + """Too many requests - rate limited.""" + + +class TikTokRegionError(TikTokError): + """Video is not available in the user's region (geo-blocked).""" + + +class TikTokExtractionError(TikTokError): + """Generic extraction/parsing error (invalid ID, unknown failure, etc.).""" + + +class TikTokVideoTooLongError(TikTokError): + """Video exceeds the maximum allowed duration.""" + + +class TikTokInvalidLinkError(TikTokError): + """TikTok link is invalid or expired (failed URL resolution).""" diff --git a/tiktok_scrapper/tiktok_scrapper/models.py b/tiktok_scrapper/tiktok_scrapper/models.py new file mode 100644 index 0000000..284c42f --- /dev/null +++ b/tiktok_scrapper/tiktok_scrapper/models.py @@ -0,0 +1,73 @@ +"""Pydantic API response models for TikTok scrapper REST API.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +class MusicResponse(BaseModel): + """Music metadata returned as part of a video response.""" + + url: str + title: str + author: str + duration: int + cover: str + + +class VideoResponse(BaseModel): + """Filtered video/slideshow response.""" + + 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): + """Raw TikTok API response (full yt-dlp extraction data).""" + + id: int + resolved_url: str + data: dict[str, Any] + + +class MusicDetailResponse(BaseModel): + """Filtered music response for the /music endpoint.""" + + id: int + title: str + author: str + duration: int + cover: str + url: str + + +class RawMusicResponse(BaseModel): + """Raw music data from TikTok API.""" + + id: int + data: dict[str, Any] + + +class HealthResponse(BaseModel): + """Health check response.""" + + status: str = "ok" + + +class ErrorResponse(BaseModel): + """Error response body.""" + + error: str + error_type: str diff --git a/tiktok_scrapper/tiktok_scrapper/proxy_manager.py b/tiktok_scrapper/tiktok_scrapper/proxy_manager.py new file mode 100644 index 0000000..ab2c9ae --- /dev/null +++ b/tiktok_scrapper/tiktok_scrapper/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/tiktok_scrapper/tiktok_scrapper/routes/__init__.py b/tiktok_scrapper/tiktok_scrapper/routes/__init__.py new file mode 100644 index 0000000..1fd57c5 --- /dev/null +++ b/tiktok_scrapper/tiktok_scrapper/routes/__init__.py @@ -0,0 +1,12 @@ +"""API route aggregation.""" + +from fastapi import APIRouter + +from .health import router as health_router +from .music import router as music_router +from .video import router as video_router + +router = APIRouter() +router.include_router(video_router) +router.include_router(music_router) +router.include_router(health_router) diff --git a/tiktok_scrapper/tiktok_scrapper/routes/health.py b/tiktok_scrapper/tiktok_scrapper/routes/health.py new file mode 100644 index 0000000..e3b1b2f --- /dev/null +++ b/tiktok_scrapper/tiktok_scrapper/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/tiktok_scrapper/tiktok_scrapper/routes/music.py b/tiktok_scrapper/tiktok_scrapper/routes/music.py new file mode 100644 index 0000000..fe8c20b --- /dev/null +++ b/tiktok_scrapper/tiktok_scrapper/routes/music.py @@ -0,0 +1,44 @@ +"""Music extraction endpoint.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from ..client import TikTokClient +from ..dependencies import get_client +from ..models import MusicDetailResponse, RawMusicResponse + +router = APIRouter() + + +@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"), + client: TikTokClient = Depends(get_client), +): + """Extract music info from a TikTok video.""" + result = await client.extract_music_info(video_id) + music_data = result["music_data"] + + if raw: + return RawMusicResponse( + id=video_id, + data=result["video_data"], + ) + + 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/tiktok_scrapper/tiktok_scrapper/routes/video.py b/tiktok_scrapper/tiktok_scrapper/routes/video.py new file mode 100644 index 0000000..006acb6 --- /dev/null +++ b/tiktok_scrapper/tiktok_scrapper/routes/video.py @@ -0,0 +1,108 @@ +"""Video/slideshow extraction endpoint.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query + +from ..client import TikTokClient +from ..dependencies import get_client +from ..models import MusicResponse, RawVideoResponse, VideoResponse + +router = APIRouter() + + +def _build_filtered_video_response( + video_data: dict[str, Any], + video_id: int, + link: str, +) -> VideoResponse: + """Build a filtered VideoResponse from raw TikTok API data.""" + 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, + ) + + +@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"), + client: TikTokClient = Depends(get_client), +): + """Extract video/slideshow info from a TikTok URL.""" + 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_filtered_video_response(video_data, video_id, url) diff --git a/tiktok_scrapper/uv.lock b/tiktok_scrapper/uv.lock index 6dd353c..c65c0b9 100644 --- a/tiktok_scrapper/uv.lock +++ b/tiktok_scrapper/uv.lock @@ -2,61 +2,6 @@ version = 1 revision = 3 requires-python = "==3.13.*" -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.13.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -87,15 +32,6 @@ 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 = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, -] - [[package]] name = "certifi" version = "2026.2.25" @@ -188,47 +124,6 @@ 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 = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -239,96 +134,40 @@ wheels = [ ] [[package]] -name = "idna" -version = "3.11" +name = "httpcore" +version = "1.0.9" 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" } +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/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { 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 = "multidict" -version = "6.7.1" +name = "httpx" +version = "0.28.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +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/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, + { 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 = "propcache" -version = "0.4.1" +name = "idna" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +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/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, - { 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" }, + { 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]] @@ -380,6 +219,20 @@ wheels = [ { 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" @@ -406,9 +259,10 @@ name = "tiktok-scrapper" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "aiohttp" }, { name = "curl-cffi" }, { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "uvicorn" }, { name = "yt-dlp" }, @@ -416,9 +270,10 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.9.0" }, - { name = "curl-cffi", specifier = ">=0.10.0,<0.15.0" }, + { name = "curl-cffi", specifier = ">=0.7.0" }, { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", specifier = ">=0.28.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "uvicorn", specifier = ">=0.34.0" }, { name = "yt-dlp", specifier = "==2026.2.4" }, @@ -458,56 +313,6 @@ 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 = "yarl" -version = "1.23.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, - { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, - { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, - { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, - { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, - { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, - { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, - { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, - { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, - { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, - { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, - { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, - { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, - { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, - { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, - { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, - { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, - { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, -] - [[package]] name = "yt-dlp" version = "2026.2.4" diff --git a/uv.lock b/uv.lock index 19592fc..82f29b5 100644 --- a/uv.lock +++ b/uv.lock @@ -159,29 +159,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 = "click" version = "8.3.1" @@ -236,27 +213,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" @@ -365,6 +321,34 @@ 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" @@ -649,15 +633,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" @@ -698,6 +673,20 @@ wheels = [ { 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 = "pyparsing" version = "3.3.1" @@ -784,9 +773,9 @@ name = "tiktok-scrapper" version = "0.1.0" source = { editable = "tiktok_scrapper" } dependencies = [ - { name = "aiohttp" }, - { name = "curl-cffi" }, { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "uvicorn" }, { name = "yt-dlp" }, @@ -794,9 +783,9 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.9.0" }, - { name = "curl-cffi", specifier = ">=0.10.0,<0.15.0" }, { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", specifier = ">=0.28.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "uvicorn", specifier = ">=0.34.0" }, { name = "yt-dlp", specifier = "==2026.2.4" }, From a43166dbb1ff47e8a1432d5e44efa6a59949afab Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Thu, 12 Mar 2026 23:10:15 -0700 Subject: [PATCH 06/10] refactor(tiktok): remove unused config options and update dependencies - Remove PROXY_DATA_ONLY, MAX_VIDEO_DURATION, STREAMING_DURATION_THRESHOLD, HOST, and PORT config options - Remove data_only_proxy parameter and feature from TikTokClient - Remove python-dotenv dependency - Update fastapi, uvicorn, yt-dlp, curl-cffi, and pydantic-settings to latest versions --- tiktok_scrapper/README.md | 5 ----- tiktok_scrapper/pyproject.toml | 13 ++++++------- tiktok_scrapper/tiktok_scrapper/app.py | 1 - tiktok_scrapper/tiktok_scrapper/client.py | 3 --- tiktok_scrapper/tiktok_scrapper/config.py | 9 --------- tiktok_scrapper/uv.lock | 2 -- 6 files changed, 6 insertions(+), 27 deletions(-) diff --git a/tiktok_scrapper/README.md b/tiktok_scrapper/README.md index ec5c98c..2973cea 100644 --- a/tiktok_scrapper/README.md +++ b/tiktok_scrapper/README.md @@ -71,11 +71,6 @@ Interactive OpenAPI documentation (Swagger UI). | `URL_RESOLVE_MAX_RETRIES` | `3` | Max retries for short URL resolution | | `VIDEO_INFO_MAX_RETRIES` | `3` | Max retries for video info extraction | | `PROXY_FILE` | `""` | Path to proxy file (one URL per line) | -| `PROXY_DATA_ONLY` | `false` | Use proxy only for API extraction | | `PROXY_INCLUDE_HOST` | `false` | Include direct connection in proxy rotation | -| `MAX_VIDEO_DURATION` | `0` | Max video duration in seconds (0 = no limit) | -| `STREAMING_DURATION_THRESHOLD`| `300` | Duration threshold for streaming downloads | | `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) | -| `HOST` | `0.0.0.0` | Server bind address | -| `PORT` | `8000` | Server port | | `YTDLP_COOKIES` | `""` | Path to Netscape-format cookies file | diff --git a/tiktok_scrapper/pyproject.toml b/tiktok_scrapper/pyproject.toml index 9b69f8f..41f861f 100644 --- a/tiktok_scrapper/pyproject.toml +++ b/tiktok_scrapper/pyproject.toml @@ -4,13 +4,12 @@ version = "0.1.0" description = "TikTok video/music/slideshow metadata extraction REST API" requires-python = "==3.13.*" dependencies = [ - "fastapi>=0.115.0", - "uvicorn>=0.34.0", - "yt-dlp==2026.02.04", - "httpx>=0.28.0", - "curl-cffi>=0.7.0", - "pydantic-settings>=2.0.0", - "python-dotenv>=1.0.0", + "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", ] [build-system] diff --git a/tiktok_scrapper/tiktok_scrapper/app.py b/tiktok_scrapper/tiktok_scrapper/app.py index 1558030..0062ccd 100644 --- a/tiktok_scrapper/tiktok_scrapper/app.py +++ b/tiktok_scrapper/tiktok_scrapper/app.py @@ -58,7 +58,6 @@ async def lifespan(app: FastAPI): app.state.client = TikTokClient( proxy_manager=proxy_manager, - data_only_proxy=settings.proxy_data_only, ) logger.info("TikTok scrapper API started") diff --git a/tiktok_scrapper/tiktok_scrapper/client.py b/tiktok_scrapper/tiktok_scrapper/client.py index c806187..e2994de 100644 --- a/tiktok_scrapper/tiktok_scrapper/client.py +++ b/tiktok_scrapper/tiktok_scrapper/client.py @@ -100,7 +100,6 @@ class TikTokClient: Args: proxy_manager: Optional ProxyManager instance for round-robin proxy rotation. - data_only_proxy: If True, proxy is used only for API extraction. cookies: Optional path to a Netscape-format cookies file. """ @@ -183,11 +182,9 @@ def shutdown_executor(cls) -> None: def __init__( self, proxy_manager: "ProxyManager | None" = None, - data_only_proxy: bool = False, cookies: str | None = None, ): self.proxy_manager = proxy_manager - self.data_only_proxy = data_only_proxy cookies_path = cookies or os.getenv("YTDLP_COOKIES") if cookies_path: diff --git a/tiktok_scrapper/tiktok_scrapper/config.py b/tiktok_scrapper/tiktok_scrapper/config.py index 58b31a4..03e012b 100644 --- a/tiktok_scrapper/tiktok_scrapper/config.py +++ b/tiktok_scrapper/tiktok_scrapper/config.py @@ -20,20 +20,11 @@ class Settings(BaseSettings): # Proxy proxy_file: str = "" - proxy_data_only: bool = False proxy_include_host: bool = False - # Performance - max_video_duration: int = 0 - streaming_duration_threshold: int = 300 - # Logging log_level: str = "INFO" - # Server - host: str = "0.0.0.0" - port: int = 8000 - @lru_cache def get_settings() -> Settings: diff --git a/tiktok_scrapper/uv.lock b/tiktok_scrapper/uv.lock index c65c0b9..7047f15 100644 --- a/tiktok_scrapper/uv.lock +++ b/tiktok_scrapper/uv.lock @@ -263,7 +263,6 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "pydantic-settings" }, - { name = "python-dotenv" }, { name = "uvicorn" }, { name = "yt-dlp" }, ] @@ -274,7 +273,6 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, - { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "uvicorn", specifier = ">=0.34.0" }, { name = "yt-dlp", specifier = "==2026.2.4" }, ] From e65080662d470128b2f43710bbf78219c58e43f5 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Fri, 13 Mar 2026 01:06:45 -0700 Subject: [PATCH 07/10] refactor(tiktok): flatten package structure from nested tiktok_scrapper to app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move application code from tiktok_scrapper/tiktok_scrapper/ to tiktok_scrapper/app/ - Remove editable package configuration and build-system requirements - Update Docker and documentation to reference new module path (tiktok_scrapper.app → app.app) - Simplify dependency management by treating tiktok_scrapper as an application, not a library package --- pyproject.toml | 4 --- tiktok_scrapper/Dockerfile | 4 +-- tiktok_scrapper/README.md | 4 +-- tiktok_scrapper/app/__init__.py | 0 .../{tiktok_scrapper => app}/app.py | 0 .../{tiktok_scrapper => app}/client.py | 0 .../{tiktok_scrapper => app}/config.py | 0 .../{tiktok_scrapper => app}/dependencies.py | 0 .../{tiktok_scrapper => app}/exceptions.py | 0 .../{tiktok_scrapper => app}/models.py | 0 .../{tiktok_scrapper => app}/proxy_manager.py | 0 .../routes/__init__.py | 0 .../{tiktok_scrapper => app}/routes/health.py | 0 .../{tiktok_scrapper => app}/routes/music.py | 0 .../{tiktok_scrapper => app}/routes/video.py | 0 tiktok_scrapper/pyproject.toml | 4 --- tiktok_scrapper/tiktok_scrapper/__init__.py | 29 ------------------- tiktok_scrapper/uv.lock | 20 ++++++------- 18 files changed, 14 insertions(+), 51 deletions(-) create mode 100644 tiktok_scrapper/app/__init__.py rename tiktok_scrapper/{tiktok_scrapper => app}/app.py (100%) rename tiktok_scrapper/{tiktok_scrapper => app}/client.py (100%) rename tiktok_scrapper/{tiktok_scrapper => app}/config.py (100%) rename tiktok_scrapper/{tiktok_scrapper => app}/dependencies.py (100%) rename tiktok_scrapper/{tiktok_scrapper => app}/exceptions.py (100%) rename tiktok_scrapper/{tiktok_scrapper => app}/models.py (100%) rename tiktok_scrapper/{tiktok_scrapper => app}/proxy_manager.py (100%) rename tiktok_scrapper/{tiktok_scrapper => app}/routes/__init__.py (100%) rename tiktok_scrapper/{tiktok_scrapper => app}/routes/health.py (100%) rename tiktok_scrapper/{tiktok_scrapper => app}/routes/music.py (100%) rename tiktok_scrapper/{tiktok_scrapper => app}/routes/video.py (100%) delete mode 100644 tiktok_scrapper/tiktok_scrapper/__init__.py diff --git a/pyproject.toml b/pyproject.toml index 059de98..6666ba5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,12 +23,8 @@ main = [ "APScheduler==3.11.2", "Pillow==12.1.0", "pillow-heif==1.1.1", - "tiktok-scrapper", ] [tool.uv] # This is an application, not a library package package = false - -[tool.uv.sources] -tiktok-scrapper = { path = "tiktok_scrapper", editable = true } diff --git a/tiktok_scrapper/Dockerfile b/tiktok_scrapper/Dockerfile index a58c266..3eccc18 100644 --- a/tiktok_scrapper/Dockerfile +++ b/tiktok_scrapper/Dockerfile @@ -9,8 +9,8 @@ COPY pyproject.toml uv.lock* ./ RUN uv sync --frozen --no-dev 2>/dev/null || uv sync --no-dev # Copy application code -COPY tiktok_scrapper/ tiktok_scrapper/ +COPY app/ app/ EXPOSE 8000 -CMD ["uv", "run", "uvicorn", "tiktok_scrapper.app:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uv", "run", "uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/tiktok_scrapper/README.md b/tiktok_scrapper/README.md index 2973cea..74d1b66 100644 --- a/tiktok_scrapper/README.md +++ b/tiktok_scrapper/README.md @@ -11,10 +11,10 @@ cd tiktok_scrapper uv sync # Start the server -uv run uvicorn tiktok_scrapper.app:app --host 0.0.0.0 --port 8000 +uv run uvicorn app.app:app --host 0.0.0.0 --port 8000 # With auto-reload for development -uv run uvicorn tiktok_scrapper.app:app --reload +uv run uvicorn app.app:app --reload ``` ## Running with Docker diff --git a/tiktok_scrapper/app/__init__.py b/tiktok_scrapper/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tiktok_scrapper/tiktok_scrapper/app.py b/tiktok_scrapper/app/app.py similarity index 100% rename from tiktok_scrapper/tiktok_scrapper/app.py rename to tiktok_scrapper/app/app.py diff --git a/tiktok_scrapper/tiktok_scrapper/client.py b/tiktok_scrapper/app/client.py similarity index 100% rename from tiktok_scrapper/tiktok_scrapper/client.py rename to tiktok_scrapper/app/client.py diff --git a/tiktok_scrapper/tiktok_scrapper/config.py b/tiktok_scrapper/app/config.py similarity index 100% rename from tiktok_scrapper/tiktok_scrapper/config.py rename to tiktok_scrapper/app/config.py diff --git a/tiktok_scrapper/tiktok_scrapper/dependencies.py b/tiktok_scrapper/app/dependencies.py similarity index 100% rename from tiktok_scrapper/tiktok_scrapper/dependencies.py rename to tiktok_scrapper/app/dependencies.py diff --git a/tiktok_scrapper/tiktok_scrapper/exceptions.py b/tiktok_scrapper/app/exceptions.py similarity index 100% rename from tiktok_scrapper/tiktok_scrapper/exceptions.py rename to tiktok_scrapper/app/exceptions.py diff --git a/tiktok_scrapper/tiktok_scrapper/models.py b/tiktok_scrapper/app/models.py similarity index 100% rename from tiktok_scrapper/tiktok_scrapper/models.py rename to tiktok_scrapper/app/models.py diff --git a/tiktok_scrapper/tiktok_scrapper/proxy_manager.py b/tiktok_scrapper/app/proxy_manager.py similarity index 100% rename from tiktok_scrapper/tiktok_scrapper/proxy_manager.py rename to tiktok_scrapper/app/proxy_manager.py diff --git a/tiktok_scrapper/tiktok_scrapper/routes/__init__.py b/tiktok_scrapper/app/routes/__init__.py similarity index 100% rename from tiktok_scrapper/tiktok_scrapper/routes/__init__.py rename to tiktok_scrapper/app/routes/__init__.py diff --git a/tiktok_scrapper/tiktok_scrapper/routes/health.py b/tiktok_scrapper/app/routes/health.py similarity index 100% rename from tiktok_scrapper/tiktok_scrapper/routes/health.py rename to tiktok_scrapper/app/routes/health.py diff --git a/tiktok_scrapper/tiktok_scrapper/routes/music.py b/tiktok_scrapper/app/routes/music.py similarity index 100% rename from tiktok_scrapper/tiktok_scrapper/routes/music.py rename to tiktok_scrapper/app/routes/music.py diff --git a/tiktok_scrapper/tiktok_scrapper/routes/video.py b/tiktok_scrapper/app/routes/video.py similarity index 100% rename from tiktok_scrapper/tiktok_scrapper/routes/video.py rename to tiktok_scrapper/app/routes/video.py diff --git a/tiktok_scrapper/pyproject.toml b/tiktok_scrapper/pyproject.toml index 41f861f..7853e22 100644 --- a/tiktok_scrapper/pyproject.toml +++ b/tiktok_scrapper/pyproject.toml @@ -11,7 +11,3 @@ dependencies = [ "curl-cffi>=0.14.0", "pydantic-settings>=2.13.1", ] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" diff --git a/tiktok_scrapper/tiktok_scrapper/__init__.py b/tiktok_scrapper/tiktok_scrapper/__init__.py deleted file mode 100644 index 90e2ff8..0000000 --- a/tiktok_scrapper/tiktok_scrapper/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -"""TikTok scrapper - standalone REST API for TikTok metadata extraction.""" - -from .client import TikTokClient -from .exceptions import ( - TikTokDeletedError, - TikTokError, - TikTokExtractionError, - TikTokInvalidLinkError, - TikTokNetworkError, - TikTokPrivateError, - TikTokRateLimitError, - TikTokRegionError, - TikTokVideoTooLongError, -) -from .proxy_manager import ProxyManager - -__all__ = [ - "TikTokClient", - "ProxyManager", - "TikTokError", - "TikTokDeletedError", - "TikTokInvalidLinkError", - "TikTokPrivateError", - "TikTokNetworkError", - "TikTokRateLimitError", - "TikTokRegionError", - "TikTokExtractionError", - "TikTokVideoTooLongError", -] diff --git a/tiktok_scrapper/uv.lock b/tiktok_scrapper/uv.lock index 7047f15..42804f6 100644 --- a/tiktok_scrapper/uv.lock +++ b/tiktok_scrapper/uv.lock @@ -257,7 +257,7 @@ wheels = [ [[package]] name = "tiktok-scrapper" version = "0.1.0" -source = { editable = "." } +source = { virtual = "." } dependencies = [ { name = "curl-cffi" }, { name = "fastapi" }, @@ -269,12 +269,12 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "curl-cffi", specifier = ">=0.7.0" }, - { name = "fastapi", specifier = ">=0.115.0" }, - { name = "httpx", specifier = ">=0.28.0" }, - { name = "pydantic-settings", specifier = ">=2.0.0" }, - { name = "uvicorn", specifier = ">=0.34.0" }, - { name = "yt-dlp", specifier = "==2026.2.4" }, + { 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]] @@ -313,9 +313,9 @@ wheels = [ [[package]] name = "yt-dlp" -version = "2026.2.4" +version = "2026.3.3" 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" } +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/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" }, + { 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" }, ] From bd27f7d442f1c65d6cae94d617a57a689fafd8a1 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Fri, 13 Mar 2026 01:26:02 -0700 Subject: [PATCH 08/10] refactor(tt-scrap): rename tiktok-scrapper package to tt-scrap Rename the package directory and all references from tiktok_scrapper to tt-scrap. Updates project name in pyproject.toml, documentation, Docker configuration, and dependency lock files. Removes tt-scrap as a local dependency from the main project's uv.lock. --- {tiktok_scrapper => tt-scrap}/Dockerfile | 0 {tiktok_scrapper => tt-scrap}/README.md | 12 +- {tiktok_scrapper => tt-scrap}/app/__init__.py | 0 {tiktok_scrapper => tt-scrap}/app/app.py | 0 {tiktok_scrapper => tt-scrap}/app/client.py | 0 {tiktok_scrapper => tt-scrap}/app/config.py | 0 .../app/dependencies.py | 0 .../app/exceptions.py | 0 {tiktok_scrapper => tt-scrap}/app/models.py | 0 .../app/proxy_manager.py | 0 .../app/routes/__init__.py | 0 .../app/routes/health.py | 0 .../app/routes/music.py | 0 .../app/routes/video.py | 0 {tiktok_scrapper => tt-scrap}/pyproject.toml | 2 +- {tiktok_scrapper => tt-scrap}/uv.lock | 2 +- uv.lock | 168 ------------------ 17 files changed, 8 insertions(+), 176 deletions(-) rename {tiktok_scrapper => tt-scrap}/Dockerfile (100%) rename {tiktok_scrapper => tt-scrap}/README.md (92%) rename {tiktok_scrapper => tt-scrap}/app/__init__.py (100%) rename {tiktok_scrapper => tt-scrap}/app/app.py (100%) rename {tiktok_scrapper => tt-scrap}/app/client.py (100%) rename {tiktok_scrapper => tt-scrap}/app/config.py (100%) rename {tiktok_scrapper => tt-scrap}/app/dependencies.py (100%) rename {tiktok_scrapper => tt-scrap}/app/exceptions.py (100%) rename {tiktok_scrapper => tt-scrap}/app/models.py (100%) rename {tiktok_scrapper => tt-scrap}/app/proxy_manager.py (100%) rename {tiktok_scrapper => tt-scrap}/app/routes/__init__.py (100%) rename {tiktok_scrapper => tt-scrap}/app/routes/health.py (100%) rename {tiktok_scrapper => tt-scrap}/app/routes/music.py (100%) rename {tiktok_scrapper => tt-scrap}/app/routes/video.py (100%) rename {tiktok_scrapper => tt-scrap}/pyproject.toml (92%) rename {tiktok_scrapper => tt-scrap}/uv.lock (99%) diff --git a/tiktok_scrapper/Dockerfile b/tt-scrap/Dockerfile similarity index 100% rename from tiktok_scrapper/Dockerfile rename to tt-scrap/Dockerfile diff --git a/tiktok_scrapper/README.md b/tt-scrap/README.md similarity index 92% rename from tiktok_scrapper/README.md rename to tt-scrap/README.md index 74d1b66..284182f 100644 --- a/tiktok_scrapper/README.md +++ b/tt-scrap/README.md @@ -1,11 +1,11 @@ -# TikTok Scrapper API +# TT Scrap API Standalone FastAPI server for extracting TikTok video, slideshow, and music metadata. ## Running with uv ```bash -cd tiktok_scrapper +cd tt-scrap # Install dependencies uv sync @@ -20,20 +20,20 @@ uv run uvicorn app.app:app --reload ## Running with Docker ```bash -cd tiktok_scrapper +cd tt-scrap # Build -docker build -t tiktok-scrapper . +docker build -t tt-scrap . # Run -docker run -p 8000:8000 tiktok-scrapper +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 \ - tiktok-scrapper + tt-scrap ``` ## API Endpoints diff --git a/tiktok_scrapper/app/__init__.py b/tt-scrap/app/__init__.py similarity index 100% rename from tiktok_scrapper/app/__init__.py rename to tt-scrap/app/__init__.py diff --git a/tiktok_scrapper/app/app.py b/tt-scrap/app/app.py similarity index 100% rename from tiktok_scrapper/app/app.py rename to tt-scrap/app/app.py diff --git a/tiktok_scrapper/app/client.py b/tt-scrap/app/client.py similarity index 100% rename from tiktok_scrapper/app/client.py rename to tt-scrap/app/client.py diff --git a/tiktok_scrapper/app/config.py b/tt-scrap/app/config.py similarity index 100% rename from tiktok_scrapper/app/config.py rename to tt-scrap/app/config.py diff --git a/tiktok_scrapper/app/dependencies.py b/tt-scrap/app/dependencies.py similarity index 100% rename from tiktok_scrapper/app/dependencies.py rename to tt-scrap/app/dependencies.py diff --git a/tiktok_scrapper/app/exceptions.py b/tt-scrap/app/exceptions.py similarity index 100% rename from tiktok_scrapper/app/exceptions.py rename to tt-scrap/app/exceptions.py diff --git a/tiktok_scrapper/app/models.py b/tt-scrap/app/models.py similarity index 100% rename from tiktok_scrapper/app/models.py rename to tt-scrap/app/models.py diff --git a/tiktok_scrapper/app/proxy_manager.py b/tt-scrap/app/proxy_manager.py similarity index 100% rename from tiktok_scrapper/app/proxy_manager.py rename to tt-scrap/app/proxy_manager.py diff --git a/tiktok_scrapper/app/routes/__init__.py b/tt-scrap/app/routes/__init__.py similarity index 100% rename from tiktok_scrapper/app/routes/__init__.py rename to tt-scrap/app/routes/__init__.py diff --git a/tiktok_scrapper/app/routes/health.py b/tt-scrap/app/routes/health.py similarity index 100% rename from tiktok_scrapper/app/routes/health.py rename to tt-scrap/app/routes/health.py diff --git a/tiktok_scrapper/app/routes/music.py b/tt-scrap/app/routes/music.py similarity index 100% rename from tiktok_scrapper/app/routes/music.py rename to tt-scrap/app/routes/music.py diff --git a/tiktok_scrapper/app/routes/video.py b/tt-scrap/app/routes/video.py similarity index 100% rename from tiktok_scrapper/app/routes/video.py rename to tt-scrap/app/routes/video.py diff --git a/tiktok_scrapper/pyproject.toml b/tt-scrap/pyproject.toml similarity index 92% rename from tiktok_scrapper/pyproject.toml rename to tt-scrap/pyproject.toml index 7853e22..cea05a4 100644 --- a/tiktok_scrapper/pyproject.toml +++ b/tt-scrap/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "tiktok-scrapper" +name = "tt-scrap" version = "0.1.0" description = "TikTok video/music/slideshow metadata extraction REST API" requires-python = "==3.13.*" diff --git a/tiktok_scrapper/uv.lock b/tt-scrap/uv.lock similarity index 99% rename from tiktok_scrapper/uv.lock rename to tt-scrap/uv.lock index 42804f6..b64accd 100644 --- a/tiktok_scrapper/uv.lock +++ b/tt-scrap/uv.lock @@ -255,7 +255,7 @@ wheels = [ ] [[package]] -name = "tiktok-scrapper" +name = "tt-scrap" version = "0.1.0" source = { virtual = "." } dependencies = [ diff --git a/uv.lock b/uv.lock index 82f29b5..5ebbeb5 100644 --- a/uv.lock +++ b/uv.lock @@ -83,15 +83,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] -[[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" @@ -101,18 +92,6 @@ 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 = "apscheduler" version = "3.11.2" @@ -159,27 +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 = "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 = "contourpy" version = "1.3.3" @@ -222,22 +180,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] -[[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 = "fonttools" version = "4.61.1" @@ -312,43 +254,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, ] -[[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" @@ -673,20 +578,6 @@ wheels = [ { 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 = "pyparsing" version = "3.3.1" @@ -756,41 +647,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, ] -[[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 = "tiktok-scrapper" -version = "0.1.0" -source = { editable = "tiktok_scrapper" } -dependencies = [ - { name = "fastapi" }, - { name = "httpx" }, - { name = "pydantic-settings" }, - { name = "python-dotenv" }, - { name = "uvicorn" }, - { name = "yt-dlp" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastapi", specifier = ">=0.115.0" }, - { name = "httpx", specifier = ">=0.28.0" }, - { name = "pydantic-settings", specifier = ">=2.0.0" }, - { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "uvicorn", specifier = ">=0.34.0" }, - { name = "yt-dlp", specifier = "==2026.2.4" }, -] - [[package]] name = "tt-bot" version = "4.5.0" @@ -808,7 +664,6 @@ main = [ { name = "apscheduler" }, { name = "pillow" }, { name = "pillow-heif" }, - { name = "tiktok-scrapper" }, ] stats = [ { name = "apscheduler" }, @@ -829,7 +684,6 @@ requires-dist = [ { name = "pillow-heif", marker = "extra == 'main'", specifier = "==1.1.1" }, { name = "python-dotenv", specifier = "==1.2.1" }, { name = "sqlalchemy", specifier = "==2.0.45" }, - { name = "tiktok-scrapper", marker = "extra == 'main'", editable = "tiktok_scrapper" }, ] provides-extras = ["stats", "main"] @@ -875,19 +729,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] -[[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 = "yarl" version = "1.22.0" @@ -933,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" }, -] From 4f9dcf4b36f63a014538087665fc2c9d875b5317 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Fri, 13 Mar 2026 01:58:49 -0700 Subject: [PATCH 09/10] refactor(app): extract tiktok service and introduce service registry pattern - Move TikTok client, routes, and config into dedicated service module - Introduce ServiceRegistry for managing multiple scraper services - Create BaseClient protocol for service implementations - Generalize exceptions from TikTok-specific to service-agnostic names - Replace dependency injection with service registry initialization - Support dynamic service registration and initialization at startup --- tt-scrap/app/app.py | 70 +++++----- tt-scrap/app/base_client.py | 25 ++++ tt-scrap/app/config.py | 6 +- tt-scrap/app/dependencies.py | 12 -- tt-scrap/app/exceptions.py | 36 ++--- tt-scrap/app/models.py | 16 +-- tt-scrap/app/registry.py | 36 +++++ tt-scrap/app/routes/__init__.py | 6 +- tt-scrap/app/routes/music.py | 44 ------- tt-scrap/app/services/__init__.py | 5 + tt-scrap/app/services/tiktok/__init__.py | 36 +++++ tt-scrap/app/{ => services/tiktok}/client.py | 123 ++++++------------ tt-scrap/app/services/tiktok/config.py | 27 ++++ .../video.py => services/tiktok/parser.py} | 50 +++---- tt-scrap/app/services/tiktok/routes.py | 78 +++++++++++ 15 files changed, 332 insertions(+), 238 deletions(-) create mode 100644 tt-scrap/app/base_client.py delete mode 100644 tt-scrap/app/dependencies.py create mode 100644 tt-scrap/app/registry.py delete mode 100644 tt-scrap/app/routes/music.py create mode 100644 tt-scrap/app/services/__init__.py create mode 100644 tt-scrap/app/services/tiktok/__init__.py rename tt-scrap/app/{ => services/tiktok}/client.py (81%) create mode 100644 tt-scrap/app/services/tiktok/config.py rename tt-scrap/app/{routes/video.py => services/tiktok/parser.py} (67%) create mode 100644 tt-scrap/app/services/tiktok/routes.py diff --git a/tt-scrap/app/app.py b/tt-scrap/app/app.py index 0062ccd..3a56068 100644 --- a/tt-scrap/app/app.py +++ b/tt-scrap/app/app.py @@ -1,4 +1,4 @@ -"""FastAPI REST API server for TikTok scrapping.""" +"""FastAPI REST API server for media scraping.""" from __future__ import annotations @@ -8,34 +8,37 @@ from fastapi import FastAPI from fastapi.responses import JSONResponse -from .client import TikTokClient from .config import settings from .exceptions import ( - TikTokDeletedError, - TikTokError, - TikTokExtractionError, - TikTokInvalidLinkError, - TikTokNetworkError, - TikTokPrivateError, - TikTokRateLimitError, - TikTokRegionError, - TikTokVideoTooLongError, + 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[TikTokError], int] = { - TikTokDeletedError: 404, - TikTokPrivateError: 403, - TikTokInvalidLinkError: 400, - TikTokVideoTooLongError: 413, - TikTokRateLimitError: 429, - TikTokNetworkError: 502, - TikTokRegionError: 451, - TikTokExtractionError: 500, +_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, } @@ -56,27 +59,32 @@ async def lifespan(app: FastAPI): else None ) - app.state.client = TikTokClient( - proxy_manager=proxy_manager, - ) + registry = ServiceRegistry() + tiktok = create_tiktok_service(proxy_manager=proxy_manager) + registry.register(tiktok) + app.include_router(tiktok.router) + + app.state.registry = registry - logger.info("TikTok scrapper API started") + logger.info("Scraper API started") yield - await TikTokClient.close_http_client() - TikTokClient.shutdown_executor() - logger.info("TikTok scrapper API stopped") + for service in registry.get_all(): + if service.shutdown: + await service.shutdown() + + logger.info("Scraper API stopped") app = FastAPI( - title="TikTok Scrapper API", - version="0.1.0", + title="Media Scraper API", + version="0.2.0", lifespan=lifespan, ) -@app.exception_handler(TikTokError) -async def tiktok_error_handler(request, exc: TikTokError): +@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, 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 index 03e012b..9c177e1 100644 --- a/tt-scrap/app/config.py +++ b/tt-scrap/app/config.py @@ -1,4 +1,4 @@ -"""Configuration for TikTok scrapper API using pydantic-settings.""" +"""Configuration for scraper API using pydantic-settings.""" from __future__ import annotations @@ -14,10 +14,6 @@ class Settings(BaseSettings): extra="ignore", ) - # Retry - url_resolve_max_retries: int = 3 - video_info_max_retries: int = 3 - # Proxy proxy_file: str = "" proxy_include_host: bool = False diff --git a/tt-scrap/app/dependencies.py b/tt-scrap/app/dependencies.py deleted file mode 100644 index 76f1921..0000000 --- a/tt-scrap/app/dependencies.py +++ /dev/null @@ -1,12 +0,0 @@ -"""FastAPI dependencies for TikTok scrapper API.""" - -from __future__ import annotations - -from fastapi import Request - -from .client import TikTokClient - - -def get_client(request: Request) -> TikTokClient: - """Get the TikTokClient instance from application state.""" - return request.app.state.client diff --git a/tt-scrap/app/exceptions.py b/tt-scrap/app/exceptions.py index 06b00e5..bf43eed 100644 --- a/tt-scrap/app/exceptions.py +++ b/tt-scrap/app/exceptions.py @@ -1,37 +1,41 @@ -"""TikTok API exception classes.""" +"""Scraper exception classes (service-agnostic).""" -class TikTokError(Exception): - """Base exception for TikTok API errors.""" +class ScraperError(Exception): + """Base exception for all scraper errors.""" -class TikTokDeletedError(TikTokError): - """Video has been deleted by the creator.""" +class ContentDeletedError(ScraperError): + """Content has been deleted by the creator.""" -class TikTokPrivateError(TikTokError): - """Video is private and cannot be accessed.""" +class ContentPrivateError(ScraperError): + """Content is private and cannot be accessed.""" -class TikTokNetworkError(TikTokError): +class NetworkError(ScraperError): """Network error occurred during request.""" -class TikTokRateLimitError(TikTokError): +class RateLimitError(ScraperError): """Too many requests - rate limited.""" -class TikTokRegionError(TikTokError): - """Video is not available in the user's region (geo-blocked).""" +class RegionBlockedError(ScraperError): + """Content is not available in the user's region (geo-blocked).""" -class TikTokExtractionError(TikTokError): +class ExtractionError(ScraperError): """Generic extraction/parsing error (invalid ID, unknown failure, etc.).""" -class TikTokVideoTooLongError(TikTokError): - """Video exceeds the maximum allowed duration.""" +class ContentTooLongError(ScraperError): + """Content exceeds the maximum allowed duration.""" -class TikTokInvalidLinkError(TikTokError): - """TikTok link is invalid or expired (failed URL resolution).""" +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 index 284c42f..7fe96ec 100644 --- a/tt-scrap/app/models.py +++ b/tt-scrap/app/models.py @@ -1,4 +1,4 @@ -"""Pydantic API response models for TikTok scrapper REST API.""" +"""Pydantic API response models for the scraper REST API.""" from __future__ import annotations @@ -8,8 +8,6 @@ class MusicResponse(BaseModel): - """Music metadata returned as part of a video response.""" - url: str title: str author: str @@ -18,8 +16,6 @@ class MusicResponse(BaseModel): class VideoResponse(BaseModel): - """Filtered video/slideshow response.""" - type: str # "video" or "images" id: int video_url: str | None = None @@ -35,16 +31,12 @@ class VideoResponse(BaseModel): class RawVideoResponse(BaseModel): - """Raw TikTok API response (full yt-dlp extraction data).""" - id: int resolved_url: str data: dict[str, Any] class MusicDetailResponse(BaseModel): - """Filtered music response for the /music endpoint.""" - id: int title: str author: str @@ -54,20 +46,14 @@ class MusicDetailResponse(BaseModel): class RawMusicResponse(BaseModel): - """Raw music data from TikTok API.""" - id: int data: dict[str, Any] class HealthResponse(BaseModel): - """Health check response.""" - status: str = "ok" class ErrorResponse(BaseModel): - """Error response body.""" - error: str error_type: str 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 index 1fd57c5..da90c64 100644 --- a/tt-scrap/app/routes/__init__.py +++ b/tt-scrap/app/routes/__init__.py @@ -1,12 +1,8 @@ -"""API route aggregation.""" +"""API route aggregation (shared routes only).""" from fastapi import APIRouter from .health import router as health_router -from .music import router as music_router -from .video import router as video_router router = APIRouter() -router.include_router(video_router) -router.include_router(music_router) router.include_router(health_router) diff --git a/tt-scrap/app/routes/music.py b/tt-scrap/app/routes/music.py deleted file mode 100644 index fe8c20b..0000000 --- a/tt-scrap/app/routes/music.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Music extraction endpoint.""" - -from __future__ import annotations - -from fastapi import APIRouter, Depends, Query - -from ..client import TikTokClient -from ..dependencies import get_client -from ..models import MusicDetailResponse, RawMusicResponse - -router = APIRouter() - - -@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"), - client: TikTokClient = Depends(get_client), -): - """Extract music info from a TikTok video.""" - result = await client.extract_music_info(video_id) - music_data = result["music_data"] - - if raw: - return RawMusicResponse( - id=video_id, - data=result["video_data"], - ) - - 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/__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/client.py b/tt-scrap/app/services/tiktok/client.py similarity index 81% rename from tt-scrap/app/client.py rename to tt-scrap/app/services/tiktok/client.py index e2994de..57fb139 100644 --- a/tt-scrap/app/client.py +++ b/tt-scrap/app/services/tiktok/client.py @@ -17,16 +17,16 @@ except ImportError: ImpersonateTarget = None -from .config import settings -from .exceptions import ( - TikTokDeletedError, - TikTokError, - TikTokExtractionError, - TikTokInvalidLinkError, - TikTokNetworkError, - TikTokPrivateError, - TikTokRateLimitError, - TikTokRegionError, +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 @@ -37,13 +37,12 @@ ) if TYPE_CHECKING: - from .proxy_manager import ProxyManager + from ...proxy_manager import ProxyManager logger = logging.getLogger(__name__) def _strip_proxy_auth(proxy_url: str | None) -> str: - """Strip authentication info from proxy URL for safe logging.""" if proxy_url is None: return "direct connection" match = re.match(r"^(https?://)(?:[^@]+@)?(.+)$", proxy_url) @@ -66,7 +65,6 @@ class ProxySession: _initialized: bool = field(default=False, init=False) def get_proxy(self) -> str | None: - """Get the current proxy (lazily initialized on first call).""" if not self._initialized: self._initialized = True if self.proxy_manager: @@ -80,7 +78,6 @@ def get_proxy(self) -> str | None: return self._current_proxy def rotate_proxy(self) -> str | None: - """Rotate to the next proxy in the rotation (for retries).""" if self.proxy_manager: old_proxy = self._current_proxy self._current_proxy = self.proxy_manager.get_next_proxy() @@ -93,14 +90,9 @@ def rotate_proxy(self) -> str | None: class TikTokClient: - """Client for extracting TikTok video and music metadata. + """Client for extracting TikTok video and music metadata via yt-dlp. - This client uses yt-dlp internally to extract video/slideshow data and music - from TikTok URLs. It only extracts metadata — no media downloads. - - Args: - proxy_manager: Optional ProxyManager instance for round-robin proxy rotation. - cookies: Optional path to a Netscape-format cookies file. + Extracts only metadata -- no media downloads. """ _executor: ThreadPoolExecutor | None = None @@ -113,7 +105,6 @@ class TikTokClient: @classmethod def _get_executor(cls) -> ThreadPoolExecutor: - """Get or create the shared ThreadPoolExecutor.""" with cls._executor_lock: if cls._executor is None: cls._executor = ThreadPoolExecutor( @@ -127,7 +118,6 @@ def _get_executor(cls) -> ThreadPoolExecutor: @classmethod def _get_http_client(cls) -> httpx.AsyncClient: - """Get or create shared httpx client for URL resolution.""" with cls._http_client_lock: if cls._http_client is None or cls._http_client.is_closed: cls._http_client = httpx.AsyncClient( @@ -142,7 +132,6 @@ def _get_http_client(cls) -> httpx.AsyncClient: @classmethod def _can_impersonate(cls) -> bool: - """Check if browser impersonation is available (cached).""" if cls._impersonate_available is None: if ImpersonateTarget is None: cls._impersonate_available = False @@ -151,7 +140,7 @@ def _can_impersonate(cls) -> bool: ydl = yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True}) targets = list(ydl._get_available_impersonate_targets()) ydl.close() - cls._impersonate_available = len(targets) > 0 + cls._impersonate_available = bool(targets) if not cls._impersonate_available: logger.warning( "No impersonate targets available (curl_cffi not installed?), " @@ -164,7 +153,6 @@ def _can_impersonate(cls) -> bool: @classmethod async def close_http_client(cls) -> None: - """Close shared httpx client. Call on application shutdown.""" with cls._http_client_lock: client = cls._http_client cls._http_client = None @@ -173,7 +161,6 @@ async def close_http_client(cls) -> None: @classmethod def shutdown_executor(cls) -> None: - """Shutdown the shared executor. Call on application shutdown.""" with cls._executor_lock: if cls._executor is not None: cls._executor.shutdown(wait=False) @@ -206,9 +193,8 @@ async def _resolve_url( proxy_session: ProxySession, max_retries: int | None = None, ) -> str: - """Resolve short URLs to full URLs with retry and proxy rotation.""" if max_retries is None: - max_retries = settings.url_resolve_max_retries + max_retries = tiktok_settings.url_resolve_max_retries is_short_url = ( "vm.tiktok.com" in url @@ -261,17 +247,15 @@ async def _resolve_url( logger.error( f"URL resolution failed after {max_retries} attempts for {url}: {last_error}" ) - raise TikTokInvalidLinkError("Invalid or expired TikTok link") + raise InvalidLinkError("Invalid or expired TikTok link") def _extract_video_id(self, url: str) -> str | None: - """Extract video ID from TikTok URL.""" 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]: - """Get base yt-dlp options.""" opts: dict[str, Any] = { "quiet": True, "no_warnings": True, @@ -305,11 +289,6 @@ def _get_ydl_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]: - """Extract TikTok data synchronously via yt-dlp. - - Returns: - Tuple of (video_data, status, download_context) - """ ydl_opts = self._get_ydl_opts(use_proxy=True, explicit_proxy=request_proxy) ydl = None @@ -326,7 +305,7 @@ def _extract_with_context_sync( f"Current yt-dlp version: {yt_dlp.version.__version__}. " "Please update yt-dlp: pip install -U yt-dlp" ) - raise TikTokExtractionError( + raise ExtractionError( "Incompatible yt-dlp version: missing required internal method." ) @@ -348,7 +327,7 @@ def _extract_with_context_sync( f"Failed to call yt-dlp internal method: {e}. " f"Current yt-dlp version: {yt_dlp.version.__version__}." ) - raise TikTokExtractionError( + raise ExtractionError( "Incompatible yt-dlp version." ) from e @@ -382,7 +361,7 @@ def _extract_with_context_sync( except yt_dlp.utils.ExtractorError as e: logger.error(f"yt-dlp extractor error for video {video_id}: {e}") return None, "extraction", None - except TikTokError: + except ScraperError: raise except Exception as e: logger.error(f"yt-dlp extraction failed for video {video_id}: {e}", exc_info=True) @@ -395,26 +374,24 @@ def _extract_with_context_sync( pass async def _run_sync(self, func: Any, *args: Any) -> Any: - """Run synchronous function in executor.""" - loop = asyncio.get_event_loop() + 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: - """Close the YoutubeDL instance in a download context if present.""" if download_context and "ydl" in download_context: try: download_context["ydl"].close() except Exception: pass - _STATUS_EXCEPTIONS: dict[str, type[TikTokError]] = { - "deleted": TikTokDeletedError, - "private": TikTokPrivateError, - "rate_limit": TikTokRateLimitError, - "network": TikTokNetworkError, - "region": TikTokRegionError, + _STATUS_EXCEPTIONS: dict[str, type[ScraperError]] = { + "deleted": ContentDeletedError, + "private": ContentPrivateError, + "rate_limit": RateLimitError, + "network": NetworkError, + "region": RegionBlockedError, } _STATUS_MESSAGES: dict[str, str] = { @@ -426,12 +403,11 @@ def _close_download_context( } def _raise_for_status(self, status: str, video_link: str) -> None: - """Raise appropriate exception based on status string.""" exc_cls = self._STATUS_EXCEPTIONS.get(status) if exc_cls: message = self._STATUS_MESSAGES[status].format(link=video_link) raise exc_cls(message) - raise TikTokExtractionError(f"Failed to extract video {video_link}") + raise ExtractionError(f"Failed to extract video {video_link}") async def _extract_video_info_with_retry( self, @@ -440,13 +416,8 @@ async def _extract_video_info_with_retry( proxy_session: ProxySession, max_retries: int | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: - """Extract video info with retry and proxy rotation. - - Returns: - Tuple of (video_data, download_context) - """ if max_retries is None: - max_retries = settings.video_info_max_retries + max_retries = tiktok_settings.video_info_max_retries last_error: Exception | None = None download_context: dict[str, Any] | None = None @@ -467,17 +438,17 @@ async def _extract_video_info_with_retry( self._raise_for_status(status, url) if status and status not in ("ok", None): - raise TikTokExtractionError(f"Extraction failed with status: {status}") + raise ExtractionError(f"Extraction failed with status: {status}") if video_data is None: - raise TikTokExtractionError("No video data returned") + raise ExtractionError("No video data returned") if download_context is None: - raise TikTokExtractionError("No download context returned") + raise ExtractionError("No download context returned") return video_data, download_context - except (TikTokDeletedError, TikTokPrivateError, TikTokRegionError): + except (ContentDeletedError, ContentPrivateError, RegionBlockedError): self._close_download_context(download_context) raise @@ -497,16 +468,11 @@ async def _extract_video_info_with_retry( f"Video info extraction failed after {max_retries} attempts " f"for {video_id}: {last_error}" ) - raise TikTokExtractionError( + raise ExtractionError( f"Failed to extract video info after {max_retries} attempts" ) async def extract_video_info(self, video_link: str) -> dict[str, Any]: - """Extract video/slideshow metadata without downloading media. - - Returns: - Dict with keys: video_data, video_id, resolved_url - """ proxy_session = ProxySession(self.proxy_manager) download_context: dict[str, Any] | None = None @@ -515,7 +481,7 @@ async def extract_video_info(self, video_link: str) -> dict[str, Any]: video_id = self._extract_video_id(full_url) if not video_id: - raise TikTokInvalidLinkError("Invalid or expired TikTok link") + 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( @@ -528,21 +494,16 @@ async def extract_video_info(self, video_link: str) -> dict[str, Any]: "resolved_url": full_url, } - except (TikTokError, asyncio.CancelledError): + except (ScraperError, asyncio.CancelledError): raise except httpx.HTTPError as e: - raise TikTokNetworkError(f"Network error: {e}") from e + raise NetworkError(f"Network error: {e}") from e except Exception as e: - raise TikTokExtractionError(f"Failed to extract video info: {e}") from 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]: - """Extract music metadata without downloading audio. - - Returns: - Dict with keys: video_data, music_data, video_id - """ + 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 @@ -554,7 +515,7 @@ async def extract_music_info(self, video_id: int) -> dict[str, Any]: music_info = video_data.get("music") if not music_info: - raise TikTokExtractionError(f"No music info found for video {video_id}") + raise ExtractionError(f"No music info found for video {video_id}") return { "video_data": video_data, @@ -562,11 +523,11 @@ async def extract_music_info(self, video_id: int) -> dict[str, Any]: "video_id": video_id, } - except (TikTokError, asyncio.CancelledError): + except (ScraperError, asyncio.CancelledError): raise except httpx.HTTPError as e: - raise TikTokNetworkError(f"Network error: {e}") from e + raise NetworkError(f"Network error: {e}") from e except Exception as e: - raise TikTokExtractionError(f"Failed to extract music info: {e}") from 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/routes/video.py b/tt-scrap/app/services/tiktok/parser.py similarity index 67% rename from tt-scrap/app/routes/video.py rename to tt-scrap/app/services/tiktok/parser.py index 006acb6..0b2e70f 100644 --- a/tt-scrap/app/routes/video.py +++ b/tt-scrap/app/services/tiktok/parser.py @@ -1,24 +1,17 @@ -"""Video/slideshow extraction endpoint.""" +"""TikTok-specific response parsing.""" from __future__ import annotations from typing import Any -from fastapi import APIRouter, Depends, Query +from ...models import MusicDetailResponse, MusicResponse, VideoResponse -from ..client import TikTokClient -from ..dependencies import get_client -from ..models import MusicResponse, RawVideoResponse, VideoResponse -router = APIRouter() - - -def _build_filtered_video_response( +def build_video_response( video_data: dict[str, Any], video_id: int, link: str, ) -> VideoResponse: - """Build a filtered VideoResponse from raw TikTok API data.""" image_post = video_data.get("imagePost") video_info = video_data.get("video", {}) stats = video_data.get("stats", {}) @@ -86,23 +79,22 @@ def _build_filtered_video_response( ) -@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"), - client: TikTokClient = Depends(get_client), -): - """Extract video/slideshow info from a TikTok URL.""" - 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, - ) +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 _build_filtered_video_response(video_data, video_id, url) + 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) From a67e28c3d27fb16249b8f7060c90d9c0998d11d9 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Fri, 13 Mar 2026 02:08:29 -0700 Subject: [PATCH 10/10] docs(tt-scrap): update README for multi-service architecture Update endpoints to reflect /{service}/... routing, document TIKTOK_ env prefix, and add instructions for adding new services. --- tt-scrap/README.md | 50 +++++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/tt-scrap/README.md b/tt-scrap/README.md index 284182f..1af835b 100644 --- a/tt-scrap/README.md +++ b/tt-scrap/README.md @@ -1,6 +1,8 @@ -# TT Scrap API +# Media Scraper API -Standalone FastAPI server for extracting TikTok video, slideshow, and music metadata. +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 @@ -38,7 +40,11 @@ docker run -p 8000:8000 \ ## API Endpoints -### `GET /video` +Routes are namespaced per service: `/{service}/...` + +### TikTok + +#### `GET /tiktok/video` Extract video or slideshow metadata from a TikTok URL. @@ -47,7 +53,7 @@ Extract video or slideshow metadata from a TikTok URL. | `url` | string | TikTok video or slideshow URL | | `raw` | bool | Return raw TikTok API data (default: false) | -### `GET /music` +#### `GET /tiktok/music` Extract music metadata from a TikTok video. @@ -56,21 +62,37 @@ Extract music metadata from a TikTok video. | `video_id` | int | TikTok video ID | | `raw` | bool | Return raw data (default: false) | -### `GET /health` +### Shared + +#### `GET /health` Health check. Returns `{"status": "ok"}`. -### `GET /docs` +#### `GET /docs` Interactive OpenAPI documentation (Swagger UI). ## Environment Variables -| Variable | Default | Description | -|-------------------------------|---------|------------------------------------------| -| `URL_RESOLVE_MAX_RETRIES` | `3` | Max retries for short URL resolution | -| `VIDEO_INFO_MAX_RETRIES` | `3` | Max retries for video info extraction | -| `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) | -| `YTDLP_COOKIES` | `""` | Path to Netscape-format cookies file | +### 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