feat: fetch views manually and worker

This commit is contained in:
Artem Tsyrulnikov
2025-11-12 23:55:44 +03:00
parent 292bb0d49b
commit a48d5e67cb
28 changed files with 632 additions and 135 deletions

2
.gitignore vendored
View File

@@ -125,5 +125,5 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
docker-compose.yml
.uv_cache/ .uv_cache/

View File

@@ -87,6 +87,7 @@ The codebase follows a clean architecture pattern with clear separation of conce
- Each use case is a standalone function - Each use case is a standalone function
- Depends on Protocol interfaces (Database, TelegramWriter, JWTEncoder) - Depends on Protocol interfaces (Database, TelegramWriter, JWTEncoder)
- Use cases are assembled in the `Usecase` dataclass for dependency injection - Use cases are assembled in the `Usecase` dataclass for dependency injection
- Один use case отвечает за полноценный сценарий «от триггера до завершения» и не должен вызывать другие use case'ы напрямую (общую логику выносить в хелперы/сервисы внутри того же модуля)
3. **Adapter Layer** (`src/adapter/`) 3. **Adapter Layer** (`src/adapter/`)
- Concrete implementations of protocol interfaces: - Concrete implementations of protocol interfaces:

View File

@@ -1,21 +1,26 @@
services: services:
backend: # backend:
build: # build:
context: . # context: .
args: # args:
GIT_COMMIT: ${GIT_COMMIT:-unknown} # GIT_COMMIT: ${GIT_COMMIT:-unknown}
env_file: # env_file:
- .env # - .env
ports: # ports:
- "8000:8000" # - "8000:8000"
depends_on: # depends_on:
- postgres # - postgres
postgres: postgres:
image: postgres image: postgres:17-alpine
environment: environment:
POSTGRES_USER: "user" POSTGRES_USER: "user"
POSTGRES_PASSWORD: "password" POSTGRES_PASSWORD: "password"
POSTGRES_DB: "tgex" POSTGRES_DB: "tgex"
ports: ports:
- "5432:5432" - "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:

View File

@@ -0,0 +1,21 @@
services:
# backend:
# build:
# context: .
# args:
# GIT_COMMIT: ${GIT_COMMIT:-unknown}
# env_file:
# - .env
# ports:
# - "8000:8000"
# depends_on:
# - postgres
postgres:
image: postgres
environment:
POSTGRES_USER: "user"
POSTGRES_PASSWORD: "password"
POSTGRES_DB: "tgex"
ports:
- "5432:5432"

View File

@@ -6,8 +6,12 @@ readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"aiogram>=3.16.0", "aiogram>=3.16.0",
"aiolimiter>=1.2.1",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"beautifulsoup4>=4.14.2",
"fastapi>=0.121.0", "fastapi>=0.121.0",
"httpx>=0.28.1",
"lxml>=6.0.2",
"pydantic-settings>=2.11.0", "pydantic-settings>=2.11.0",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"python-multipart>=0.0.20", "python-multipart>=0.0.20",

111
shared/http_parser_base.py Normal file
View File

@@ -0,0 +1,111 @@
"""Base class for HTTP-based HTML/content parsing."""
import logging
from typing import Any
import httpx
import pydantic
log = logging.getLogger(__name__)
class HttpParserConfig(pydantic.BaseModel):
"""Configuration for HTTP parser."""
TIMEOUT: int = 10
USER_AGENT: str | None = None
FOLLOW_REDIRECTS: bool = True
class HttpParserBase:
"""Base class for HTTP requests and HTML parsing.
Provides reusable HTTP client functionality with error handling.
Subclasses implement domain-specific parsing logic.
"""
def __init__(self, config: HttpParserConfig) -> None:
self.config = config
def get_headers(self, **extra_headers: str) -> dict[str, str]:
"""Build HTTP headers with default User-Agent.
Args:
**extra_headers: Additional headers to merge
Returns:
Dictionary of HTTP headers
"""
headers = {
'User-Agent': self.config.USER_AGENT or 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
}
headers.update(extra_headers)
return headers
async def fetch_html(
self,
url: str,
*,
headers: dict[str, str] | None = None,
follow_redirects: bool | None = None,
timeout: int | None = None,
) -> tuple[int, str]:
"""Fetch HTML content from URL.
Args:
url: Target URL to fetch
headers: Optional custom headers (uses get_headers() if None)
follow_redirects: Whether to follow HTTP redirects (uses config default if None)
timeout: Optional custom timeout (uses config default if None)
Returns:
Tuple of (status_code, html_content)
Raises:
httpx.TimeoutException: Request timed out
httpx.RequestError: Network error occurred
"""
if headers is None:
headers = self.get_headers()
async with httpx.AsyncClient(
timeout=timeout or self.config.TIMEOUT,
follow_redirects=follow_redirects if follow_redirects is not None else self.config.FOLLOW_REDIRECTS,
) as client:
response = await client.get(url, headers=headers)
return response.status_code, response.text
async def fetch_json(
self,
url: str,
*,
headers: dict[str, str] | None = None,
follow_redirects: bool | None = None,
timeout: int | None = None,
) -> tuple[int, Any]:
"""Fetch JSON content from URL.
Args:
url: Target URL to fetch
headers: Optional custom headers (uses get_headers() if None)
follow_redirects: Whether to follow HTTP redirects (uses config default if None)
timeout: Optional custom timeout (uses config default if None)
Returns:
Tuple of (status_code, json_data)
Raises:
httpx.TimeoutException: Request timed out
httpx.RequestError: Network error occurred
"""
if headers is None:
headers = self.get_headers()
async with httpx.AsyncClient(
timeout=timeout or self.config.TIMEOUT,
follow_redirects=follow_redirects if follow_redirects is not None else self.config.FOLLOW_REDIRECTS,
) as client:
response = await client.get(url, headers=headers)
return response.status_code, response.json()

65
shared/worker_base.py Normal file
View File

@@ -0,0 +1,65 @@
import asyncio
import logging
import pydantic
log = logging.getLogger(__name__)
class WorkerConfig(pydantic.BaseModel):
INTERVAL_SECONDS: int = 60
class WorkerBase:
def __init__(self, config: WorkerConfig) -> None:
self.config = config
self._task: asyncio.Task[None] | None = None
self._stop_event = asyncio.Event()
log.info('Worker initialized (interval=%ds)', config.INTERVAL_SECONDS)
async def _cycle_func(self) -> None:
raise NotImplementedError
async def start(self) -> None:
if self._task is not None:
log.warning('Worker already running')
return
self._stop_event.clear()
self._task = asyncio.create_task(self._run())
log.info('Worker started')
async def stop(self) -> None:
if self._task is None:
log.warning('Worker not running')
return
self._stop_event.set()
try:
await asyncio.wait_for(self._task, timeout=5.0)
except TimeoutError:
log.warning('Worker did not stop in time, cancelling')
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
except asyncio.CancelledError:
log.info('Worker task cancelled')
self._task = None
log.info('Worker stopped')
async def _run(self) -> None:
while not self._stop_event.is_set():
try:
await self._cycle_func()
except Exception:
log.exception('Error in worker cycle')
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=self.config.INTERVAL_SECONDS)
except TimeoutError:
pass # Время вышло, продолжаем

View File

@@ -358,3 +358,17 @@ class Postgres(DatabaseBase):
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
return list(result.scalars().all()) return list(result.scalars().all())
async def get_active_purchases_with_urls(self) -> list[domain.Purchase]:
stmt = (
select(domain.Purchase)
.where(
domain.Purchase.status == domain.PurchaseStatus.ACTIVE,
domain.Purchase.ad_post_url.isnot(None),
domain.Purchase.views_availability != domain.PostViewsAvailability.MANUAL,
)
.order_by(domain.Purchase.created_at.asc())
)
result = await self.session.execute(stmt)
return list(result.scalars().all())

View File

@@ -1,59 +0,0 @@
import logging
from typing import Any
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from shared.telegram_base import TelegramBase
log = logging.getLogger(__name__)
class Telegram(TelegramBase):
async def send_message(self, text: str, chat_id: int) -> None:
await self.bot.send_message(chat_id=chat_id, text=text)
async def send_message_with_button(self, text: str, chat_id: int, button_text: str, button_url: str) -> None:
keyboard = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=button_text, url=button_url)]])
await self.bot.send_message(chat_id=chat_id, text=text, reply_markup=keyboard)
async def get_chat_member(self, chat_id: int, user_id: int) -> dict[str, Any]:
member = await self.bot.get_chat_member(chat_id=chat_id, user_id=user_id)
return member.model_dump()
async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str:
invite_link = await self.bot.create_chat_invite_link(
chat_id=chat_id,
creates_join_request=requires_approval,
)
return invite_link.invite_link
async def get_post_views(self, post_url: str) -> int | None:
"""
Получить количество просмотров поста по URL.
ЗАГЛУШКА: В текущей реализации возвращает None.
Для реальной работы необходимо:
1. Интегрировать TDLib/MTProto клиент
2. Парсить post_url (формат: t.me/<username>/<message_id> или t.me/c/<chat_id>/<message_id>)
3. Вызывать channels.getMessages через TDLib
4. Извлекать поле views из ответа
Returns:
int | None: Количество просмотров или None если не удалось получить
"""
log.warning(
'get_post_views is not implemented yet (TDLib integration required). URL: %s',
post_url,
)
# TODO: Implement TDLib integration
# Example pseudo-code:
# try:
# username, message_id = parse_telegram_url(post_url)
# channel = await tdlib.resolve_username(username)
# message = await tdlib.get_message(channel.id, message_id)
# return message.views
# except Exception as e:
# log.error('Failed to get post views: %s', e)
# return None
return None

View File

@@ -0,0 +1,25 @@
import logging
from typing import Any
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from shared.telegram_base import TelegramBase
log = logging.getLogger(__name__)
class TelegramBot(TelegramBase):
async def send_message(self, text: str, chat_id: int) -> None:
await self.bot.send_message(chat_id=chat_id, text=text)
async def send_message_with_button(self, text: str, chat_id: int, button_text: str, button_url: str) -> None:
keyboard = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=button_text, url=button_url)]])
await self.bot.send_message(chat_id=chat_id, text=text, reply_markup=keyboard)
async def get_chat_member(self, chat_id: int, user_id: int) -> dict[str, Any]:
member = await self.bot.get_chat_member(chat_id=chat_id, user_id=user_id)
return member.model_dump()
async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str:
invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval)
return invite_link.invite_link

View File

@@ -0,0 +1,99 @@
import logging
import re
from bs4 import BeautifulSoup
from shared.http_parser_base import HttpParserBase, HttpParserConfig
log = logging.getLogger(__name__)
class TelegramParser(HttpParserBase):
def __init__(self, config: HttpParserConfig) -> None:
super().__init__(config)
log.info('TelegramParser initialized (timeout=%s)', config.TIMEOUT)
def _parse_url(self, url: str) -> tuple[str, int] | None:
"""Parse Telegram post URL into username and message_id."""
match = re.match(r'(?:https?://)?t\.me/([\w]+)/(\d+)', url)
if not match:
return None
username, message_id = match.groups()
return username, int(message_id)
def _parse_view_count(self, text: str) -> int | None:
"""Convert view counter text (1.2K, 5.3M) to integer."""
if not text:
return None
text = text.strip().replace(',', '').replace(' ', '')
multipliers = {'K': 1_000, 'M': 1_000_000, 'B': 1_000_000_000}
try:
if text[-1].upper() in multipliers:
return int(float(text[:-1]) * multipliers[text[-1].upper()])
return int(text)
except (ValueError, IndexError):
return None
async def _fetch_from_preview(self, username: str, message_id: int) -> int | None:
url = f'https://t.me/s/{username}/{message_id}'
status_code, html = await self.fetch_html(url)
if status_code != 200:
return None
soup = BeautifulSoup(html, 'lxml')
messages = soup.find_all('div', class_='tgme_widget_message')
for message_div in messages:
data_post = message_div.get('data-post', '')
if isinstance(data_post, str) and data_post.endswith(f'/{message_id}'):
views_span = message_div.find('span', class_='tgme_widget_message_views')
if views_span:
views_text = views_span.get_text(strip=True)
return self._parse_view_count(views_text)
return None
async def _fetch_from_embed(self, username: str, message_id: int) -> int | None:
url = f'https://t.me/{username}/{message_id}?embed=1'
status_code, html = await self.fetch_html(url)
if status_code != 200:
return None
soup = BeautifulSoup(html, 'lxml')
views_span = soup.find('span', class_='tgme_widget_message_views')
if not views_span:
return None
views_text = views_span.get_text(strip=True)
return self._parse_view_count(views_text)
async def get_post_views(self, post_url: str) -> int | None:
parsed = self._parse_url(post_url)
if not parsed:
log.error('Invalid Telegram URL format: %s', post_url)
return None
username, message_id = parsed
try:
views = await self._fetch_from_preview(username, message_id)
if views is not None:
log.info('Fetched views for %s: %d', post_url, views)
return views
views = await self._fetch_from_embed(username, message_id)
if views is not None:
log.info('Fetched views for %s: %d', post_url, views)
return views
log.warning('Unable to extract views from %s', post_url)
return None
except Exception as exc:
log.exception('Failed to fetch %s: %s', post_url, exc.__class__.__name__)
return None

View File

@@ -3,8 +3,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from shared.config_helper import load_settings from shared.config_helper import load_settings
from shared.datebase_base import DatabaseConfig from shared.datebase_base import DatabaseConfig
from shared.http_parser_base import HttpParserConfig
from shared.logger import LoggerConfig from shared.logger import LoggerConfig
from shared.telegram_base import TelegramConfig from shared.telegram_base import TelegramConfig
from shared.worker_base import WorkerConfig
from src.adapter.jwt import JWTConfig from src.adapter.jwt import JWTConfig
@@ -19,7 +21,10 @@ class Settings(BaseSettings):
db: DatabaseConfig db: DatabaseConfig
logger: LoggerConfig logger: LoggerConfig
telegram: TelegramConfig telegram: TelegramConfig
telegram_parser: HttpParserConfig
jwt: JWTConfig jwt: JWTConfig
fetch_views_worker: WorkerConfig
settings: Settings = load_settings(Settings) settings: Settings = load_settings(Settings)

View File

@@ -33,9 +33,9 @@ async def get_views_history(
async def fetch_views( async def fetch_views(
purchase_id: uuid.UUID, purchase_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)], current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.FetchViewsOutput: ) -> dto.FetchViewsManuallyOutput:
"""Получить актуальные просмотры через Telegram API.""" """Получить актуальные просмотры через Telegram API."""
input_data = dto.FetchViewsInput( input_data = dto.FetchViewsInputManually(
purchase_id=purchase_id, purchase_id=purchase_id,
user_id=current_user.user_id, user_id=current_user.user_id,
) )

View File

@@ -0,0 +1,15 @@
import logging
from typing import TYPE_CHECKING
from shared.worker_base import WorkerBase
from src import dependencies
if TYPE_CHECKING:
pass
log = logging.getLogger(__name__)
class FetchViewsWorker(WorkerBase):
async def _cycle_func(self) -> None:
await dependencies.get_usecase().run_views_worker_cycle(self.config.INTERVAL_SECONDS)

View File

@@ -34,8 +34,8 @@ __all__ = (
'ViewsSnapshotOutput', 'ViewsSnapshotOutput',
'GetViewsHistoryInput', 'GetViewsHistoryInput',
'GetViewsHistoryOutput', 'GetViewsHistoryOutput',
'FetchViewsInput', 'FetchViewsInputManually',
'FetchViewsOutput', 'FetchViewsManuallyOutput',
'UpdateViewsManuallyInput', 'UpdateViewsManuallyInput',
) )
@@ -79,8 +79,8 @@ from .target_channel import (
from .telegram_login import TelegramLoginInput from .telegram_login import TelegramLoginInput
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
from .views import ( from .views import (
FetchViewsInput, FetchViewsInputManually,
FetchViewsOutput, FetchViewsManuallyOutput,
GetViewsHistoryInput, GetViewsHistoryInput,
GetViewsHistoryOutput, GetViewsHistoryOutput,
UpdateViewsManuallyInput, UpdateViewsManuallyInput,

View File

@@ -31,16 +31,14 @@ class GetViewsHistoryOutput(pydantic.BaseModel):
snapshots: list[ViewsSnapshotOutput] snapshots: list[ViewsSnapshotOutput]
class FetchViewsInput(pydantic.BaseModel): class FetchViewsInputManually(pydantic.BaseModel):
"""Запросить обновление просмотров для закупа.""" """Запросить обновление просмотров для закупа."""
purchase_id: uuid.UUID purchase_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
class FetchViewsOutput(pydantic.BaseModel): class FetchViewsManuallyOutput(pydantic.BaseModel):
"""Результат получения просмотров."""
purchase_id: uuid.UUID purchase_id: uuid.UUID
views_count: int | None # None если не удалось получить views_count: int | None # None если не удалось получить
views_availability: domain.PostViewsAvailability views_availability: domain.PostViewsAvailability

View File

@@ -7,10 +7,12 @@ from shared import logger
from src import dependencies from src import dependencies
from src.adapter.jwt import JWT from src.adapter.jwt import JWT
from src.adapter.postgres import Postgres from src.adapter.postgres import Postgres
from src.adapter.telegram import Telegram from src.adapter.telegram_bot import TelegramBot
from src.adapter.telegram_parser import TelegramParser
from src.config import settings from src.config import settings
from src.controller.http import api_router from src.controller.http import api_router
from src.controller.telegram_callback import telegram_callback_router from src.controller.telegram_callback import telegram_callback_router
from src.controller.worker.views_worker import FetchViewsWorker
from src.usecase import Usecase from src.usecase import Usecase
@@ -18,9 +20,11 @@ from src.usecase import Usecase
async def lifespan(app: FastAPI) -> AsyncGenerator[None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
await postgres.connect() await postgres.connect()
await telegram.start_polling() await telegram.start_polling()
await views_worker.start()
yield yield
await views_worker.stop()
await telegram.stop_polling() await telegram.stop_polling()
await postgres.close() await postgres.close()
@@ -29,15 +33,19 @@ logger.init(settings.logger)
# Dependencies # Dependencies
postgres = Postgres(settings.db) postgres = Postgres(settings.db)
telegram = Telegram(settings.telegram, telegram_callback_router) telegram = TelegramBot(settings.telegram, telegram_callback_router)
telegram_views_fetcher = TelegramParser(settings.telegram_parser)
jwt_encoder = JWT(settings.jwt) jwt_encoder = JWT(settings.jwt)
usecase = Usecase( usecase = Usecase(
database=postgres, database=postgres,
telegram_writer=telegram, telegram_bot=telegram,
telegram_parser=telegram_views_fetcher,
jwt_encoder=jwt_encoder, jwt_encoder=jwt_encoder,
) )
dependencies.set_usecase(usecase) dependencies.set_usecase(usecase)
views_worker = FetchViewsWorker(config=settings.fetch_views_worker)
app = FastAPI(lifespan=lifespan, version=settings.logger.APP_VERSION) app = FastAPI(lifespan=lifespan, version=settings.logger.APP_VERSION)
app.include_router(api_router) app.include_router(api_router)

View File

@@ -28,8 +28,9 @@ from .target_channel.get_user_target_chans import get_user_target_chans
from .target_channel.update_target_chan_permissions import update_target_chan_permissions from .target_channel.update_target_chan_permissions import update_target_chan_permissions
from .telegram_login import telegram_login from .telegram_login import telegram_login
from .validate_login_token import validate_login_token from .validate_login_token import validate_login_token
from .views.fetch_views import fetch_views from .views.fetch_views_manually import fetch_views_manually
from .views.get_views_history import get_views_history from .views.get_views_history import get_views_history
from .views.run_views_worker_cycle import run_views_worker_cycle
from .views.update_views_manually import update_views_manually from .views.update_views_manually import update_views_manually
@@ -132,8 +133,10 @@ class Database(typing.Protocol):
to_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None,
) -> list[domain.ViewsSnapshot]: ... ) -> list[domain.ViewsSnapshot]: ...
async def get_active_purchases_with_urls(self) -> list[domain.Purchase]: ... # Для worker
class TelegramWriter(typing.Protocol):
class TelegramBotWriter(typing.Protocol):
async def send_message(self, text: str, chat_id: int) -> None: ... async def send_message(self, text: str, chat_id: int) -> None: ...
async def send_message_with_button(self, text: str, chat_id: int, button_text: str, button_url: str) -> None: ... async def send_message_with_button(self, text: str, chat_id: int, button_text: str, button_url: str) -> None: ...
@@ -142,7 +145,9 @@ class TelegramWriter(typing.Protocol):
async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: ... async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: ...
async def get_post_views(self, post_url: str) -> int | None: ... # None если не удалось получить
class TelegramParser(typing.Protocol):
async def get_post_views(self, post_url: str) -> int | None: ...
class JWTEncoder(typing.Protocol): class JWTEncoder(typing.Protocol):
@@ -152,7 +157,8 @@ class JWTEncoder(typing.Protocol):
@dataclass @dataclass
class Usecase: class Usecase:
database: Database database: Database
telegram_writer: TelegramWriter telegram_bot: TelegramBotWriter
telegram_parser: TelegramParser
jwt_encoder: JWTEncoder jwt_encoder: JWTEncoder
validate_login_token = validate_login_token validate_login_token = validate_login_token
@@ -178,6 +184,7 @@ class Usecase:
update_purchase = update_purchase update_purchase = update_purchase
delete_purchase = delete_purchase delete_purchase = delete_purchase
handle_subscription = handle_subscription handle_subscription = handle_subscription
fetch_views = fetch_views fetch_views = fetch_views_manually
run_views_worker_cycle = run_views_worker_cycle
get_views_history = get_views_history get_views_history = get_views_history
update_views_manually = update_views_manually update_views_manually = update_views_manually

View File

@@ -36,7 +36,7 @@ async def create_purchase(self: 'Usecase', input: dto.CreatePurchaseInput, user_
# Создаём уникальную пригласительную ссылку через Telegram Bot API # Создаём уникальную пригласительную ссылку через Telegram Bot API
requires_approval = input.invite_link_type == domain.InviteLinkType.APPROVAL requires_approval = input.invite_link_type == domain.InviteLinkType.APPROVAL
invite_link = await self.telegram_writer.create_chat_invite_link( invite_link = await self.telegram_bot.create_chat_invite_link(
chat_id=target_channel.telegram_id, chat_id=target_channel.telegram_id,
requires_approval=requires_approval, requires_approval=requires_approval,
) )

View File

@@ -14,7 +14,7 @@ async def connect_target_chan(self: 'Usecase', input: dto.ConnectTargetChanInput
if not permissions.is_admin: if not permissions.is_admin:
log.warning(f'Bot is not admin in channel {input.telegram_id}. Attempted by user {input.user_telegram_id}') log.warning(f'Bot is not admin in channel {input.telegram_id}. Attempted by user {input.user_telegram_id}')
await self.telegram_writer.send_message( await self.telegram_bot.send_message(
f'⚠️ Бот был добавлен в канал "{input.title}", но не является админом.\n\n' f'⚠️ Бот был добавлен в канал "{input.title}", но не является админом.\n\n'
'Пожалуйста, сделайте бота администратором канала.', 'Пожалуйста, сделайте бота администратором канала.',
chat_id=input.user_telegram_id, chat_id=input.user_telegram_id,
@@ -33,7 +33,7 @@ async def connect_target_chan(self: 'Usecase', input: dto.ConnectTargetChanInput
f'Attempted by user {input.user_telegram_id}' f'Attempted by user {input.user_telegram_id}'
) )
permissions_text = '\n'.join(f'{p}' for p in missing_permissions) permissions_text = '\n'.join(f'{p}' for p in missing_permissions)
await self.telegram_writer.send_message( await self.telegram_bot.send_message(
f'⚠️ Бот был добавлен в канал "{input.title}", но не имеет необходимых прав.\n\n' f'⚠️ Бот был добавлен в канал "{input.title}", но не имеет необходимых прав.\n\n'
f'Отсутствующие права:\n{permissions_text}\n\n' f'Отсутствующие права:\n{permissions_text}\n\n'
'Пожалуйста, предоставьте эти права боту в настройках канала.', 'Пожалуйста, предоставьте эти права боту в настройках канала.',
@@ -45,7 +45,7 @@ async def connect_target_chan(self: 'Usecase', input: dto.ConnectTargetChanInput
user = await self.database.get_user(telegram_id=input.user_telegram_id) user = await self.database.get_user(telegram_id=input.user_telegram_id)
if not user: if not user:
log.warning(f'User {input.user_telegram_id} not found when trying to connect channel {input.telegram_id}') log.warning(f'User {input.user_telegram_id} not found when trying to connect channel {input.telegram_id}')
await self.telegram_writer.send_message( await self.telegram_bot.send_message(
f'⚠️ Канал "{input.title}" не может быть подключен.\n\n' f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
'Вы должны сначала авторизоваться в веб-панели перед подключением каналов.', 'Вы должны сначала авторизоваться в веб-панели перед подключением каналов.',
chat_id=input.user_telegram_id, chat_id=input.user_telegram_id,
@@ -63,7 +63,7 @@ async def connect_target_chan(self: 'Usecase', input: dto.ConnectTargetChanInput
upserted_channel = await self.database.upsert_target_channel(channel) upserted_channel = await self.database.upsert_target_channel(channel)
log.info(f'Channel {input.telegram_id} connected/updated successfully by user {input.user_telegram_id}') log.info(f'Channel {input.telegram_id} connected/updated successfully by user {input.user_telegram_id}')
await self.telegram_writer.send_message( await self.telegram_bot.send_message(
f'✅ Канал "{input.title}" успешно подключен!', f'✅ Канал "{input.title}" успешно подключен!',
chat_id=input.user_telegram_id, chat_id=input.user_telegram_id,
) )

View File

@@ -33,7 +33,7 @@ async def update_target_chan_permissions(self: 'Usecase', input: dto.UpdateTarge
channel.is_active = True channel.is_active = True
await self.database.update_target_channel(channel) await self.database.update_target_channel(channel)
await self.telegram_writer.send_message( await self.telegram_bot.send_message(
f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!', f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!',
chat_id=input.user_telegram_id, chat_id=input.user_telegram_id,
) )
@@ -45,7 +45,7 @@ async def update_target_chan_permissions(self: 'Usecase', input: dto.UpdateTarge
await self.database.update_target_channel(channel) await self.database.update_target_channel(channel)
missed_permissions = '\n'.join(f'{p}' for p in missing_permissions) missed_permissions = '\n'.join(f'{p}' for p in missing_permissions)
await self.telegram_writer.send_message( await self.telegram_bot.send_message(
f'⚠️ Канал "{input.chat_title}" был деактивирован.\ f'⚠️ Канал "{input.chat_title}" был деактивирован.\
\n\nБоту убрали необходимые права:\n{missed_permissions}', \n\nБоту убрали необходимые права:\n{missed_permissions}',
chat_id=input.user_telegram_id, chat_id=input.user_telegram_id,

View File

@@ -28,7 +28,7 @@ async def telegram_login(self: 'Usecase', input: dto.TelegramLoginInput) -> None
login_url = f'{settings.app.URL}/auth/complete?token={token}' login_url = f'{settings.app.URL}/auth/complete?token={token}'
username_display = f'@{user.username}' if user.username else 'пользователь' username_display = f'@{user.username}' if user.username else 'пользователь'
await self.telegram_writer.send_message_with_button( await self.telegram_bot.send_message_with_button(
f'Привет, {username_display}!\n\nНажми кнопку ниже для входа на сайт.', f'Привет, {username_display}!\n\nНажми кнопку ниже для входа на сайт.',
chat_id=input.chat_id, chat_id=input.chat_id,
button_text='Войти на сайт', button_text='Войти на сайт',

View File

@@ -10,21 +10,14 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
async def fetch_views(self: 'Usecase', input: dto.FetchViewsInput) -> dto.FetchViewsOutput: async def fetch_views_manually(self: 'Usecase', input: dto.FetchViewsInputManually) -> dto.FetchViewsManuallyOutput:
"""
Получить просмотры для закупа через Telegram API.
Создаёт снимок просмотров и обновляет метрики закупа.
"""
async with self.database.transaction(): async with self.database.transaction():
purchase = await self.database.get_purchase(input.user_id, input.purchase_id) purchase = await self.database.get_purchase(input.user_id, input.purchase_id)
if not purchase: if not purchase:
log.warning('Purchase %s not found for user %s', input.purchase_id, input.user_id)
raise domain.PurchaseNotFound(input.purchase_id) raise domain.PurchaseNotFound(input.purchase_id)
if not purchase.ad_post_url: if not purchase.ad_post_url:
log.warning('Purchase %s has no ad_post_url', input.purchase_id) return dto.FetchViewsManuallyOutput(
return dto.FetchViewsOutput(
purchase_id=purchase.id, purchase_id=purchase.id,
views_count=None, views_count=None,
views_availability=domain.PostViewsAvailability.UNAVAILABLE, views_availability=domain.PostViewsAvailability.UNAVAILABLE,
@@ -32,19 +25,15 @@ async def fetch_views(self: 'Usecase', input: dto.FetchViewsInput) -> dto.FetchV
error_message='No ad_post_url specified', error_message='No ad_post_url specified',
) )
# Попытка получить просмотры через Telegram API views_count = await self.telegram_parser.get_post_views(purchase.ad_post_url)
views_count = await self.telegram_writer.get_post_views(purchase.ad_post_url)
fetched_at = datetime.datetime.now(datetime.UTC) fetched_at = datetime.datetime.now(datetime.UTC)
if views_count is not None: if views_count:
# Успешно получены просмотры
purchase.views_count = views_count purchase.views_count = views_count
purchase.views_availability = domain.PostViewsAvailability.AVAILABLE purchase.views_availability = domain.PostViewsAvailability.AVAILABLE
purchase.last_views_fetch_at = fetched_at purchase.last_views_fetch_at = fetched_at
await self.database.update_purchase(purchase) await self.database.update_purchase(purchase)
# Создаём снимок
snapshot = domain.ViewsSnapshot( snapshot = domain.ViewsSnapshot(
purchase_id=purchase.id, purchase_id=purchase.id,
views_count=views_count, views_count=views_count,
@@ -54,21 +43,20 @@ async def fetch_views(self: 'Usecase', input: dto.FetchViewsInput) -> dto.FetchV
log.info('Views fetched for purchase %s: %d', purchase.id, views_count) log.info('Views fetched for purchase %s: %d', purchase.id, views_count)
return dto.FetchViewsOutput( return dto.FetchViewsManuallyOutput(
purchase_id=purchase.id, purchase_id=purchase.id,
views_count=views_count, views_count=views_count,
views_availability=domain.PostViewsAvailability.AVAILABLE, views_availability=domain.PostViewsAvailability.AVAILABLE,
fetched_at=fetched_at, fetched_at=fetched_at,
) )
else:
# Не удалось получить просмотры
purchase.views_availability = domain.PostViewsAvailability.UNAVAILABLE purchase.views_availability = domain.PostViewsAvailability.UNAVAILABLE
purchase.last_views_fetch_at = fetched_at purchase.last_views_fetch_at = fetched_at
await self.database.update_purchase(purchase) await self.database.update_purchase(purchase)
log.warning('Failed to fetch views for purchase %s', purchase.id) log.warning('Failed to fetch views for purchase %s', purchase.id)
return dto.FetchViewsOutput( return dto.FetchViewsManuallyOutput(
purchase_id=purchase.id, purchase_id=purchase.id,
views_count=None, views_count=None,
views_availability=domain.PostViewsAvailability.UNAVAILABLE, views_availability=domain.PostViewsAvailability.UNAVAILABLE,

View File

@@ -0,0 +1,65 @@
import asyncio
import datetime
import logging
from typing import TYPE_CHECKING, cast
from aiolimiter import AsyncLimiter
from src import domain
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def _process_purchase(self: 'Usecase', purchase_id, user_id, ad_post_url: str):
views_count = await self.telegram_parser.get_post_views(ad_post_url)
fetched_at = datetime.datetime.now(datetime.UTC)
async with self.database.transaction():
p = await self.database.get_purchase(user_id=user_id, purchase_id=purchase_id)
if not p:
log.error('Purchase %s not found', purchase_id)
return
p.last_views_fetch_at = fetched_at
if not views_count:
p.views_availability = domain.PostViewsAvailability.UNAVAILABLE
await self.database.update_purchase(p)
log.warning('Failed to fetch views for %s', purchase_id)
return
p.views_count = views_count
p.views_availability = domain.PostViewsAvailability.AVAILABLE
await self.database.update_purchase(p)
snapshot = domain.ViewsSnapshot(
purchase_id=purchase_id,
views_count=views_count,
fetched_at=fetched_at,
)
await self.database.create_views_snapshot(snapshot)
log.info('Updated purchase %s: %d', purchase_id, views_count)
async def run_views_worker_cycle(self: 'Usecase', interval_seconds: int) -> None:
async with self.database.transaction():
purchases = await self.database.get_active_purchases_with_urls()
if not purchases:
log.debug('ViewsWorkerCycle: No purchases to process')
return
limiter = AsyncLimiter(max_rate=len(purchases), time_period=interval_seconds)
log.info('Processing %d purchases for %d seconds', len(purchases), interval_seconds)
async def process(p):
async with limiter:
try:
await _process_purchase(self, p.id, p.user_id, cast(str, p.ad_post_url))
except Exception:
log.exception('Error updating views for %s', p.id)
await asyncio.gather(*(process(p) for p in purchases))

View File

@@ -11,7 +11,6 @@ log = logging.getLogger(__name__)
async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyInput) -> dto.PurchaseOutput: async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyInput) -> dto.PurchaseOutput:
"""Ручное обновление просмотров для закупа."""
async with self.database.transaction(): async with self.database.transaction():
purchase = await self.database.get_purchase(input.user_id, input.purchase_id) purchase = await self.database.get_purchase(input.user_id, input.purchase_id)
if not purchase: if not purchase:
@@ -20,13 +19,11 @@ async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyI
fetched_at = datetime.datetime.now(datetime.UTC) fetched_at = datetime.datetime.now(datetime.UTC)
# Обновляем метрики закупа
purchase.views_count = input.views_count purchase.views_count = input.views_count
purchase.views_availability = domain.PostViewsAvailability.MANUAL purchase.views_availability = domain.PostViewsAvailability.MANUAL
purchase.last_views_fetch_at = fetched_at purchase.last_views_fetch_at = fetched_at
await self.database.update_purchase(purchase) await self.database.update_purchase(purchase)
# Создаём снимок
snapshot = domain.ViewsSnapshot( snapshot = domain.ViewsSnapshot(
purchase_id=purchase.id, purchase_id=purchase.id,
views_count=input.views_count, views_count=input.views_count,
@@ -36,7 +33,6 @@ async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyI
log.info('Views manually updated for purchase %s: %d', purchase.id, input.views_count) log.info('Views manually updated for purchase %s: %d', purchase.id, input.views_count)
# Возвращаем обновлённый закуп
return dto.PurchaseOutput( return dto.PurchaseOutput(
id=purchase.id, id=purchase.id,
target_channel_id=purchase.target_channel_id, target_channel_id=purchase.target_channel_id,

View File

@@ -367,4 +367,4 @@ def mock_jwt() -> MockJWTEncoder:
def usecases( def usecases(
mock_database: MockDatabase, mock_telegram: MockTelegramWriter, mock_jwt: MockJWTEncoder mock_database: MockDatabase, mock_telegram: MockTelegramWriter, mock_jwt: MockJWTEncoder
) -> usecase.Usecase: ) -> usecase.Usecase:
return usecase.Usecase(database=mock_database, telegram_writer=mock_telegram, jwt_encoder=mock_jwt) return usecase.Usecase(database=mock_database, telegram_bot=mock_telegram, jwt_encoder=mock_jwt)

View File

@@ -55,7 +55,7 @@ async def test_fetch_views_no_ad_post_url(usecases: usecase.Usecase, mock_databa
) )
await mock_database.create_purchase(purchase) await mock_database.create_purchase(purchase)
input_data = dto.FetchViewsInput( input_data = dto.FetchViewsInputManually(
purchase_id=purchase.id, purchase_id=purchase.id,
user_id=user.id, user_id=user.id,
) )
@@ -114,7 +114,7 @@ async def test_fetch_views_api_unavailable(usecases: usecase.Usecase, mock_datab
) )
await mock_database.create_purchase(purchase) await mock_database.create_purchase(purchase)
input_data = dto.FetchViewsInput( input_data = dto.FetchViewsInputManually(
purchase_id=purchase.id, purchase_id=purchase.id,
user_id=user.id, user_id=user.id,
) )

129
uv.lock generated
View File

@@ -71,6 +71,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" },
] ]
[[package]]
name = "aiolimiter"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/23/b52debf471f7a1e42e362d959a3982bdcb4fe13a5d46e63d28868807a79c/aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9", size = 7185, upload-time = "2024-12-08T15:31:51.496Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload-time = "2024-12-08T15:31:49.874Z" },
]
[[package]] [[package]]
name = "aiosignal" name = "aiosignal"
version = "1.4.0" version = "1.4.0"
@@ -153,6 +162,19 @@ 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" }, { 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 = "beautifulsoup4"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.10.5" version = "2025.10.5"
@@ -308,6 +330,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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.11"
@@ -326,6 +376,68 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "lxml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
]
[[package]] [[package]]
name = "magic-filter" name = "magic-filter"
version = "1.0.12" version = "1.0.12"
@@ -767,6 +879,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
] ]
[[package]]
name = "soupsieve"
version = "2.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.44" version = "2.0.44"
@@ -811,8 +932,12 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiogram" }, { name = "aiogram" },
{ name = "aiolimiter" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "beautifulsoup4" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" },
{ name = "lxml" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "python-multipart" }, { name = "python-multipart" },
@@ -833,8 +958,12 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiogram", specifier = ">=3.16.0" }, { name = "aiogram", specifier = ">=3.16.0" },
{ name = "aiolimiter", specifier = ">=1.2.1" },
{ name = "asyncpg", specifier = ">=0.30.0" }, { name = "asyncpg", specifier = ">=0.30.0" },
{ name = "beautifulsoup4", specifier = ">=4.14.2" },
{ name = "fastapi", specifier = ">=0.121.0" }, { name = "fastapi", specifier = ">=0.121.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "lxml", specifier = ">=6.0.2" },
{ name = "pydantic-settings", specifier = ">=2.11.0" }, { name = "pydantic-settings", specifier = ">=2.11.0" },
{ name = "pyjwt", specifier = ">=2.10.1" }, { name = "pyjwt", specifier = ">=2.10.1" },
{ name = "python-multipart", specifier = ">=0.0.20" }, { name = "python-multipart", specifier = ">=0.0.20" },