feat: fetch views manually and worker
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -125,5 +125,5 @@ cython_debug/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
docker-compose.yml
|
||||
.uv_cache/
|
||||
@@ -87,6 +87,7 @@ The codebase follows a clean architecture pattern with clear separation of conce
|
||||
- Each use case is a standalone function
|
||||
- Depends on Protocol interfaces (Database, TelegramWriter, JWTEncoder)
|
||||
- Use cases are assembled in the `Usecase` dataclass for dependency injection
|
||||
- Один use case отвечает за полноценный сценарий «от триггера до завершения» и не должен вызывать другие use case'ы напрямую (общую логику выносить в хелперы/сервисы внутри того же модуля)
|
||||
|
||||
3. **Adapter Layer** (`src/adapter/`)
|
||||
- Concrete implementations of protocol interfaces:
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
GIT_COMMIT: ${GIT_COMMIT:-unknown}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
- postgres
|
||||
# backend:
|
||||
# build:
|
||||
# context: .
|
||||
# args:
|
||||
# GIT_COMMIT: ${GIT_COMMIT:-unknown}
|
||||
# env_file:
|
||||
# - .env
|
||||
# ports:
|
||||
# - "8000:8000"
|
||||
# depends_on:
|
||||
# - postgres
|
||||
|
||||
postgres:
|
||||
image: postgres
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: "user"
|
||||
POSTGRES_PASSWORD: "password"
|
||||
POSTGRES_DB: "tgex"
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
21
docker-compose.yml.example
Normal file
21
docker-compose.yml.example
Normal 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"
|
||||
@@ -6,8 +6,12 @@ readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aiogram>=3.16.0",
|
||||
"aiolimiter>=1.2.1",
|
||||
"asyncpg>=0.30.0",
|
||||
"beautifulsoup4>=4.14.2",
|
||||
"fastapi>=0.121.0",
|
||||
"httpx>=0.28.1",
|
||||
"lxml>=6.0.2",
|
||||
"pydantic-settings>=2.11.0",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-multipart>=0.0.20",
|
||||
|
||||
111
shared/http_parser_base.py
Normal file
111
shared/http_parser_base.py
Normal 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
65
shared/worker_base.py
Normal 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 # Время вышло, продолжаем
|
||||
@@ -358,3 +358,17 @@ class Postgres(DatabaseBase):
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
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())
|
||||
|
||||
@@ -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
|
||||
25
src/adapter/telegram_bot.py
Normal file
25
src/adapter/telegram_bot.py
Normal 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
|
||||
99
src/adapter/telegram_parser.py
Normal file
99
src/adapter/telegram_parser.py
Normal 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
|
||||
@@ -3,8 +3,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from shared.config_helper import load_settings
|
||||
from shared.datebase_base import DatabaseConfig
|
||||
from shared.http_parser_base import HttpParserConfig
|
||||
from shared.logger import LoggerConfig
|
||||
from shared.telegram_base import TelegramConfig
|
||||
from shared.worker_base import WorkerConfig
|
||||
from src.adapter.jwt import JWTConfig
|
||||
|
||||
|
||||
@@ -19,7 +21,10 @@ class Settings(BaseSettings):
|
||||
db: DatabaseConfig
|
||||
logger: LoggerConfig
|
||||
telegram: TelegramConfig
|
||||
telegram_parser: HttpParserConfig
|
||||
jwt: JWTConfig
|
||||
|
||||
fetch_views_worker: WorkerConfig
|
||||
|
||||
|
||||
settings: Settings = load_settings(Settings)
|
||||
|
||||
@@ -33,9 +33,9 @@ async def get_views_history(
|
||||
async def fetch_views(
|
||||
purchase_id: uuid.UUID,
|
||||
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
|
||||
) -> dto.FetchViewsOutput:
|
||||
) -> dto.FetchViewsManuallyOutput:
|
||||
"""Получить актуальные просмотры через Telegram API."""
|
||||
input_data = dto.FetchViewsInput(
|
||||
input_data = dto.FetchViewsInputManually(
|
||||
purchase_id=purchase_id,
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
|
||||
15
src/controller/worker/views_worker.py
Normal file
15
src/controller/worker/views_worker.py
Normal 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)
|
||||
@@ -34,8 +34,8 @@ __all__ = (
|
||||
'ViewsSnapshotOutput',
|
||||
'GetViewsHistoryInput',
|
||||
'GetViewsHistoryOutput',
|
||||
'FetchViewsInput',
|
||||
'FetchViewsOutput',
|
||||
'FetchViewsInputManually',
|
||||
'FetchViewsManuallyOutput',
|
||||
'UpdateViewsManuallyInput',
|
||||
)
|
||||
|
||||
@@ -79,8 +79,8 @@ from .target_channel import (
|
||||
from .telegram_login import TelegramLoginInput
|
||||
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
|
||||
from .views import (
|
||||
FetchViewsInput,
|
||||
FetchViewsOutput,
|
||||
FetchViewsInputManually,
|
||||
FetchViewsManuallyOutput,
|
||||
GetViewsHistoryInput,
|
||||
GetViewsHistoryOutput,
|
||||
UpdateViewsManuallyInput,
|
||||
|
||||
@@ -31,16 +31,14 @@ class GetViewsHistoryOutput(pydantic.BaseModel):
|
||||
snapshots: list[ViewsSnapshotOutput]
|
||||
|
||||
|
||||
class FetchViewsInput(pydantic.BaseModel):
|
||||
class FetchViewsInputManually(pydantic.BaseModel):
|
||||
"""Запросить обновление просмотров для закупа."""
|
||||
|
||||
purchase_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
|
||||
|
||||
class FetchViewsOutput(pydantic.BaseModel):
|
||||
"""Результат получения просмотров."""
|
||||
|
||||
class FetchViewsManuallyOutput(pydantic.BaseModel):
|
||||
purchase_id: uuid.UUID
|
||||
views_count: int | None # None если не удалось получить
|
||||
views_availability: domain.PostViewsAvailability
|
||||
|
||||
14
src/main.py
14
src/main.py
@@ -7,10 +7,12 @@ from shared import logger
|
||||
from src import dependencies
|
||||
from src.adapter.jwt import JWT
|
||||
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.controller.http import api_router
|
||||
from src.controller.telegram_callback import telegram_callback_router
|
||||
from src.controller.worker.views_worker import FetchViewsWorker
|
||||
from src.usecase import Usecase
|
||||
|
||||
|
||||
@@ -18,9 +20,11 @@ from src.usecase import Usecase
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
||||
await postgres.connect()
|
||||
await telegram.start_polling()
|
||||
await views_worker.start()
|
||||
|
||||
yield
|
||||
|
||||
await views_worker.stop()
|
||||
await telegram.stop_polling()
|
||||
await postgres.close()
|
||||
|
||||
@@ -29,15 +33,19 @@ logger.init(settings.logger)
|
||||
|
||||
# Dependencies
|
||||
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)
|
||||
|
||||
usecase = Usecase(
|
||||
database=postgres,
|
||||
telegram_writer=telegram,
|
||||
telegram_bot=telegram,
|
||||
telegram_parser=telegram_views_fetcher,
|
||||
jwt_encoder=jwt_encoder,
|
||||
)
|
||||
dependencies.set_usecase(usecase)
|
||||
|
||||
views_worker = FetchViewsWorker(config=settings.fetch_views_worker)
|
||||
|
||||
app = FastAPI(lifespan=lifespan, version=settings.logger.APP_VERSION)
|
||||
app.include_router(api_router)
|
||||
|
||||
@@ -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 .telegram_login import telegram_login
|
||||
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.run_views_worker_cycle import run_views_worker_cycle
|
||||
from .views.update_views_manually import update_views_manually
|
||||
|
||||
|
||||
@@ -132,8 +133,10 @@ class Database(typing.Protocol):
|
||||
to_date: datetime.datetime | None = None,
|
||||
) -> 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_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 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):
|
||||
@@ -152,7 +157,8 @@ class JWTEncoder(typing.Protocol):
|
||||
@dataclass
|
||||
class Usecase:
|
||||
database: Database
|
||||
telegram_writer: TelegramWriter
|
||||
telegram_bot: TelegramBotWriter
|
||||
telegram_parser: TelegramParser
|
||||
jwt_encoder: JWTEncoder
|
||||
|
||||
validate_login_token = validate_login_token
|
||||
@@ -178,6 +184,7 @@ class Usecase:
|
||||
update_purchase = update_purchase
|
||||
delete_purchase = delete_purchase
|
||||
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
|
||||
update_views_manually = update_views_manually
|
||||
|
||||
@@ -36,7 +36,7 @@ async def create_purchase(self: 'Usecase', input: dto.CreatePurchaseInput, user_
|
||||
|
||||
# Создаём уникальную пригласительную ссылку через Telegram Bot API
|
||||
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,
|
||||
requires_approval=requires_approval,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ async def connect_target_chan(self: 'Usecase', input: dto.ConnectTargetChanInput
|
||||
|
||||
if not permissions.is_admin:
|
||||
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'
|
||||
'Пожалуйста, сделайте бота администратором канала.',
|
||||
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}'
|
||||
)
|
||||
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'Отсутствующие права:\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)
|
||||
if not user:
|
||||
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'
|
||||
'Вы должны сначала авторизоваться в веб-панели перед подключением каналов.',
|
||||
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)
|
||||
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}" успешно подключен!',
|
||||
chat_id=input.user_telegram_id,
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ async def update_target_chan_permissions(self: 'Usecase', input: dto.UpdateTarge
|
||||
channel.is_active = True
|
||||
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Все необходимые права боту предоставлены!',
|
||||
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)
|
||||
|
||||
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}" был деактивирован.\
|
||||
\n\nБоту убрали необходимые права:\n{missed_permissions}',
|
||||
chat_id=input.user_telegram_id,
|
||||
|
||||
@@ -28,7 +28,7 @@ async def telegram_login(self: 'Usecase', input: dto.TelegramLoginInput) -> None
|
||||
login_url = f'{settings.app.URL}/auth/complete?token={token}'
|
||||
|
||||
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Нажми кнопку ниже для входа на сайт.',
|
||||
chat_id=input.chat_id,
|
||||
button_text='Войти на сайт',
|
||||
|
||||
@@ -10,21 +10,14 @@ if TYPE_CHECKING:
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def fetch_views(self: 'Usecase', input: dto.FetchViewsInput) -> dto.FetchViewsOutput:
|
||||
"""
|
||||
Получить просмотры для закупа через Telegram API.
|
||||
|
||||
Создаёт снимок просмотров и обновляет метрики закупа.
|
||||
"""
|
||||
async def fetch_views_manually(self: 'Usecase', input: dto.FetchViewsInputManually) -> dto.FetchViewsManuallyOutput:
|
||||
async with self.database.transaction():
|
||||
purchase = await self.database.get_purchase(input.user_id, input.purchase_id)
|
||||
if not purchase:
|
||||
log.warning('Purchase %s not found for user %s', input.purchase_id, input.user_id)
|
||||
raise domain.PurchaseNotFound(input.purchase_id)
|
||||
|
||||
if not purchase.ad_post_url:
|
||||
log.warning('Purchase %s has no ad_post_url', input.purchase_id)
|
||||
return dto.FetchViewsOutput(
|
||||
return dto.FetchViewsManuallyOutput(
|
||||
purchase_id=purchase.id,
|
||||
views_count=None,
|
||||
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',
|
||||
)
|
||||
|
||||
# Попытка получить просмотры через Telegram API
|
||||
views_count = await self.telegram_writer.get_post_views(purchase.ad_post_url)
|
||||
|
||||
views_count = await self.telegram_parser.get_post_views(purchase.ad_post_url)
|
||||
fetched_at = datetime.datetime.now(datetime.UTC)
|
||||
|
||||
if views_count is not None:
|
||||
# Успешно получены просмотры
|
||||
if views_count:
|
||||
purchase.views_count = views_count
|
||||
purchase.views_availability = domain.PostViewsAvailability.AVAILABLE
|
||||
purchase.last_views_fetch_at = fetched_at
|
||||
await self.database.update_purchase(purchase)
|
||||
|
||||
# Создаём снимок
|
||||
snapshot = domain.ViewsSnapshot(
|
||||
purchase_id=purchase.id,
|
||||
views_count=views_count,
|
||||
@@ -54,24 +43,23 @@ async def fetch_views(self: 'Usecase', input: dto.FetchViewsInput) -> dto.FetchV
|
||||
|
||||
log.info('Views fetched for purchase %s: %d', purchase.id, views_count)
|
||||
|
||||
return dto.FetchViewsOutput(
|
||||
return dto.FetchViewsManuallyOutput(
|
||||
purchase_id=purchase.id,
|
||||
views_count=views_count,
|
||||
views_availability=domain.PostViewsAvailability.AVAILABLE,
|
||||
fetched_at=fetched_at,
|
||||
)
|
||||
else:
|
||||
# Не удалось получить просмотры
|
||||
purchase.views_availability = domain.PostViewsAvailability.UNAVAILABLE
|
||||
purchase.last_views_fetch_at = fetched_at
|
||||
await self.database.update_purchase(purchase)
|
||||
|
||||
log.warning('Failed to fetch views for purchase %s', purchase.id)
|
||||
purchase.views_availability = domain.PostViewsAvailability.UNAVAILABLE
|
||||
purchase.last_views_fetch_at = fetched_at
|
||||
await self.database.update_purchase(purchase)
|
||||
|
||||
return dto.FetchViewsOutput(
|
||||
purchase_id=purchase.id,
|
||||
views_count=None,
|
||||
views_availability=domain.PostViewsAvailability.UNAVAILABLE,
|
||||
fetched_at=fetched_at,
|
||||
error_message='Failed to fetch views from Telegram API',
|
||||
)
|
||||
log.warning('Failed to fetch views for purchase %s', purchase.id)
|
||||
|
||||
return dto.FetchViewsManuallyOutput(
|
||||
purchase_id=purchase.id,
|
||||
views_count=None,
|
||||
views_availability=domain.PostViewsAvailability.UNAVAILABLE,
|
||||
fetched_at=fetched_at,
|
||||
error_message='Failed to fetch views from Telegram API',
|
||||
)
|
||||
65
src/usecase/views/run_views_worker_cycle.py
Normal file
65
src/usecase/views/run_views_worker_cycle.py
Normal 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))
|
||||
@@ -11,7 +11,6 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyInput) -> dto.PurchaseOutput:
|
||||
"""Ручное обновление просмотров для закупа."""
|
||||
async with self.database.transaction():
|
||||
purchase = await self.database.get_purchase(input.user_id, input.purchase_id)
|
||||
if not purchase:
|
||||
@@ -20,13 +19,11 @@ async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyI
|
||||
|
||||
fetched_at = datetime.datetime.now(datetime.UTC)
|
||||
|
||||
# Обновляем метрики закупа
|
||||
purchase.views_count = input.views_count
|
||||
purchase.views_availability = domain.PostViewsAvailability.MANUAL
|
||||
purchase.last_views_fetch_at = fetched_at
|
||||
await self.database.update_purchase(purchase)
|
||||
|
||||
# Создаём снимок
|
||||
snapshot = domain.ViewsSnapshot(
|
||||
purchase_id=purchase.id,
|
||||
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)
|
||||
|
||||
# Возвращаем обновлённый закуп
|
||||
return dto.PurchaseOutput(
|
||||
id=purchase.id,
|
||||
target_channel_id=purchase.target_channel_id,
|
||||
|
||||
@@ -367,4 +367,4 @@ def mock_jwt() -> MockJWTEncoder:
|
||||
def usecases(
|
||||
mock_database: MockDatabase, mock_telegram: MockTelegramWriter, mock_jwt: MockJWTEncoder
|
||||
) -> 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)
|
||||
|
||||
@@ -55,7 +55,7 @@ async def test_fetch_views_no_ad_post_url(usecases: usecase.Usecase, mock_databa
|
||||
)
|
||||
await mock_database.create_purchase(purchase)
|
||||
|
||||
input_data = dto.FetchViewsInput(
|
||||
input_data = dto.FetchViewsInputManually(
|
||||
purchase_id=purchase.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)
|
||||
|
||||
input_data = dto.FetchViewsInput(
|
||||
input_data = dto.FetchViewsInputManually(
|
||||
purchase_id=purchase.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
129
uv.lock
generated
129
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "aiosignal"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "certifi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "magic-filter"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.44"
|
||||
@@ -811,8 +932,12 @@ version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiogram" },
|
||||
{ name = "aiolimiter" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "lxml" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-multipart" },
|
||||
@@ -833,8 +958,12 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiogram", specifier = ">=3.16.0" },
|
||||
{ name = "aiolimiter", specifier = ">=1.2.1" },
|
||||
{ name = "asyncpg", specifier = ">=0.30.0" },
|
||||
{ name = "beautifulsoup4", specifier = ">=4.14.2" },
|
||||
{ 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 = "pyjwt", specifier = ">=2.10.1" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
|
||||
Reference in New Issue
Block a user