This commit is contained in:
2026-06-08 16:09:13 +08:00
commit 518c6b3c7a
32 changed files with 4156 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
# 服务监听地址,通常本地用 127.0.0.1,容器/服务器用 0.0.0.0
SURUGAYA_APP_HOST=0.0.0.0
# 服务监听端口
SURUGAYA_APP_PORT=31106
# 日志级别:DEBUG / INFO / WARNING / ERROR
SURUGAYA_LOG_LEVEL=INFO
# 是否将日志写入文件(dev 默认 false,prod 默认 true;可显式覆盖)
SURUGAYA_LOG_TO_FILE=
# 日志目录(相对项目根目录)
SURUGAYA_LOG_DIR=logs
# 日志切割策略(Loguru 语法):例如 100 MB / 1 day / 00:00
SURUGAYA_LOG_ROTATION=100 MB
# 日志清理策略(Loguru 语法):例如 14 days / 30 days
SURUGAYA_LOG_RETENTION=14 days
# 日志压缩格式:zip / gz / tar.gz;留空表示不压缩
SURUGAYA_LOG_COMPRESSION=zip
# 运行环境:dev / prod / test(dev 下默认非无头浏览器)
SURUGAYA_APP_ENV=dev
# Bearer Token 鉴权密钥:请求需携带 Authorization: Bearer <token>
SURUGAYA_BEARER_TOKEN=REPLACE_WITH_TOKEN_16CHARS
# 是否启用 Playwright 浏览器抓取(false 时仅保留 API 骨架,方便本地跑测试)
SURUGAYA_BROWSER_ENABLED=true
# 浏览器无头模式(服务器通常 true,本地调试可改 false)
SURUGAYA_BROWSER_HEADLESS=false
# 抓取异常时是否保留浏览器窗口不自动关闭(local + 非无头下建议 true,方便观察 Cloudflare 页面)
SURUGAYA_BROWSER_KEEP_OPEN_ON_ERROR=true
# 使用的浏览器通道(Windows 常用 chrome;也可留空使用 bundled chromium)
SURUGAYA_BROWSER_CHANNEL=chrome
# 代理地址(需要日本 IP 时配置,例如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)
SURUGAYA_PROXY_SERVER=
# 代理用户名(如代理需要认证则填写)
SURUGAYA_PROXY_USERNAME=
# 代理密码(如代理需要认证则填写)
SURUGAYA_PROXY_PASSWORD=
# Redis(可选;用于跨进程/跨实例复用会话与限流状态)
# 留空表示不使用 Redis(退回进程内存)
SURUGAYA_REDIS_HOST=
SURUGAYA_REDIS_PORT=6379
SURUGAYA_REDIS_PASSWORD=
SURUGAYA_REDIS_DB=0
+16
View File
@@ -0,0 +1,16 @@
.pytest_cache/
__pycache__/
*.pyc
*.log
.env
*.egg-info/
/logs/
./vscode/
.trae/
.idea/
.codebuddy/
.deepseek/
.claude/
+29
View File
@@ -0,0 +1,29 @@
# Project Instructions
This file provides context for AI assistants working on this project.
## Project Type: Python
### Commands
- Install: `pip install -e .`
- Test: `pytest`
- Format: `black .`
- Lint: `ruff check .`
### Documentation
See README.md for project overview.
### Version Control
This project uses Git. See .gitignore for excluded files.
## Guidelines
- Follow existing code style and patterns
- Write tests for new functionality
- Keep changes focused and atomic
- Document public APIs
## Important Notes
<!-- Add project-specific notes here -->
+28
View File
@@ -0,0 +1,28 @@
# 1. 使用更新的官方 Playwright Python 镜像
# v1.54.0-jammy (Ubuntu 22.04) 或 v1.54.0-noble (Ubuntu 24.04, Python 3.12+)
FROM mcr.microsoft.com/playwright/python:v1.54.0-noble
# 2. 设置工作目录
WORKDIR /app
# 3. 设置环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
TZ=Asia/Tokyo
# 4. 复制依赖文件并安装 Python 依赖
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
# 5. 安装 Playwright 的 Chromium 浏览器内核
# 虽然基础镜像自带了一些依赖,但为了确保版本匹配,建议执行 install
RUN playwright install chromium
# 6. 复制项目代码
COPY . /app/
# 7. 暴露端口
EXPOSE 31106
# 8. 启动命令
CMD ["python", "-m", "app.main"]
+113
View File
@@ -0,0 +1,113 @@
# Surugaya Scraper Service
一个用于执行骏河屋(suruga-ya.jp)抓取任务的 HTTP API 服务。首版目标是技术验证:在 Cloudflare 保护场景下,稳定抓取搜索列表与商品详情,并支持 API 被并发调用。
## 功能概览
- FastAPI 提供 HTTP API
- Bearer Token 接口鉴权(通过 `Authorization: Bearer <token>` 请求头校验)
- Playwright 驱动浏览器访问目标站点
- Cloudflare 会话复用:自动获取并复用通过验证后的 Cookie(含 `cf_clearance`
- 会话存储:优先 Redis(可选),未配置时自动退回进程内存
## 接口
- `GET /health`
- `POST /api/search`
- `POST /api/item_detail`
## 安装
```bash
python -m pip install -e ".[dev]"
python -m playwright install chromium
```
## 启动
默认监听 `0.0.0.0:31106`
启动前建议先配置 `.env`(项目使用 pydantic-settings 自动读取同目录下的 `.env`)。
方式 1:直接运行入口(推荐)
```bash
python -m app.main
```
方式 2:使用 uvicorn
```bash
uvicorn app.main:app --host 0.0.0.0 --port 31106
```
方式 3:使用启动脚本(自动处理端口占用,推荐 Windows 本地开发)
```powershell
.\start-server.ps1
```
可选参数:
- `-Port 31106`:指定端口
- `-HostName 0.0.0.0`:指定监听地址
- `-NoKillExisting`:检测到端口占用时直接报错,不自动结束旧进程
- `-DryRun`:只做检查不实际启动
## 鉴权(Bearer Token)
服务端从请求头 `Authorization` 取值,字符串替换掉前缀 `Bearer ` 后,与服务端配置的 token 做比较。
建议在 `.env` 中设置:
- `SURUGAYA_BEARER_TOKEN`
示例(curl):
```bash
curl -H "Authorization: Bearer <token>" ^
-H "Content-Type: application/json" ^
-X POST "http://127.0.0.1:31106/api/search" ^
-d "{\"search_word\":\"cat\",\"category\":\"\",\"page\":2}"
```
## 常用环境变量
- 服务:
- `SURUGAYA_APP_HOST`(默认 `0.0.0.0`
- `SURUGAYA_APP_PORT`(默认 `31106`
- 鉴权:
- `SURUGAYA_BEARER_TOKEN`
- 代理(日本 IP):
- `SURUGAYA_PROXY_SERVER`(例:`http://127.0.0.1:7890`
- `SURUGAYA_PROXY_USERNAME`
- `SURUGAYA_PROXY_PASSWORD`
- Redis(可选):
- `SURUGAYA_REDIS_HOST`(例:`127.0.0.1`,留空表示不使用 Redis)
- `SURUGAYA_REDIS_PORT`(默认 `6379`
- `SURUGAYA_REDIS_PASSWORD`
- `SURUGAYA_REDIS_DB`(默认 `0`
## 请求示例
### 搜索
`POST /api/search`
```json
{
"search_word": "cat",
"category": "",
"page": 2
}
```
### 详情
`POST /api/item_detail`
```json
{
"product_id": "985029623"
}
```
+1
View File
@@ -0,0 +1 @@
"""Surugaya scraper service package."""
+1
View File
@@ -0,0 +1 @@
"""API routing package."""
+41
View File
@@ -0,0 +1,41 @@
"""FastAPI 依赖注入:提供容器获取和鉴权校验"""
import logging
import secrets
from fastapi import Depends, Request
from app.core.container import ServiceContainer
from app.core.errors import AuthenticationError
logger = logging.getLogger(__name__)
def get_container(request: Request) -> ServiceContainer:
"""从请求中获取服务容器"""
return request.app.state.container
def require_bearer_token(
request: Request,
container: ServiceContainer = Depends(get_container),
) -> None:
"""Bearer Token 鉴权依赖
从请求头 Authorization 中提取 Bearer Token,
与服务端配置的 token 做安全比较(使用 secrets.compare_digest 防止时序攻击)。
"""
authorization = request.headers.get("Authorization")
if not authorization:
logger.warning("鉴权失败:缺少 Authorization 请求头")
raise AuthenticationError("Missing Authorization header")
token = authorization.replace("Bearer ", "", 1).strip()
if token == authorization:
logger.warning("鉴权失败:Authorization scheme 非 Bearer")
raise AuthenticationError("Invalid Authorization scheme")
if not token:
logger.warning("鉴权失败:Bearer token 为空")
raise AuthenticationError("Invalid token")
if not secrets.compare_digest(token, container.settings.bearer_token):
logger.warning("鉴权失败:token 不匹配")
raise AuthenticationError("Invalid token")
+1
View File
@@ -0,0 +1 @@
"""API route modules."""
+31
View File
@@ -0,0 +1,31 @@
"""健康检查接口:返回服务状态和浏览器池信息"""
from fastapi import APIRouter, Depends
from app.api.dependencies import get_container, require_bearer_token
from app.core.container import ServiceContainer
from app.models.scrape import ApiResponse, HealthData
router = APIRouter(tags=["health"])
@router.get("/health", response_model=ApiResponse[HealthData], dependencies=[Depends(require_bearer_token)])
async def health(container: ServiceContainer = Depends(get_container)) -> ApiResponse[HealthData]:
"""健康检查接口
返回浏览器池状态、Redis 连接状态和当前活跃会话等信息。
"""
session = await container.session_store.get_session("www.suruga-ya.jp:search")
data = HealthData(
status="ok" if container.browser_pool.ready else "degraded",
browser_enabled=container.settings.browser_enabled,
browser_ready=container.browser_pool.ready,
browser_startup_error=container.browser_pool.startup_error,
redis_enabled=container.session_store.redis_enabled,
active_session_id=session.session_id if session else None,
)
return ApiResponse[HealthData](
success=True,
msg="success",
data=data,
code=0,
)
+354
View File
@@ -0,0 +1,354 @@
from __future__ import annotations
import asyncio
import json
import time
from typing import Any
from urllib import error, request
from fastapi import APIRouter, Depends
from app.api.dependencies import get_container, require_bearer_token
from app.core.container import ServiceContainer
from app.models.scrape import (
ApiResponse,
CategoryData,
CheckTaskCallbackUrlData,
CheckTaskCallbackUrlRequest,
DetailRequest,
OrderShippingFeeData,
OrderShippingFeeRequest,
ProductDetailData,
PurchaseTaskRequest,
SearchRequest,
SearchResultData, TradeMonitorRequest,
)
from app.utils.http_utils import generate_signed_headers
from app.utils.parse_util import get_handle_fee, get_shipping_fee
router = APIRouter(prefix="/api", tags=["scrape"])
def _parse_callback_response_body(content_type: str, body_bytes: bytes) -> Any:
if not body_bytes:
return None
body_text = body_bytes.decode("utf-8", errors="replace")
if "application/json" not in content_type.lower():
return body_text
try:
return json.loads(body_text)
except json.JSONDecodeError:
return body_text
def _post_callback_request(
callback_url: str,
request_body: dict[str, Any],
headers: dict[str, str],
) -> tuple[int, dict[str, str], Any]:
body_bytes = json.dumps(request_body, ensure_ascii=False).encode("utf-8")
http_request = request.Request(
url=callback_url,
data=body_bytes,
headers=headers,
method="POST",
)
try:
with request.urlopen(http_request, timeout=15) as response:
response_body = response.read()
response_headers = dict(response.headers.items())
content_type = response_headers.get("Content-Type", "")
return response.status, response_headers, _parse_callback_response_body(content_type, response_body)
except error.HTTPError as exc:
response_body = exc.read()
response_headers = dict(exc.headers.items()) if exc.headers is not None else {}
content_type = response_headers.get("Content-Type", "")
return exc.code, response_headers, _parse_callback_response_body(content_type, response_body)
@router.post(
"/search",
response_model=ApiResponse[SearchResultData],
dependencies=[Depends(require_bearer_token)],
)
async def search(
payload: SearchRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[SearchResultData]:
"""搜索商品列表 Authorization Bearer NilsO8Il8yoT1wazJFs2eeOmlPRfWuSx
根据关键词和页码抓取骏河屋搜索结果,返回商品列表、总数和分页信息。
"""
data = await container.surugaya_client.search(payload)
return ApiResponse[SearchResultData](
success=True,
msg="success",
data=data,
code=0,
)
@router.post(
"/item_detail",
response_model=ApiResponse[ProductDetailData],
dependencies=[Depends(require_bearer_token)],
)
async def item_detail(
payload: DetailRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[ProductDetailData]:
"""获取商品详情
根据商品 ID 或 URL 抓取骏河屋商品详情页,返回名称、价格、图片等信息。
"""
data = await container.surugaya_client.fetch_detail(payload)
return ApiResponse[ProductDetailData](
success=True,
msg="success",
data=data,
code=0,
)
@router.get(
"/categories",
response_model=ApiResponse[list[CategoryData]],
dependencies=[Depends(require_bearer_token)],
)
async def categories(
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[list[CategoryData]]:
"""获取商品分类
获取骏河屋商品分类列表,包含分类名称和分类 ID。数据带有缓存。
"""
data = await container.surugaya_client.fetch_categories()
return ApiResponse[list[CategoryData]](
success=True,
msg="success",
data=data,
code=0,
)
@router.post(
"/order/get_shipping_fee",
response_model=ApiResponse[OrderShippingFeeData],
dependencies=[Depends(require_bearer_token)],
)
async def order_get_shipping_fee(payload: OrderShippingFeeRequest) -> ApiResponse[OrderShippingFeeData]:
goods_amount = sum(item.goods_number * item.goods_price for item in payload.item_list)
shipping_fee = get_shipping_fee(goods_amount)
handle_amount = get_handle_fee(goods_amount)
order_amount = goods_amount + shipping_fee + handle_amount
return ApiResponse[OrderShippingFeeData](
success=True,
msg="success",
data=OrderShippingFeeData(
goods_amount=goods_amount,
shipping_fee=shipping_fee,
order_amount=order_amount,
handle_amount=handle_amount,
),
code=0,
)
@router.post(
"/order/add_purchase_task",
response_model=ApiResponse[None],
dependencies=[Depends(require_bearer_token)],
)
async def order_add_purchase_task(
payload: PurchaseTaskRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[None]:
"""添加采购任务
将采购任务加入 Redis Stream 队列。
"""
if container.session_store._redis is None:
return ApiResponse[None](
success=False,
msg="Redis 未配置",
data=None,
code=500,
)
stream_name = "jp_surugaya_purchase_task_dev" if payload.is_dev == "1" else "jp_surugaya_purchase_task"
params_str = payload.model_dump_json(by_alias=True)
await container.session_store._redis.xadd(
name=stream_name,
fields={"payload": params_str},
maxlen=100,
approximate=True
)
return ApiResponse[None](
success=True,
msg="success",
data=None,
code=0,
)
@router.post(
"/order/add_trade_monitor",
response_model=ApiResponse[None],
dependencies=[Depends(require_bearer_token)],
)
async def order_add_trade_monitor(
payload: TradeMonitorRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[None]:
"""添加采购监控
将采购监控加入 Redis Stream 队列。
"""
if container.session_store._redis is None:
return ApiResponse[None](
success=False,
msg="Redis 未配置",
data=None,
code=500,
)
node_id = payload.node_id
if node_id is None:
return ApiResponse[None](
success=False,
msg="node_id 不能为空",
data=None,
code=400,
)
trade_code = payload.payload.get("trade_code")
if not trade_code or trade_code == "":
return ApiResponse[None](
success=False,
msg="trade_code 不能为空",
data=None,
code=400,
)
queue_name = "jp_surugaya_trade_delay_dev" if payload.is_dev == "1" else "jp_surugaya_trade_delay"
queue_name += f":{node_id}"
score = int(time.time()) + 3
member = json.dumps(payload.payload)
await container.session_store._redis.zadd(
name=queue_name,
mapping={member: score}
)
return ApiResponse[None](
success=True,
msg="success",
data=None,
code=0,
)
@router.get(
"/trade",
response_model=ApiResponse[dict[str, Any]],
dependencies=[Depends(require_bearer_token)],
)
async def trade(
trade_code: str,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[dict[str, Any]]:
"""
获取订单详情
"""
if not trade_code or trade_code.strip() == "":
return ApiResponse[dict[str, Any]](
success=False,
msg="trade_code 不能为空",
data=None,
code=400,
)
if container.session_store._redis is None:
return ApiResponse[dict[str, Any]](
success=False,
msg="Redis 未配置",
data=None,
code=500,
)
trade_data = await container.session_store._redis.hget(
name="jp_surugaya_trade",
key=trade_code,
)
if not trade_data:
return ApiResponse[dict[str, Any]](
success=False,
msg="未找到该交易",
data=None,
code=404,
)
return ApiResponse[dict[str, Any]](
success=True,
msg="success",
data=json.loads(trade_data),
code=0,
)
@router.post(
"/test/check_task_callback_url",
response_model=ApiResponse[CheckTaskCallbackUrlData],
dependencies=[Depends(require_bearer_token)],
)
async def check_task_callback_url(
payload: CheckTaskCallbackUrlRequest,
) -> ApiResponse[CheckTaskCallbackUrlData]:
"""测试回调地址可用性并返回回调响应。"""
request_body = payload.data
headers = generate_signed_headers(
app_id=payload.app_id,
app_secret=payload.app_secret,
data=request_body,
method="POST",
)
try:
response_status, response_headers, response_body = await asyncio.to_thread(
_post_callback_request,
str(payload.callback_url),
request_body,
headers,
)
except error.URLError as exc:
return ApiResponse[CheckTaskCallbackUrlData](
success=False,
msg=f"回调请求失败: {exc.reason}",
data=None,
code=500,
)
except Exception as exc:
return ApiResponse[CheckTaskCallbackUrlData](
success=False,
msg=f"回调请求异常: {exc}",
data=None,
code=500,
)
success = 200 <= response_status < 300
return ApiResponse[CheckTaskCallbackUrlData](
success=success,
msg="调用正常" if success else "回调请求有异常",
data=CheckTaskCallbackUrlData(
response_status=response_status,
response_body=response_body,
),
code=0 if success else response_status,
)
+1
View File
@@ -0,0 +1 @@
"""Core configuration and infrastructure helpers."""
+131
View File
@@ -0,0 +1,131 @@
"""应用配置:通过环境变量和 .env 文件加载所有配置项
配置项统一使用 SURUGAYA_ 前缀,例如 SURUGAYA_APP_PORT=31106。
支持 .env 文件自动加载。
"""
from functools import lru_cache
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
class Settings(BaseSettings):
"""应用全局配置
所有配置项均可通过环境变量覆盖,前缀为 SURUGAYA_。
例如:SURUGAYA_APP_PORT=31106 对应 app_port 配置项。
"""
model_config = SettingsConfigDict(
env_file=str(BASE_DIR / ".env"), # 使用绝对路径
env_file_encoding="utf-8",
env_prefix="SURUGAYA_",
extra="ignore",
)
# ---- 服务基本配置 ----
app_name: str = "Surugaya Scraper Service"
app_env: Literal["dev", "prod", "test"] = "dev"
app_host: str = "0.0.0.0"
app_port: int = 31106
# ---- 日志配置 ----
log_level: str = "DEBUG"
log_to_file: bool | None = None # None 表示根据环境自动决定
log_dir: str = "logs"
log_rotation: str = "10 MB"
log_retention: str = "14 days"
log_compression: str = "zip"
log_format: str = "{time:YYYY-MM-DD HH:mm:ss} {level} {message}"
log_enqueue: bool = True
# ---- 鉴权配置 ----
bearer_token: str = "NilsO8Il8yoT1wazJFs2eeOmlPRfWuSx"
# ---- 浏览器配置 ----
browser_enabled: bool = True
browser_headless: bool | None = None # None 表示根据环境自动决定
browser_keep_open: bool | None = None # None 表示根据环境自动决定
browser_keep_open_on_error: bool | None = None # None 表示根据环境自动决定
browser_channel: str | None = "chrome"
browser_pool_size: int = 3
max_site_concurrency: int = 5 # 最大并发页面数
page_acquire_timeout_seconds: float = 60.0 # 获取页面槽位超时
request_timeout_seconds: float = 120.0 # 页面请求超时
cloudflare_wait_timeout_seconds: float = 60.0 # 等待 Cloudflare 挑战超时
session_ttl_seconds: int = 900 # 会话过期时间(秒)
browser_locale: str = "ja-JP"
browser_timezone_id: str = "Asia/Tokyo"
browser_user_agent: str = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
browser_stealth_enabled: bool = True # 是否启用浏览器 Stealth 模式
# ---- Redis 配置(可选,未配置时使用进程内存)----
redis_host: str = ""
redis_port: int = 6379
redis_password: str | None = None
redis_db: int = 0
session_namespace: str = "jp_surugaya"
# ---- 代理配置(可选,用于日本 IP)----
proxy_server: str | None = None
proxy_username: str | None = None
proxy_password: str | None = None
# ---- 目标站点 ----
base_url: str = "https://www.suruga-ya.jp"
@property
def browser_headless_effective(self) -> bool:
"""浏览器无头模式:显式配置优先,否则开发环境使用有头模式方便调试"""
if self.browser_headless is not None:
return self.browser_headless
if self.app_env == "dev":
return False
return True
@property
def browser_keep_open_on_error_effective(self) -> bool:
"""出错时是否保留浏览器窗口:仅在开发环境有头模式下保留"""
if self.browser_keep_open_on_error is not None:
return self.browser_keep_open_on_error
return self.app_env == "dev" and not self.browser_headless_effective
@property
def browser_keep_open_effective(self) -> bool:
"""成功后是否保留浏览器窗口:仅在开发环境有头模式下保留"""
if self.browser_keep_open is not None:
return self.browser_keep_open
return self.app_env == "dev" and not self.browser_headless_effective
@property
def log_to_file_effective(self) -> bool:
"""是否写日志文件:显式配置优先,否则生产环境默认写文件"""
if self.log_to_file is not None:
return self.log_to_file
return self.app_env == "prod"
@property
def proxy(self) -> dict[str, str] | None:
"""构建 Playwright 代理配置字典"""
if not self.proxy_server:
return None
proxy: dict[str, str] = {"server": self.proxy_server}
if self.proxy_username:
proxy["username"] = self.proxy_username
if self.proxy_password:
proxy["password"] = self.proxy_password
return proxy
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""获取全局配置单例"""
return Settings()
+25
View File
@@ -0,0 +1,25 @@
"""服务容器:集中管理所有服务实例,用于依赖注入"""
from dataclasses import dataclass
from app.core.config import Settings
from app.services.browser_pool import BrowserPool
from app.services.cloudflare_session import CloudflareSessionManager
from app.services.session_store import SessionStore
from app.services.surugaya_client import SurugayaClient
from app.services.cargo_service import CargoService
@dataclass(slots=True)
class ServiceContainer:
"""服务容器,持有所有核心服务实例
通过 FastAPI 的 app.state.container 在请求间共享,
各路由通过依赖注入获取容器中的服务。
"""
settings: Settings
session_store: SessionStore
browser_pool: BrowserPool
session_manager: CloudflareSessionManager
surugaya_client: SurugayaClient
cargo_service: CargoService
+78
View File
@@ -0,0 +1,78 @@
"""应用异常定义:所有业务异常均继承自 AppError
错误码规范:
- 1xxx: 请求/鉴权错误
- 2xxx: 浏览器资源错误
- 3xxx: Cloudflare/反爬相关错误
- 4xxx: 页面解析错误
"""
class AppError(Exception):
"""应用基础异常,携带错误码、HTTP 状态码和是否可重试信息"""
def __init__(
self,
message: str,
code: str,
err_code: int,
retryable: bool = False,
status_code: int = 400,
headers: dict[str, str] | None = None,
):
super().__init__(message)
self.message = message
self.code = code
self.err_code = err_code
self.retryable = retryable
self.status_code = status_code
self.headers = headers
class AuthenticationError(AppError):
"""鉴权失败(Bearer Token 无效或缺失)"""
def __init__(self, message: str = "Invalid credentials"):
super().__init__(
message=message,
code="AUTH_INVALID",
err_code=1001,
retryable=False,
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
)
class BrowserUnavailableError(AppError):
"""浏览器不可用(未启用或启动失败)"""
def __init__(self, message: str = "Browser pool is unavailable"):
super().__init__(message=message, code="BROWSER_UNAVAILABLE", err_code=2001, retryable=True)
class ResourceBusyError(AppError):
"""浏览器资源繁忙(获取页面槽位超时)"""
def __init__(self, message: str = "Timed out while waiting for a browser slot"):
super().__init__(message=message, code="RESOURCE_BUSY", err_code=2002, retryable=True)
class CloudflareChallengeError(AppError):
"""Cloudflare 挑战未在规定时间内通过"""
def __init__(self, message: str = "Cloudflare challenge did not clear in time"):
super().__init__(message=message, code="CLOUDFLARE_CHALLENGE", err_code=3001, retryable=True)
class UpstreamBlockedError(AppError):
"""上游请求被 Cloudflare 阻断(如 1020/403 错误)"""
def __init__(self, message: str = "The upstream request was blocked"):
super().__init__(message=message, code="UPSTREAM_BLOCKED", err_code=3002, retryable=True)
class ScrapeParseError(AppError):
"""页面解析失败"""
def __init__(self, message: str = "Failed to parse the target page"):
super().__init__(message=message, code="PARSE_ERROR", err_code=4001, retryable=False)
+82
View File
@@ -0,0 +1,82 @@
"""日志配置:使用 loguru 替代标准 logging,统一管理日志输出
- 控制台输出:始终启用
- 文件输出:生产环境默认启用,支持日志轮转、压缩和保留策略
- 拦截标准 logging:将 uvicorn 等第三方库的日志转发到 loguru
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from types import FrameType
from loguru import logger as loguru_logger
from app.core.config import Settings
class InterceptHandler(logging.Handler):
"""拦截标准 logging 日志,转发到 loguru"""
def emit(self, record: logging.LogRecord) -> None:
try:
level: str | int = loguru_logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame: FrameType | None = logging.currentframe()
depth = 2
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
loguru_logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def configure_logging(settings: Settings) -> None:
"""根据配置初始化日志系统
- 移除 loguru 默认 handler
- 添加控制台输出
- 生产环境添加文件输出(轮转、压缩、保留策略)
- 拦截标准 logging,统一走 loguru 输出
"""
loguru_logger.remove()
loguru_logger.add(
sys.stdout,
level=settings.log_level.upper(),
format=settings.log_format,
enqueue=settings.log_enqueue,
backtrace=False,
diagnose=False,
)
if settings.log_to_file_effective:
log_dir = Path(settings.log_dir)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"app_{settings.app_env}.log"
loguru_logger.add(
str(log_path),
level=settings.log_level.upper(),
format=settings.log_format,
rotation=settings.log_rotation,
retention=settings.log_retention,
compression=settings.log_compression,
enqueue=settings.log_enqueue,
encoding="utf-8",
backtrace=False,
diagnose=False,
)
intercept = InterceptHandler()
logging.basicConfig(handlers=[intercept], level=getattr(logging, settings.log_level.upper(), logging.INFO), force=True)
# 将 uvicorn 的日志也转发到 loguru
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
logger = logging.getLogger(name)
logger.handlers = [intercept]
logger.propagate = False
# 降低 asyncio 的日志级别,避免过多输出
logging.getLogger("asyncio").setLevel(logging.WARNING)
+181
View File
@@ -0,0 +1,181 @@
"""应用入口:FastAPI 应用创建与生命周期管理
职责:
- 构建服务容器(依赖注入)
- 管理应用生命周期(启动/关闭浏览器池和会话存储)
- 注册路由和全局异常处理器
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.api.routes.health import router as health_router
from app.api.routes.scrape import router as scrape_router
from app.core.config import get_settings
from app.core.container import ServiceContainer
from app.core.errors import AppError
from app.core.logging_setup import configure_logging
from app.models.scrape import ApiResponse
from app.services.browser_pool import BrowserPool
from app.services.cloudflare_session import CloudflareSessionManager
from app.services.session_store import SessionStore
from app.services.surugaya_client import SurugayaClient
from app.services.cargo_service import CargoService
logger = logging.getLogger(__name__)
def _format_validation_msg(errors: list[dict]) -> str:
"""将校验错误整理为便于前端展示的消息。"""
if not errors:
return "Validation error"
messages: list[str] = []
for error in errors:
loc = ".".join(str(part) for part in error.get("loc", []) if part != "body")
msg = str(error.get("msg", "Validation error"))
messages.append(f"{loc}: {msg}" if loc else msg)
return "; ".join(messages)
def build_container() -> ServiceContainer:
"""构建服务容器,组装所有依赖"""
settings = get_settings()
session_store = SessionStore(settings)
browser_pool = BrowserPool(settings)
session_manager = CloudflareSessionManager(settings, browser_pool, session_store)
surugaya_client = SurugayaClient(settings, browser_pool, session_manager, session_store)
cargo_service = CargoService(settings)
return ServiceContainer(
settings=settings,
session_store=session_store,
browser_pool=browser_pool,
session_manager=session_manager,
surugaya_client=surugaya_client,
cargo_service=cargo_service,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理:启动时初始化各服务,关闭时释放资源"""
container = build_container()
app.state.container = container
configure_logging(container.settings)
logger.info("应用启动:%s:%s", container.settings.app_host, container.settings.app_port)
logger.info("日志级别:%s", container.settings.log_level)
logger.info("当前环境:%s", container.settings.app_env)
await container.session_store.start()
await container.browser_pool.start()
try:
yield
finally:
await container.browser_pool.close()
await container.session_store.close()
def create_app() -> FastAPI:
"""创建 FastAPI 应用实例,注册路由和异常处理器"""
app = FastAPI(title="Surugaya Scraper Service", lifespan=lifespan)
app.include_router(health_router)
app.include_router(scrape_router)
@app.exception_handler(AppError)
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
"""业务异常处理器:返回结构化的错误响应"""
return JSONResponse(
status_code=exc.status_code,
content=ApiResponse[None](
success=False,
code=exc.err_code,
msg=exc.message,
data=None,
).model_dump(),
headers=exc.headers,
)
@app.exception_handler(RequestValidationError)
async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
"""请求参数校验异常处理器"""
errors = exc.errors()
return JSONResponse(
status_code=422,
content=ApiResponse[object](
success=False,
msg=_format_validation_msg(errors),
data=errors,
code=1002,
).model_dump(),
)
@app.exception_handler(ValidationError)
async def pydantic_validation_error_handler(_: Request, exc: ValidationError) -> JSONResponse:
"""Pydantic 模型校验异常处理器。"""
errors = exc.errors()
return JSONResponse(
status_code=422,
content=ApiResponse[object](
success=False,
msg=_format_validation_msg(errors),
data=errors,
code=1002,
).model_dump(),
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse:
"""HTTP 异常处理器(404、500 等)"""
status_code = int(getattr(exc, "status_code", 500) or 500)
err_code = 1404 if status_code == 404 else 1500
return JSONResponse(
status_code=status_code,
content=ApiResponse[None](
success=False,
msg=str(getattr(exc, "detail", "HTTP error")),
data=None,
code=err_code,
).model_dump(),
headers=getattr(exc, "headers", None),
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(_: Request, __: Exception) -> JSONResponse:
"""兜底异常处理器:捕获所有未处理的异常"""
return JSONResponse(
status_code=500,
content=ApiResponse[None](
success=False,
msg="Internal server error",
data=None,
code=1500,
).model_dump(),
)
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
settings = get_settings()
configure_logging(settings)
uvicorn.run(
"app.main:app",
host=settings.app_host,
port=settings.app_port,
log_config=None,
timeout_keep_alive=120,
)
+1
View File
@@ -0,0 +1 @@
"""Pydantic models used by the API and services."""
+207
View File
@@ -0,0 +1,207 @@
"""API 数据模型:请求体和响应体定义"""
from __future__ import annotations
import json
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
"""统一 API 响应格式"""
success: bool
msg: str
data: T | None = None
code: int
class CategoryData(BaseModel):
"""商品分类信息"""
id: str
name: str
pid: str = ""
icon: str | None = None
href: str = ""
children: list["CategoryData"] = Field(default_factory=list)
class SearchRequest(BaseModel):
"""搜索请求参数"""
model_config = ConfigDict(extra="allow")
search_word: str = ""
page: int = Field(default=1, ge=1, le=100)
search_url: HttpUrl | None = None
class DetailRequest(BaseModel):
"""商品详情请求参数,支持 id 或 product_url"""
id: str | None = Field(default=None, min_length=3, max_length=40)
product_url: HttpUrl | None = None
class ProductSummary(BaseModel):
"""搜索结果中的商品摘要信息"""
goods_id: str
goods_name: str
goods_image: str = ""
goods_price: int = 0 # 当前售价(日元)
original_price: int = 0 # 原价(日元)
third_shop_price: int = 0 # 第三方市场价(日元)
goods_link: str
on_sold: int = 0 # 1: 在售, 0: 售罄
created_at: str = ""
updated_at: str = ""
class SearchResultData(BaseModel):
"""搜索结果数据"""
query: str
page: int
page_size: int = 0
total_count: int = 0
has_more: int = 0
session_id: str
items: list[ProductSummary]
class ProductDetailData(BaseModel):
"""商品详情数据"""
goods_id: str
goods_name: str
goods_image: str = ""
goods_price: int = 0 # 当前售价(日元)
on_sold: int = 0 # 1: 在售, 0: 售罄
stock: int | None = None
description: str | None = None
session_id: str | None = None
supplier: str | None = None # 供应商名称
price_note: str | None = None # 价格备注
shipping_comission_fee_text: str | None = None # 运费描述
shipping_comission_fee: int = 0 # 运费
sku_list: list[dict[str, Any]] # 规格列表
sku: str = "" # 规格
breadcrumb_list: list[dict[str, Any]] = [] # 面包屑
class HealthData(BaseModel):
"""健康检查响应数据"""
status: str
browser_enabled: bool
browser_ready: bool
browser_startup_error: str | None = None
redis_enabled: bool
active_session_id: str | None = None
class CargoItemRequest(BaseModel):
id: str
number: int = 1
price_limit: int | None = None
class CargoPayload(BaseModel):
item_list: list[CargoItemRequest]
class CargoRequest(BaseModel):
record_video: str = "0"
payload: CargoPayload
class CargoItemData(BaseModel):
"""购物车商品信息"""
goods_id: str
goods_name: str
goods_price: int
goods_image: str = ""
quantity: int = 1
class CargoInfoData(BaseModel):
"""购物车整体信息"""
items: list[CargoItemData]
total_goods_amount: int = 0
total_shipping_fee: int = 0
total_commission_fee: int = 0 # 邮购手续费
class OrderItem(BaseModel):
goods_id: str = Field(min_length=1, max_length=40)
goods_number: int = Field(ge=1, le=9999)
goods_price: int = Field(ge=0)
class OrderShippingFeeRequest(BaseModel):
item_list: list[OrderItem] = Field(min_length=1)
class OrderShippingFeeData(BaseModel):
goods_amount: int
shipping_fee: int
order_amount: int
handle_amount: int
class PurchaseTaskRequest(BaseModel):
"""添加采购任务请求参数"""
model_config = ConfigDict(extra="allow")
task_id: str
topic: str
purchase_account: str
site_id: str
record_video: str
is_dev: str
payload: dict[str, Any]
class TradeMonitorRequest(BaseModel):
"""添加采购监控请求参数"""
model_config = ConfigDict(extra="allow")
node_id: str
payload: dict[str, Any]
is_dev: str
class CheckTaskCallbackUrlRequest(BaseModel):
"""测试回调地址请求参数"""
app_id: str = Field(min_length=1, max_length=100)
app_secret: str = Field(min_length=1, max_length=200)
callback_url: HttpUrl
data: dict[str, Any]
@field_validator("app_id", "app_secret", mode="before")
@classmethod
def strip_text_fields(cls, value: Any) -> Any:
if isinstance(value, str):
return value.strip()
return value
@field_validator("callback_url", mode="before")
@classmethod
def normalize_callback_url(cls, value: Any) -> Any:
if isinstance(value, str):
return value.strip().strip("`").strip()
return value
@field_validator("data")
@classmethod
def validate_data(cls, value: dict[str, Any]) -> dict[str, Any]:
if not value:
raise ValueError("data must not be empty")
try:
json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError) as exc:
raise ValueError("data must be a valid JSON object") from exc
return value
class CheckTaskCallbackUrlData(BaseModel):
"""测试回调地址响应数据"""
response_status: int
response_body: Any
+1
View File
@@ -0,0 +1 @@
"""Service layer implementations."""
+299
View File
@@ -0,0 +1,299 @@
"""浏览器池:管理 Playwright 浏览器实例的生命周期和并发访问
核心能力:
- 启动/关闭 Chromium 浏览器实例(进程级单例)
- 通过信号量控制并发页面数量,避免 API 并发把浏览器资源打爆
- 支持本地调试模式下保留浏览器窗口(出错或成功时),方便人工排查
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from app.core.config import Settings
from app.core.errors import BrowserUnavailableError, ResourceBusyError
logger = logging.getLogger(__name__)
class BrowserPool:
"""浏览器实例池,提供并发安全的页面会话获取"""
def __init__(self, settings: Settings):
self._settings = settings
self._playwright: Any | None = None
self._browser: Any | None = None
self._semaphore = asyncio.Semaphore(settings.max_site_concurrency)
self._restart_lock = asyncio.Lock()
self._started = False
self._startup_error: str | None = None
self._retained_contexts: list[Any] = []
self._retained_pages: list[Any] = []
@property
def enabled(self) -> bool:
"""浏览器功能是否启用"""
return self._settings.browser_enabled
@property
def ready(self) -> bool:
"""浏览器是否已就绪(未启用或已启动成功均视为就绪)"""
return not self.enabled or self._started
@property
def startup_error(self) -> str | None:
"""浏览器启动失败时的错误信息"""
return self._startup_error
async def start(self) -> None:
"""启动 Playwright 浏览器实例
仅在 browser_enabled=True 且尚未启动时执行。
启动失败会记录错误信息到 startup_error,但不会抛异常(服务仍可启动)。
"""
if not self.enabled or self._started:
return
try:
from playwright.async_api import async_playwright
headless = self._settings.browser_headless_effective
logger.info(
"启动浏览器:enabled=%s headless=%s keep_open_on_error=%s channel=%s proxy=%s env=%s",
self._settings.browser_enabled,
headless,
self._settings.browser_keep_open_on_error_effective,
self._settings.browser_channel,
bool(self._settings.proxy_server),
self._settings.app_env,
)
self._playwright = await async_playwright().start()
chromium = self._playwright.chromium
launch_args = [
"--disable-blink-features=AutomationControlled",
"--disable-dev-shm-usage",
"--disable-web-security",
"--no-first-run",
"--disable-infobars",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-gpu",
"--use-mock-keychain",
"--password-store=basic",
]
# 本地可视化调试时强制窗口化,避免浏览器启动后不可见
if not headless:
launch_args.extend(["--start-maximized", "--window-position=120,120"])
self._browser = await chromium.launch(
headless=headless,
channel=self._settings.browser_channel,
proxy=self._settings.proxy,
args=launch_args,
)
self._started = True
self._startup_error = None
logger.info("浏览器启动完成")
except Exception as exc:
self._playwright = None
self._browser = None
self._started = False
self._startup_error = str(exc)
logger.exception("浏览器启动失败:%s", self._startup_error)
def _is_browser_connected(self) -> bool:
"""检查当前浏览器对象是否仍与 Playwright driver 保持连接。"""
browser = self._browser
if not self._started or browser is None:
return False
is_connected = getattr(browser, "is_connected", None)
if callable(is_connected):
try:
return bool(is_connected())
except Exception:
return False
return True
async def close(self) -> None:
"""关闭浏览器与 Playwright 运行时,通常发生在服务退出时"""
self._retained_pages.clear()
for context in self._retained_contexts:
try:
await context.close()
except Exception:
logger.exception("关闭保留的调试上下文失败")
self._retained_contexts.clear()
if self._browser is not None:
try:
await self._browser.close()
except Exception:
logger.exception("关闭浏览器实例失败")
if self._playwright is not None:
try:
await self._playwright.stop()
except Exception:
logger.exception("停止 Playwright 运行时失败")
self._browser = None
self._playwright = None
self._started = False
async def _restart_browser(self, reason: str) -> None:
"""在浏览器掉线后串行重建 Playwright 运行时。"""
async with self._restart_lock:
if self._is_browser_connected():
return
logger.warning("检测到浏览器连接不可用,准备重建浏览器实例:reason=%s", reason)
await self.close()
await self.start()
if not self._is_browser_connected():
raise BrowserUnavailableError(self._startup_error or f"Browser restart failed: {reason}")
async def _ensure_browser_ready(self) -> None:
"""确保浏览器池处于可用状态;若连接已断开则自动重建。"""
if not self.enabled:
raise BrowserUnavailableError("Browser support is disabled")
if self._is_browser_connected():
return
await self._restart_browser("browser is disconnected before acquiring page session")
@staticmethod
def _should_retry_context_creation(exc: Exception) -> bool:
"""识别 Playwright driver 断链类错误,允许执行一次重建后重试。"""
message = str(exc).lower()
retry_markers = (
"connection closed while reading from the driver",
"browser has been closed",
"target page, context or browser has been closed",
"connection closed",
)
return any(marker in message for marker in retry_markers)
async def _retain_context(self, context: Any, page: Any | None, reason: str) -> None:
"""保留浏览器上下文用于本地调试排查
当本地调试模式开启时,出错或成功后不关闭浏览器窗口,
方便人工查看页面状态。保留数量超过池大小时自动关闭最早的。
"""
if page is not None:
try:
await page.bring_to_front()
logger.debug("%s:已将页面置顶:url=%s", reason, page.url)
except Exception:
logger.exception("%s:页面置顶失败", reason)
self._retained_pages.append(page)
self._retained_contexts.append(context)
# 超出保留上限时关闭最早的上下文
limit = max(1, int(self._settings.browser_pool_size))
while len(self._retained_contexts) > limit:
old = self._retained_contexts.pop(0)
try:
await old.close()
except Exception:
logger.exception("关闭旧的保留调试上下文失败")
@asynccontextmanager
async def page_session(
self,
storage_state: dict[str, Any] | None = None,
*,
keep_open_on_success: bool | None = None,
) -> AsyncIterator[tuple[Any, Any]]:
"""获取一个浏览器页面会话(上下文管理器)
内部通过信号量做并发控制,超时未获取到槽位会抛出 ResourceBusyError。
页面创建时会设置日语环境、User-Agent 等反检测参数。
Args:
storage_state: 浏览器存储状态(含 Cookie),用于会话复用
keep_open_on_success: 请求成功后是否保留浏览器窗口(用于本地调试)
Yields:
(context, page) 元组,context 为浏览器上下文,page 为页面实例
Raises:
BrowserUnavailableError: 浏览器未启用或未启动
ResourceBusyError: 等待可用槽位超时
"""
await self._ensure_browser_ready()
should_keep_open_on_success = (
self._settings.browser_keep_open_effective
if keep_open_on_success is None
else keep_open_on_success
)
try:
await asyncio.wait_for(
self._semaphore.acquire(),
timeout=self._settings.page_acquire_timeout_seconds,
)
except TimeoutError as exc:
raise ResourceBusyError() from exc
context = None
page = None
try:
for attempt in range(2):
try:
context = await self._browser.new_context(
locale=self._settings.browser_locale,
timezone_id=self._settings.browser_timezone_id,
user_agent=self._settings.browser_user_agent,
viewport=None if not self._settings.browser_headless_effective else {"width": 1440, "height": 900},
storage_state=storage_state,
)
break
except Exception as exc:
if attempt == 0 and self._should_retry_context_creation(exc):
logger.warning("创建浏览器上下文失败,尝试重建浏览器后重试一次:err=%s", exc)
await self._restart_browser(f"new_context failed: {exc}")
continue
raise
if self._settings.browser_stealth_enabled:
try:
from playwright_stealth import Stealth
await Stealth().apply_stealth_async(context)
# logger.debug("已为浏览器上下文应用 Stealth 模式")
except Exception as e:
logger.warning("应用 playwright-stealth 失败,隐身模式未生效, err=%s", e)
page = await context.new_page()
await page.set_extra_http_headers(
{
"Accept-Language": "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
}
)
yield context, page
except Exception:
if self._settings.browser_keep_open_on_error_effective and context is not None:
await self._retain_context(context, page, reason="抓取失败,已保留页面用于排查")
logger.warning(
"本地调试模式已保留浏览器页面用于排查,窗口不会自动关闭;请手动查看页面。当前保留上下文数=%s",
len(self._retained_contexts),
)
page = None
context = None
raise
finally:
# 本地调试模式下保留窗口
if context is not None and should_keep_open_on_success:
await self._retain_context(context, page, reason="本地调试:已保留页面(请求成功)")
page = None
context = None
if page is not None:
await page.close()
if context is not None:
await context.close()
self._semaphore.release()
+383
View File
@@ -0,0 +1,383 @@
import asyncio
import logging
import random
import re
from playwright.async_api import async_playwright, Page
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from selectolax.parser import HTMLParser
from app.core.config import Settings
from app.models.scrape import CargoRequest
from app.utils.parse_util import extract_product_id, format_price
logger = logging.getLogger(__name__)
class CargoService:
"""购物车服务"""
def __init__(self, settings: Settings):
self._settings = settings
async def get_cargo_info(self, request: CargoRequest) -> dict:
"""获取购物车信息
每次访问都会启动一个全新的 Playwright 浏览器实例,
创建全新的上下文,避免任何状态残留。
先将指定的商品加入购物车,然后再进入购物车页面解析信息。
"""
async with async_playwright() as p:
headless = self._settings.browser_headless_effective
launch_args = [
"--disable-blink-features=AutomationControlled",
"--disable-infobars",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--use-mock-keychain",
"--password-store=basic",
]
if not headless:
launch_args.extend(["--start-maximized", "--window-position=0,0"])
# 启动全新的浏览器进程
browser = await p.chromium.launch(
headless=headless,
channel=self._settings.browser_channel,
proxy=self._settings.proxy,
args=launch_args,
)
try:
# 创建全新上下文
context = await browser.new_context(
locale=self._settings.browser_locale,
timezone_id=self._settings.browser_timezone_id,
user_agent=self._settings.browser_user_agent,
viewport=None if not headless else {"width": 1440, "height": 900},
)
if self._settings.browser_stealth_enabled:
try:
from playwright_stealth import Stealth
await Stealth().apply_stealth_async(context)
except Exception as e:
logger.warning("应用 playwright-stealth 失败: %s", e)
page = await context.new_page()
await page.set_extra_http_headers(
{
"Accept-Language": "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
}
)
target_url = "https://www.suruga-ya.jp/cargo/detail"
# 先进入购物车页面
await page.goto(target_url, wait_until="domcontentloaded", timeout=15000)
# 判断购物车是否有商品
cart_goods_num = await page.locator("table.item_box tbody tr.item").count()
# 清空购物车
await asyncio.sleep(random.uniform(1, 3)) # 加入随机延迟,模拟人工操作间隔
if cart_goods_num > 0:
await self.clear_cart(page)
# 将待采购的商品加入购物车
item_list = request.payload.item_list
for index, item in enumerate(item_list):
product_id = item.id
if not product_id:
logger.warning("索引 %s 的商品未提供 id,跳过。", index)
continue
number = item.number or 1
if number <= 0:
logger.warning("索引 %s 的商品数量为 0,跳过。", index)
continue
logger.debug("进入商品 %s 详情页,准备购买数量: %s...", product_id, number)
detail_url = f"https://www.suruga-ya.jp/product/detail/{product_id}"
await page.goto(detail_url, wait_until="domcontentloaded", timeout=20000)
# 判断是否缺货
out_of_stock_dom = page.locator("div.out-of-stock-text")
is_out_of_stock = await out_of_stock_dom.is_visible()
if is_out_of_stock:
logger.warning("商品 %s 缺货,跳过", product_id)
continue
# 提取商品最大购买数量
try:
quantity_select = page.locator("#quantity_selection")
await quantity_select.wait_for(state="visible", timeout=5000)
last_option_locator = quantity_select.locator("option").last
buy_number_limit_str = await last_option_locator.get_attribute("value")
buy_number_limit = int(buy_number_limit_str) if buy_number_limit_str else 0
logger.debug("商品 %s 最大购买数量: %s", product_id, buy_number_limit)
if 0 < buy_number_limit < number:
number = buy_number_limit
except PlaywrightTimeoutError:
logger.debug("商品 %s 没有数量选择框,默认购买数量: %s", product_id, number)
try:
btn_buy = page.locator("button.btn_buy").first
await btn_buy.wait_for(state="visible", timeout=5000)
if await btn_buy.is_disabled():
logger.warning("商品 %s 已售罄或无法购买(按钮被禁用),跳过", product_id)
continue
# 根据 number 循环点击加入购物车
for i in range(number):
logger.debug("点击加入购物车按钮,第 %s", i + 1)
await btn_buy.click()
if i < number - 1:
# 多次点击之间加入随机延迟,防止被识别为机器人或触发频率限制
await asyncio.sleep(random.uniform(0.8, 1.5))
except PlaywrightTimeoutError:
logger.warning("商品 %s 找不到购买按钮(可能是已下架/缺货),跳过", product_id)
continue
logger.info("再次访问购物车页面: %s", target_url)
await page.goto(target_url, wait_until="domcontentloaded", timeout=15000)
# 解析购物车
try:
primary = page.locator("#container #main3 #primary")
if not await primary.is_visible(timeout=10000):
logger.warning("购物车商品元素未找到,跳过解析")
raise Exception("购物车商品获取失败")
result = await self.parse_cargo_info(page)
except Exception as e:
logger.warning("购物车价格元素未找到,跳过解析: %s", e)
raise Exception("购物车价格获取失败")
return result
finally:
await browser.close()
async def parse_cargo_info(self, page: Page):
"""
解析商品总额,运费和手续费金额等信息
"""
logger.debug("解析购物车信息")
total_goods_amount = 0
total_shipping_fee = 0
total_commission_fee = 0
items: list[dict] = []
total_price_dom = page.locator("#total_box div.total_price")
try:
await total_price_dom.first.wait_for(state="attached", timeout=20000)
total_price_str = await total_price_dom.locator("p.price_name .total_price").first.text_content()
if total_price_str:
total_goods_amount = format_price(total_price_str.strip()) or 0
logger.debug("商品总额: %s", total_goods_amount)
delivery_charge_html = await total_price_dom.locator("p.delivery_charge").first.inner_html()
if delivery_charge_html:
shipping_match = re.search(r"([\d,]+)(?=円の送料)", delivery_charge_html)
if shipping_match:
total_shipping_fee = int(shipping_match.group(1).replace(",", ""))
logger.debug("运费: %s", total_shipping_fee)
handling_match = re.search(r"通信販売手数料.*?:([\d,]+)(?=円)", delivery_charge_html)
if handling_match:
total_commission_fee = int(handling_match.group(1).replace(",", ""))
logger.debug("手续费: %s", total_commission_fee)
except PlaywrightTimeoutError:
pass
table = page.locator("#container #main3 #primary table.item_box")
await table.wait_for(state="visible", timeout=10000)
rows = table.locator("tr.item_row")
for i in range(await rows.count()):
row = rows.nth(i)
logger.debug("解析商品 %s", i)
link_node = row.locator("td.photo_box a").first
href = await link_node.get_attribute("href") or ""
img_node = row.locator("td.photo_box img").first
goods_image = await img_node.get_attribute("src") or ""
name_node = row.locator("td.item_detail .item_name a").first
goods_name = (await name_node.text_content() or "").strip()
price_node = row.locator("td.price").first
price_text = (await price_node.text_content() or "").strip()
goods_price = format_price(price_text) or 0
quantity = 1
qty_input = row.locator("input[name*='qty'], input[name*='quantity']").first
if await qty_input.count():
qty_val = (await qty_input.get_attribute("value") or "").strip()
if qty_val.isdigit():
quantity = int(qty_val)
else:
qty_select = row.locator("select[name*='qty'], select[name*='quantity'], select.quantity").first
if await qty_select.count():
selected = qty_select.locator("option[selected]").first
if await selected.count():
qty_val = (await selected.get_attribute("value") or "").strip()
if qty_val.isdigit():
quantity = int(qty_val)
items.append(
{
"goods_id": extract_product_id(href),
"goods_name": goods_name,
"goods_price": goods_price,
"goods_image": goods_image,
"quantity": quantity,
}
)
if total_goods_amount <= 0:
total_goods_amount = sum(item["goods_price"] * item["quantity"] for item in items)
return {
"total_goods_amount": total_goods_amount,
"total_shipping_fee": total_shipping_fee,
"total_commission_fee": total_commission_fee,
"items": items,
}
def _parse_cargo_html(self, html: str) -> dict:
tree = HTMLParser(html)
items = []
# 根据常见的骏河屋购物车结构进行解析
item_nodes = tree.css("table.cart_table tr.item_row, .cart-item")
if not item_nodes:
item_nodes = tree.css("div.item_box") # Fallback
for node in item_nodes:
# 解析商品ID
id_node = node.css_first("input[name='goods_id'], .goods_id")
goods_id = id_node.attributes.get("value", "") if id_node else ""
if not goods_id:
# 尝试从链接提取
link = node.css_first("a[href*='/product/detail/']")
if link:
href = link.attributes.get("href", "")
import re
match = re.search(r'/product/detail/([A-Za-z0-9]+)', href)
if match:
goods_id = match.group(1)
if not goods_id:
continue
# 解析商品名称
name_node = node.css_first(".item_name, .title, p.name")
goods_name = name_node.text().strip() if name_node else f"product-{goods_id}"
# 解析商品价格
price_node = node.css_first(".price, .item_price, td.price")
price_text = price_node.text().strip() if price_node else "0"
goods_price = format_price(price_text) or 0
# 解析商品图片
img_node = node.css_first("img")
goods_image = img_node.attributes.get("src", "") if img_node else ""
items.append({
"goods_id": goods_id,
"goods_name": goods_name,
"goods_price": goods_price,
"goods_image": goods_image,
"number": 1
})
# 提取金额信息
total_goods_amount = 0
total_shipping_fee = 0
total_commission_fee = 0
# 寻找包含金额的总计区域
summary_nodes = tree.css(".summary_box, .total_box, table.total_table tr")
for node in summary_nodes:
text = node.text().replace(" ", "").replace("\n", "")
if "商品合計" in text or "小計" in text:
price_node = node.css_first(".price, td:last-child")
if price_node:
total_goods_amount = format_price(price_node.text().strip()) or 0
elif "送料" in text:
price_node = node.css_first(".price, td:last-child")
if price_node:
total_shipping_fee = format_price(price_node.text().strip()) or 0
elif "通信販売手数料" in text or "代引手数料" in text or "手数料" in text:
price_node = node.css_first(".price, td:last-child")
if price_node:
total_commission_fee = format_price(price_node.text().strip()) or 0
return {
"items": items,
"total_goods_amount": total_goods_amount,
"total_shipping_fee": total_shipping_fee,
"total_commission_fee": total_commission_fee
}
async def clear_cart(self, page):
"""
清空购物车
"""
cart_goods_num = await page.locator("table.item_box tbody tr.item").count()
if cart_goods_num < 1:
return
is_delete = 0
# 购物车商品数量超过3个后会出现"全削除"按钮
if cart_goods_num > 3:
try:
delete_button = page.locator("#total_box .delbtn-all").get_by_role("button", name="全削除")
# 3. 显式等待按钮在页面上可见(容错处理:比如给它最多 3000 毫秒的加载时间)
# 如果按钮是一开始就在静态 HTML 里的,这一步也可以省略,直接用下面的 is_visible()
await delete_button.wait_for(state="visible", timeout=3000)
if await delete_button.is_visible():
logger.debug("检测到‘全削除’按钮存在,正在执行点击...")
await delete_button.click()
is_delete = 1
except Exception:
logger.debug("未检测到‘全削除’按钮。")
if is_delete == 1:
return
# 遍历删除
item_selector = "table.item_box tbody tr.item"
remove_selector = "table.item_box a.remove_product"
while True:
cart_goods_num = await page.locator(item_selector).count()
if cart_goods_num == 0:
break
remove_link = page.locator(remove_selector).first
try:
await remove_link.click()
# 删除按钮不会触发弹窗,直接等待购物车条目数减少即可。
await page.wait_for_function(
"""({selector, expectedCount}) => {
return document.querySelectorAll(selector).length < expectedCount;
}""",
arg={"selector": item_selector, "expectedCount": cart_goods_num},
timeout=15_000,
)
await page.wait_for_timeout(random.randint(800, 1500))
except PlaywrightTimeoutError:
latest_count = await page.locator(item_selector).count()
if latest_count < cart_goods_num:
continue
await page.wait_for_timeout(1000)
+252
View File
@@ -0,0 +1,252 @@
"""Cloudflare 会话管理器:自动获取并复用通过验证的浏览器会话
骏河屋网站受 Cloudflare 保护,首次访问需通过浏览器完成 JS 挑战。
本模块负责:
- 检测并等待 Cloudflare 挑战完成
- 保存通过验证后的浏览器存储状态(含 cf_clearance Cookie)
- 后续请求复用已验证会话,避免重复触发挑战
- 会话失效时支持主动失效和重建
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
from collections.abc import Iterable
from dataclasses import dataclass
from urllib.parse import urlparse
from uuid import uuid4
from pathlib import Path
from app.core.config import Settings
from app.core.errors import CloudflareChallengeError, UpstreamBlockedError
from app.services.browser_pool import BrowserPool
from app.services.session_store import SessionState, SessionStore
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class VerifiedSession:
"""已通过 Cloudflare 验证的会话信息"""
session_id: str
storage_state: dict
cf_clearance: str | None # cf_clearance Cookie 值
html: str | None = None # 新创建会话时携带的页面 HTML,复用场景为 None
class CloudflareSessionManager:
"""Cloudflare 会话管理器,负责会话的获取、验证和失效"""
def __init__(
self,
settings: Settings,
browser_pool: BrowserPool,
session_store: SessionStore,
):
self._settings = settings
self._browser_pool = browser_pool
self._session_store = session_store
async def get_verified_session(self, target_url: str, force_refresh: bool = False) -> VerifiedSession:
"""获取一个已通过 Cloudflare 验证的会话
优先复用已有会话;当 force_refresh=True 或无可用会话时,
启动浏览器访问目标页面,等待 Cloudflare 挑战通过后保存会话。
Args:
target_url: 目标页面 URL,用于确定会话名称
force_refresh: 是否强制创建新会话(忽略已有会话)
Returns:
VerifiedSession: 已验证的会话信息
Raises:
UpstreamBlockedError: 目标站点返回阻断响应(如 1020/403)
CloudflareChallengeError: 等待 Cloudflare 挑战超时
"""
session_name = self._session_name_for_url(target_url)
if not force_refresh:
existing = await self._session_store.get_session(session_name)
if existing is not None:
logger.debug("复用已验证会话:session_name=%s session_id=%s", session_name, existing.session_id)
return self._to_verified_session(existing)
logger.debug("创建新会话:session_name=%s force_refresh=%s", session_name, force_refresh)
session = await self._create_verified_session(session_name=session_name, target_url=target_url)
return session
async def invalidate(self, target_url: str) -> None:
"""主动让会话失效
当识别到被阻断/挑战失败时调用,下次请求将创建新会话。
Args:
target_url: 目标页面 URL,用于定位需要失效的会话
"""
session_name = self._session_name_for_url(target_url)
logger.warning("会话失效:session_name=%s", session_name)
await self._session_store.invalidate_session(session_name)
async def _create_verified_session(self, session_name: str, target_url: str) -> VerifiedSession:
"""通过真实浏览器访问目标页面,让 Cloudflare 挑战在浏览器侧完成
流程:
1. 从浏览器池获取页面会话
2. 访问目标页面
3. 等待 Cloudflare 挑战通过
4. 保存浏览器存储状态和 cf_clearance Cookie
5. 同时返回页面 HTML,避免调用方二次加载同一页面
"""
async with self._browser_pool.page_session(keep_open_on_success=False) as (context, page):
logger.debug("开始访问(用于通过挑战):%s", target_url)
response = await page.goto(
target_url,
wait_until="domcontentloaded",
timeout=int(self._settings.request_timeout_seconds * 1000),
)
status = response.status
if status != 200:
try:
screenshot_dir = Path(__file__).resolve().parents[2] / self._settings.log_dir / "screenshots"
screenshot_dir.mkdir(parents=True, exist_ok=True)
screenshot_file = screenshot_dir / f"{uuid4().hex}.png"
await page.screenshot(path=str(screenshot_file), full_page=True)
screenshot_path = str(screenshot_file)
except Exception:
screenshot_path = None
raise UpstreamBlockedError(f"Upstream status={status}, 页面返回非200状态码, 截图地址:{screenshot_path}")
await self._wait_for_clearance(page)
html_content = await page.content()
storage_state = await context.storage_state()
cookies = await context.cookies()
cf_clearance = self._get_cookie_value(cookies, "cf_clearance")
logger.debug("挑战通过:session_name=%s cf_clearance=%s", session_name, bool(cf_clearance))
saved = await self._session_store.save_session(
session_name=session_name,
session_id=str(uuid4()),
user_agent=self._settings.browser_user_agent,
proxy_key=self._settings.proxy_server or "direct",
storage_state=storage_state,
cf_clearance=cf_clearance,
)
return VerifiedSession(
session_id=saved.session_id,
storage_state=saved.storage_state,
cf_clearance=saved.cf_clearance,
html=html_content,
)
async def _wait_for_clearance(self, page: object) -> None:
"""检测并等待 Cloudflare 挑战完成
循环检测页面标题和内容:
- 如果检测到阻断响应(如 1020/403),直接抛出异常
- 如果检测到挑战页面(如 "Just a moment"),继续等待
- 如果既非阻断也非挑战,认为已通过
Raises:
UpstreamBlockedError: 检测到被 Cloudflare 阻断
CloudflareChallengeError: 等待挑战超时
"""
timeout_at = asyncio.get_running_loop().time() + self._settings.cloudflare_wait_timeout_seconds
while True:
title = (await page.title()).lower()
content = (await page.content()).lower()
if self._is_blocked_response(title, content):
logger.warning("检测到被阻断响应(可能是 1020/403)")
raise UpstreamBlockedError("Cloudflare returned a blocked response")
if not self._looks_like_challenge(title, content):
return
try:
cf_iframe = page.frame_locator('iframe[src*="turnstile"], iframe[src*="cloudflare"]')
if await cf_iframe.locator('input[type="checkbox"]').count() > 0:
checkbox = cf_iframe.locator('input[type="checkbox"]')
if await checkbox.is_visible():
logger.debug("检测到 Turnstile 复选框,尝试自动点击")
await checkbox.click()
await page.wait_for_timeout(2000)
except Exception as e:
logger.debug("自动点击 Turnstile 失败或未找到: %s", e)
if asyncio.get_running_loop().time() >= timeout_at:
logger.warning("等待挑战超时(秒):%s", self._settings.cloudflare_wait_timeout_seconds)
raise CloudflareChallengeError()
await page.wait_for_timeout(1500)
@staticmethod
def _get_cookie_value(cookies: Iterable[dict], name: str) -> str | None:
"""从 Cookie 列表中获取指定名称的 Cookie 值"""
for cookie in cookies:
if cookie.get("name") == name:
return cookie.get("value")
return None
@staticmethod
def _looks_like_challenge(title: str, content: str) -> bool:
"""判断页面是否仍处于 Cloudflare 挑战中
先排除正常页面(搜索列表/商品详情页中也可能包含 Cloudflare 脚本),
再检查标题和内容中是否包含挑战标志(如 "Just a moment")。
"""
# 正常页面的 DOM 特征,出现这些说明挑战已通过
if (
'class="item_box' in content
or "product-name" in content
or 'id="item_title"' in content
or "h1_title_product" in content
or 'id="item_detailinfo"' in content
or "tbl_product_info" in content
or "商品詳細情報" in content
):
return False
challenge_markers = (
"just a moment",
"checking your browser",
"verify you are human",
"attention required",
"cf-challenge",
"cf-turnstile",
"cf_chl_opt",
"cf-please-wait",
"challenge-form",
)
return any(marker in title or marker in content for marker in challenge_markers)
@staticmethod
def _is_blocked_response(title: str, content: str) -> bool:
"""判断页面是否为 Cloudflare 阻断响应(如 1020 错误)"""
blocked_markers = (
"access denied",
"error code 1020",
"forbidden",
"temporarily blocked",
)
return any(marker in title or marker in content for marker in blocked_markers)
def _session_name_for_url(self, target_url: str) -> str:
"""根据 URL、代理和 User-Agent 生成会话名称
同一域名下仍共享会话,但会按当前代理与 UA 做进一步隔离,
避免不同运行身份误复用同一套 Cloudflare 验证状态。
"""
parsed = urlparse(target_url)
proxy_key = self._settings.proxy_server or "direct"
user_agent = self._settings.browser_user_agent
user_agent_hash = hashlib.sha1(user_agent.encode("utf-8")).hexdigest()[:12]
return f"{parsed.netloc}|{proxy_key}|{user_agent_hash}"
@staticmethod
def _to_verified_session(state: SessionState) -> VerifiedSession:
"""将 SessionState 转换为 VerifiedSession(复用场景,html 为空)"""
return VerifiedSession(
session_id=state.session_id,
storage_state=state.storage_state,
cf_clearance=state.cf_clearance,
html=None,
)
+139
View File
@@ -0,0 +1,139 @@
"""会话存储:管理 Cloudflare 验证通过后的浏览器会话状态
支持两种存储后端:
- Redis(推荐生产环境使用,支持多实例共享)
- 进程内存(默认回退,仅单实例可用)
会话包含浏览器存储状态(Cookie、localStorage 等)和 cf_clearance 值,
设置 TTL 自动过期,过期后需要重新通过 Cloudflare 挑战。
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from app.core.config import Settings
try:
from redis.asyncio import Redis
except Exception: # pragma: no cover - optional until dependencies are installed.
Redis = Any # type: ignore[misc,assignment]
@dataclass(slots=True)
class SessionState:
"""会话状态数据"""
session_id: str
user_agent: str
proxy_key: str
storage_state: dict[str, Any]
cf_clearance: str | None
created_at: str
expires_at: str
@property
def is_expired(self) -> bool:
"""会话是否已过期"""
return datetime.now(UTC) >= datetime.fromisoformat(self.expires_at)
class SessionStore:
"""会话存储,支持 Redis 和进程内存两种后端"""
def __init__(self, settings: Settings):
self._settings = settings
self._redis: Redis | None = None
self._memory: dict[str, SessionState] = {}
async def start(self) -> None:
"""初始化存储后端:配置了 Redis 时连接 Redis,否则使用进程内存"""
if not self._settings.redis_host:
return
from redis.asyncio import Redis as AsyncRedis
self._redis = AsyncRedis(
host=self._settings.redis_host,
port=self._settings.redis_port,
password=self._settings.redis_password,
db=self._settings.redis_db,
decode_responses=True,
)
await self._redis.ping()
async def close(self) -> None:
"""关闭存储后端连接"""
if self._redis is not None:
await self._redis.aclose()
self._redis = None
@property
def redis_enabled(self) -> bool:
"""Redis 是否已连接"""
return self._redis is not None
def _session_key(self, session_name: str) -> str:
"""生成 Redis/内存中的存储键"""
return f"{self._settings.session_namespace}:session:{session_name}"
async def get_session(self, session_name: str) -> SessionState | None:
"""获取会话状态,过期会话自动失效并返回 None"""
key = self._session_key(session_name)
if self._redis is not None:
raw = await self._redis.get(key)
if not raw:
return None
session = SessionState(**json.loads(raw))
if session.is_expired:
await self.invalidate_session(session_name)
return None
return session
session = self._memory.get(key)
if session and session.is_expired:
self._memory.pop(key, None)
return None
return session
async def save_session(
self,
session_name: str,
session_id: str,
user_agent: str,
proxy_key: str,
storage_state: dict[str, Any],
cf_clearance: str | None,
) -> SessionState:
"""保存会话状态
创建带 TTL 的会话记录,Redis 后端会自动在 TTL 到期后删除。
"""
now = datetime.now(UTC)
state = SessionState(
session_id=session_id,
user_agent=user_agent,
proxy_key=proxy_key,
storage_state=storage_state,
cf_clearance=cf_clearance,
created_at=now.isoformat(),
expires_at=(now + timedelta(seconds=self._settings.session_ttl_seconds)).isoformat(),
)
key = self._session_key(session_name)
if self._redis is not None:
ttl = self._settings.session_ttl_seconds
await self._redis.set(key, json.dumps(asdict(state)), ex=ttl)
return state
self._memory[key] = state
return state
async def invalidate_session(self, session_name: str) -> None:
"""使会话失效,删除存储中的会话记录"""
key = self._session_key(session_name)
if self._redis is not None:
await self._redis.delete(key)
return
self._memory.pop(key, None)
+615
View File
@@ -0,0 +1,615 @@
"""骏河屋(suruga-ya.jp)抓取客户端
负责搜索列表和商品详情的抓取与解析。
通过 Cloudflare 会话复用机制,避免每次请求都触发验证挑战。
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from datetime import datetime
from pathlib import Path
from urllib.parse import parse_qs, urlencode, urlparse
from uuid import uuid4
from typing import Any
from selectolax.parser import HTMLParser
from app.core.config import Settings
from app.core.errors import ScrapeParseError, UpstreamBlockedError
from app.models.scrape import CategoryData, DetailRequest, ProductDetailData, ProductSummary, SearchRequest, \
SearchResultData
from app.services.browser_pool import BrowserPool
from app.services.cloudflare_session import CloudflareSessionManager
from app.services.session_store import SessionStore
from app.utils.app_utils import is_empty_str
from app.utils.parse_util import format_price, get_shipping_fee, surugaya_photo_url_to_cdn
logger = logging.getLogger(__name__)
class SurugayaClient:
"""骏河屋抓取客户端,提供搜索和商品详情两个核心能力
工作流程:
1. 获取已通过 Cloudflare 验证的会话(优先复用,必要时新建)
2. 使用浏览器池中的页面,携带会话 Cookie 访问目标页面
3. 解析页面 HTML 提取结构化数据
"""
def __init__(
self,
settings: Settings,
browser_pool: BrowserPool,
session_manager: CloudflareSessionManager,
session_store: SessionStore,
):
self._settings = settings
self._browser_pool = browser_pool
self._session_manager = session_manager
self._session_store = session_store
self._categories_cache_key = "surugaya:categories:cache"
async def _raise_upstream_error(self, *, page: object, response: object | None, url: str) -> None:
status = getattr(response, "status", None)
try:
await self._session_manager.invalidate(url)
except Exception as exc:
logger.warning("清理上游异常会话失败:url=%s err=%s", url, exc)
screenshot_path: str | None = None
try:
screenshot_dir = Path(__file__).resolve().parents[2] / self._settings.log_dir / "screenshots"
screenshot_dir.mkdir(parents=True, exist_ok=True)
screenshot_file = screenshot_dir / f"{uuid4().hex}.png"
await page.screenshot(path=str(screenshot_file), full_page=True)
screenshot_path = str(screenshot_file)
except Exception:
screenshot_path = None
raise UpstreamBlockedError(f"Upstream status:{status}, 截图地址:{screenshot_path}")
async def search(self, payload: SearchRequest) -> SearchResultData:
"""抓取搜索列表页
流程:
1. 构建/使用搜索 URL
2. 获取已通过 Cloudflare 验证的会话
3. 用浏览器页面访问并获取 HTML
4. 解析商品总数和商品列表
Args:
payload: 搜索请求参数,包含 search_word、page 等
Returns:
SearchResultData: 包含搜索结果列表、总数、分页信息等
"""
logger.debug("开始抓取搜索页:payload=%s", json.dumps(payload.model_dump(), ensure_ascii=False))
url = str(payload.search_url) if payload.search_url else self._build_search_url(payload)
session = await self._session_manager.get_verified_session(url)
if session.html is not None:
# 新创建的会话已携带页面 HTML,直接使用,避免二次加载
html = session.html
else:
async with self._browser_pool.page_session(storage_state=session.storage_state) as (_, page):
response = await page.goto(
url,
wait_until="domcontentloaded",
timeout=int(self._settings.request_timeout_seconds * 1000),
)
status = response.status if response is not None else None
if status != 200:
await self._raise_upstream_error(page=page, response=response, url=url)
html = await page.content()
tree = HTMLParser(html)
# 解析商品总数量,格式示例:該当件数:10,428件中 1-24件
current_page = self._extract_page_value(payload)
total_count = 0
page_size = 24
has_more = 0
hit_node = tree.css_first("#search_header .search_option .hit")
if hit_node:
raw_text = hit_node.text()
match = re.search(r'該当件数:([\d,]+)件', raw_text)
if match:
total_count = int(match.group(1).replace(',', ''))
if total_count > 0:
has_more = int(current_page * page_size < total_count)
# 解析商品列表
items = self._parse_search_items(tree)
logger.info("搜索页解析完成 ===> 关键词: %s 抓取商品数=%s", payload.search_word, len(items))
return SearchResultData(
query=payload.search_word,
page=current_page,
page_size=page_size,
total_count=total_count,
has_more=has_more,
items=items,
session_id=session.session_id,
)
async def fetch_detail(self, payload: DetailRequest) -> ProductDetailData:
"""抓取商品详情页
流程:
1. 根据请求参数确定商品 URL 和 ID(支持直接传 ID 或完整 URL)
2. 获取已通过 Cloudflare 验证的会话
3. 用浏览器页面访问并获取 HTML
4. 从 DOM 中提取商品名称、图片、价格、售卖状态、描述等
Args:
payload: 详情请求参数,包含 id 或 product_url
Returns:
ProductDetailData: 商品详情数据
Raises:
ScrapeParseError: 当 id 和 product_url 都未提供时
"""
goods_id = payload.id or ""
if is_empty_str(goods_id):
raise ScrapeParseError("id is empty")
url = self._build_product_url(goods_id)
logger.info("开始抓取详情页:goods_id=%s", goods_id)
logger.debug("详情页URL:url=%s", url)
# 抓取页面 HTML
session = await self._session_manager.get_verified_session(url)
if session.html is not None:
html = session.html
else:
async with self._browser_pool.page_session(storage_state=session.storage_state) as (_, page):
response = await page.goto(
url,
wait_until="domcontentloaded",
timeout=int(self._settings.request_timeout_seconds * 1000),
)
status = response.status if response is not None else None
if status != 200:
await self._raise_upstream_error(page=page, response=response, url=url)
html = await page.content()
tree = HTMLParser(html)
# 提取商品标题
title_node = tree.css_first("#item_title")
goods_name = title_node.text().strip() if title_node else None
if not goods_name:
raise ScrapeParseError("商品名称解析失败")
# 提取商品主图
img_node = tree.css_first("div.easyzoom--with-thumbnails img")
goods_image: str = (img_node.attributes.get("src") or "") if img_node else ""
if goods_image and "database/images/no_photo.jpg" in goods_image:
goods_image = "https://oss.daimatech.com/suruga-ya/no_photo.jpg"
# 提取商品价格:优先从 .price_choise 中取,其次从 span.purchase-price 中取
price_text = ""
price_choise = tree.css_first("#cart .price_choise")
if price_choise:
buy_node = price_choise.css_first(".text-price-detail.price-buy")
if buy_node:
price_text = buy_node.text().strip()
if not price_text:
purchase_node = tree.css_first("span.purchase-price")
if purchase_node:
price_text = purchase_node.text().strip()
goods_price: int = format_price(price_text) or 0 if price_text else 0
# 判断售卖状态:
out_of_stock_dom = tree.css_first("div.out-of-stock-text")
on_sold = 0 if out_of_stock_dom else 1
# 解析规格列表
sku = ""
sku_list = []
pdom = tree.css_first("#item_title")
if pdom and pdom.parent:
direct_children = pdom.parent.css(".price_group > .item-price")
sku_index = 0
for node in direct_children:
sku_index += 1
vo: dict[str, Any] = {"sku_name": "", "price": 0, "stock": 0}
label_node = node.css_first("label")
if label_node:
vo["sku_name"] = label_node.attributes.get("data-label", "").strip()
input_node = node.css_first("input.form-check-input")
if input_node:
zaiko_str = input_node.attributes.get("data-zaiko", "")
if zaiko_str and zaiko_str != "null":
try:
zaiko = json.loads(zaiko_str)
if isinstance(zaiko, dict):
vo.update(zaiko)
vo["price"] = int(zaiko.get("baika", 0))
vo["stock"] = int(zaiko.get("zaiko", 0))
vo["sku_id"] = str(sku_index)
if sku_index == 1:
sku = vo["sku_id"]
except json.JSONDecodeError:
vo["zaiko_raw"] = zaiko_str
sku_list.append(vo)
# 提取库存
stock = 0
if sku_list:
# 从 SKU 列表中找到匹配当前 SKU 的库存
stock = next((item["stock"] for item in sku_list if item.get("sku_id") == sku), 0)
else:
quantity_selection = tree.css_first("#quantity_selection")
if quantity_selection:
stock = 1
last_option = quantity_selection.css_first("option:last-child")
if last_option:
option_val = last_option.attributes.get("value")
if option_val and option_val.isdigit():
stock = int(option_val)
# 提取商品描述
desc_note = tree.css_first("p.note.text-break")
description = desc_note.text().strip() if desc_note else ""
# 提取店铺来源
supplier = "" # 默认表示官方自营
# 利用与目标 div.mb-2 平级的 #naviplus-review-list-5 缩小查找范围以提升效率
review_node = tree.css_first("#naviplus-review-list-5")
if review_node and review_node.parent:
context_node = review_node.parent
for node in context_node.css("div.mb-2"):
text = node.text()
if "この商品は" in text and "販売" in text and "発送" in text:
# 提取 mb-2 下的全部文本,并清理多余的换行和空白字符
supplier = " ".join(text.split())
break
# 提取价格备注
price_note = None
price_note_node = tree.css_first(".item-price-note-wrap")
if price_note_node:
price_note = price_note_node.text().strip()
# 提取运费相关
shipping_comission_fee = get_shipping_fee(goods_price)
# 提取面包屑
breadcrumb_list = self.get_breadcrumb_list(tree)
detail = ProductDetailData(
goods_id=goods_id,
goods_name=goods_name,
goods_image=goods_image,
goods_price=goods_price,
stock=stock,
on_sold=on_sold,
sku=sku,
sku_list=sku_list,
description=description,
supplier=supplier,
price_note=price_note,
shipping_comission_fee=shipping_comission_fee,
breadcrumb_list=breadcrumb_list,
)
logger.debug("详情页解析完成:goods_id=%s goods_name=%s ", detail.goods_id, detail.goods_name)
return detail
async def fetch_categories(self) -> list[CategoryData]:
"""获取商品分类(带缓存)
解析骏河屋的商品分类,由于分类不易变动,
优先从 redis 获取缓存,若 redis 未配置或缓存失效,则重新抓取。
默认缓存 24 小时。
"""
# 如果配置了 Redis,尝试从 Redis 获取缓存
if self._session_store._redis is not None:
try:
cached_data = await self._session_store._redis.get(self._categories_cache_key)
if cached_data:
logger.debug("命中商品分类 Redis 缓存")
data_list = json.loads(cached_data)
return [CategoryData(**item) for item in data_list]
except Exception as e:
logger.warning("读取 Redis 缓存失败: %s", e)
logger.info("开始获取并解析商品分类...")
top_categories: list[CategoryData] = []
target_url = "https://www.suruga-ya.jp/"
session = await self._session_manager.get_verified_session(target_url, True)
html_content = ""
if session.html is not None:
html_content = session.html
else:
async with self._browser_pool.page_session(storage_state=session.storage_state) as (_, page):
try:
response = await page.goto(
target_url,
wait_until="domcontentloaded",
timeout=int(self._settings.request_timeout_seconds * 1000),
)
status = response.status if response is not None else None
if status != 200:
raise ScrapeParseError(f"抓取首页分类失败,状态码 {status}")
html_content = await page.content()
except Exception as e:
logger.error("抓取首页分类失败:%s", e)
raise ScrapeParseError(f"抓取首页分类失败: {e!s}") from e
tree = HTMLParser(html_content)
# 解析一级分类列表
cate_nodes = tree.css(".catemenu_group .cate_menu .cate_item")
for cate_item in cate_nodes:
href = cate_item.css_first("a").attributes.get("href")
if not href:
continue
# 提取 href 的路径名(不含 .html 和前置的 /,例如:/avsoft.html -> avsoft)
match = re.search(r'/([^/]+)(?:\.html|/)', href)
if not match:
continue
cate_id = match.group(1)
# 过滤掉指定的分类
if cate_id in ["boyslove"]:
continue
# 提取包含的文字内容(拼接多个 span,例如 おもちゃ・ + ホビー)
h4_node = cate_item.css_first("a h4")
if not h4_node:
continue
cate_name = h4_node.text().strip()
# 清理多余空格与换行,防止拼接文本里出现多余空白
cate_name = "".join(cate_name.split())
icon_node = cate_item.css_first("a img")
icon = icon_node.attributes.get("src") if icon_node else None
pid = "0"
top_categories.append(CategoryData(id=cate_id, name=cate_name, icon=icon, pid=pid, href=href))
# 解析二级分类列表
seen_pairs: set[tuple[str, str]] = set()
async with self._browser_pool.page_session(storage_state=session.storage_state) as (_, page):
for top_category in top_categories:
try:
category_url = f"https://www.suruga-ya.jp{top_category.href}"
response = await page.goto(
category_url,
wait_until="domcontentloaded",
timeout=int(self._settings.request_timeout_seconds * 1000),
)
status = response.status if response is not None else None
if status != 200:
await self._raise_upstream_error(page=page, response=response, url=category_url)
category_html = await page.content()
category_tree = HTMLParser(category_html)
menu_node = category_tree.css_first("#menu")
if not menu_node:
continue
related_block = None
for block in menu_node.css(".block"):
h2_node = block.css_first("h2")
if h2_node and "関連ジャンルで絞り込む" in h2_node.text():
related_block = block
break
if not related_block:
continue
for a_node in related_block.css("ul.border_bottom li a"):
sub_name = "".join(a_node.text().split())
if not sub_name:
continue
sub_href = a_node.attributes.get("href") or ""
parsed = urlparse(sub_href)
sub_id = ""
if parsed.path == "/search":
query = parse_qs(parsed.query)
sub_id = (query.get("category") or [""])[0]
if not sub_id:
match = re.search(r"/([^/]+)\.html$", parsed.path)
if match:
sub_id = match.group(1)
if not sub_id:
continue
pair = (top_category.id, sub_id)
if pair in seen_pairs:
continue
seen_pairs.add(pair)
top_category.children.append(
CategoryData(id=sub_id, name=sub_name, pid=top_category.id, href=sub_href))
# 休眠1秒,避免对服务器造成过大压力
await asyncio.sleep(1)
except Exception as e:
logger.warning("抓取二级分类失败:top_id=%s err=%s", top_category.id, e)
continue
# 更新 Redis 缓存
if top_categories and self._session_store._redis is not None:
try:
logger.debug("更新商品分类 Redis 缓存")
data_list = [item.model_dump() for item in top_categories]
# 缓存有效期 24 小时 (86400秒)
await self._session_store._redis.setex(
self._categories_cache_key,
86400 * 30,
json.dumps(data_list, ensure_ascii=False)
)
except Exception as e:
logger.warning("写入 Redis 缓存失败: %s", e)
return top_categories
# ------------------------------------------------------------------
# 内部辅助方法
# ------------------------------------------------------------------
def _build_search_url(self, payload: SearchRequest) -> str:
"""根据搜索参数构建骏河屋搜索 URL
将 payload 中的 search_word、page 等参数拼接为完整的搜索地址。
search_url 字段本身不参与拼接,它用于直接指定完整 URL。
"""
params = payload.model_dump(exclude_none=True)
params.pop("search_url", None)
# if not str(params.get("search_word", "")).strip():
# raise ScrapeParseError("search_word is required")
query_string = urlencode(params, doseq=True)
base_url = self._settings.base_url.rstrip("/")
return f"{base_url}/search?{query_string}"
@staticmethod
def _extract_page_value(payload: SearchRequest) -> int:
"""安全地提取页码,确保返回 >= 1 的整数"""
page_raw = payload.model_dump(exclude_none=True).get("page", 1)
try:
current_page = int(page_raw)
except (TypeError, ValueError):
return 1
return current_page if current_page >= 1 else 1
def _build_product_url(self, product_id: str | None) -> str:
"""根据商品 ID 构建详情页 URL"""
if not product_id:
raise ScrapeParseError("Either product_id or product_url must be provided")
base_url = self._settings.base_url.rstrip("/")
return f"{base_url}/product/detail/{product_id}"
def _parse_search_items(self, tree: HTMLParser) -> list[ProductSummary]:
"""解析搜索结果列表
从搜索页 DOM 中提取每个商品的 ID、名称、图片、价格、市场价、售卖状态。
自动去重(按 goods_id),最多返回 50 条结果。
"""
item_nodes = tree.css("div.item_box div.item")
if not item_nodes:
item_nodes = tree.css("div.item")
items: list[ProductSummary] = []
seen_goods_ids: set[str] = set()
for node in item_nodes:
link_node = node.css_first("div.title a") or node.css_first("div.photo_box a")
href = link_node.attributes.get("href") if link_node else None
if not href:
continue
goods_link = self._normalize_url(href)
goods_id = self._extract_product_id(goods_link) or self._extract_product_id(href)
if not goods_id:
continue
if goods_id in seen_goods_ids:
continue
seen_goods_ids.add(goods_id)
name_node = node.css_first("h3.product-name")
goods_name = name_node.text().strip() if name_node else f"product-{goods_id}"
img_node = node.css_first("div.photo_box img")
goods_image_str = (img_node.attributes.get("src") or "").strip() if img_node else ""
goods_image = surugaya_photo_url_to_cdn(goods_image_str, goods_id)
# 解析商品售价(自营价格)
goods_price = 0
price_teika = node.css_first(".item_price .price_teika")
if price_teika is not None:
strong = price_teika.css_first(".text-red strong")
price_text = strong.text().strip() if strong is not None else price_teika.text().strip()
parsed_goods_price = format_price(price_text)
if parsed_goods_price is not None:
goods_price = parsed_goods_price
# 解析第三方市场价
third_shop_price = 0
makepla_strong = node.css_first(".item_price .makeplaTit .text-red strong")
if makepla_strong is not None:
price_text = makepla_strong.text().strip()
parsed_third_shop_price = format_price(price_text)
if parsed_third_shop_price is not None:
third_shop_price = parsed_third_shop_price
# 判断售卖状态:品切れ = 售罄
on_sold = 1
sold_out = node.css_first(".item_price p.price")
if sold_out is not None and "品切れ" in sold_out.text().strip():
on_sold = 0
# 售罄时,售价回退到市场价
goods_price = third_shop_price
now = str(int(datetime.now().timestamp()))
items.append(
ProductSummary(
goods_id=goods_id,
goods_name=goods_name,
goods_image=goods_image,
goods_price=goods_price,
third_shop_price=third_shop_price,
goods_link=goods_link,
on_sold=on_sold,
created_at=now,
updated_at=now,
)
)
return items
@staticmethod
def _extract_product_id(text: str) -> str | None:
"""从 URL 或文本中提取商品 ID
匹配 /product/detail/{id} 或 /product/other/{id} 格式
"""
match = re.search(r"/product/(?:detail|other)/([A-Za-z0-9]+)", text)
return match.group(1) if match else None
@staticmethod
def _normalize_url(url: str) -> str:
"""将相对 URL 补全为完整的 https URL"""
if url.startswith("http://") or url.startswith("https://"):
return url
return f"https://www.suruga-ya.jp{url}"
def get_breadcrumb_list(self, tree):
dom_list = tree.css(".block-blocksurugayabreadcrum nav .breadcrumb .breadcrumb-item")
breadcrumb_list = []
for index, dom in enumerate(dom_list):
a_node = dom.css_first("a")
if not a_node:
continue
breadcrumb_list.append({
"position": index + 1,
"is_active": index == len(dom_list) - 1,
"name": a_node.text().strip(),
"href": a_node.attributes.get("href") or "",
})
return breadcrumb_list
+8
View File
@@ -0,0 +1,8 @@
def is_valid_str(s):
"""判断字符串是否有效,即非空且非空格。"""
return isinstance(s, str) and len(s.strip()) > 0
def is_empty_str(s):
"""判断字符串是否为空,即空或仅包含空格。"""
return isinstance(s, str) and len(s.strip()) == 0
+38
View File
@@ -0,0 +1,38 @@
import json
import time
import hmac
import secrets
import hashlib
from typing import Dict, Any
def generate_signed_headers(app_id: str, app_secret: str, data: Dict[str, Any], method: str = "POST") -> Dict[str, str]:
timestamp = str(int(time.time() * 1000))
nonce = secrets.token_hex(16)
payload_str = json.dumps(data, ensure_ascii=False)
payload_bytes = payload_str.encode("utf-8")
actual_body_hash = hashlib.sha256(payload_bytes).hexdigest()
string_to_sign = "\n".join([
method.upper(),
app_id,
timestamp,
nonce,
actual_body_hash
])
signature = hmac.new(
app_secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha256
).hexdigest()
headers = {
"X-Ca-Appid": app_id,
"X-Ca-Timestamp": timestamp,
"X-Ca-Nonce": nonce,
"X-Ca-Signature": signature,
"Content-Type": "application/json; charset=utf-8"
}
return headers
+163
View File
@@ -0,0 +1,163 @@
"""骏河屋页面解析工具函数"""
import re
from urllib.parse import parse_qs, urlparse
def extract_low_price(text: str) -> int | None:
"""从价格范围文本中提取最低价格
例如 "¥4,700 ~ ¥6,200" 会返回 4700
"""
prices = re.findall(r'(\d[0-9,.]*)', text)
if prices:
low_price_str = prices[0].replace(',', '')
return int(float(low_price_str))
return None
def format_price(price_text: str) -> int | None:
"""格式化商品价格文字为整数(日元)
支持的格式:
- 单价:¥4,700 → 4700
- 范围:¥4,700 ~ ¥6,200 → 4700(取最低价)
- 円后缀:4700円 → 4700
"""
if not price_text:
return None
# 价格范围,取最低价
if "" in price_text:
return extract_low_price(price_text)
match = re.search(r"[¥¥]\s*([0-9][0-9,]*)", price_text)
if not match:
match = re.search(r"([0-9][0-9,]*)\s*円", price_text)
if not match:
return None
try:
return int(match.group(1).replace(",", ""))
except ValueError:
return None
def normalize_goods_id(goods_id: str) -> str:
"""
高效处理商品ID:如果包含字母则转小写。
Python 的 lower() 会处理整个字符串,但在全是数字的情况下开销极小。
"""
# 如果 ID 已经是全数字,直接返回原对象,避免额外的处理
if goods_id.isdigit():
return goods_id
return goods_id.lower()
def surugaya_photo_url_to_cdn_m(photo_url: str | None = None, item_id: str | None = None) -> str:
"""将骏河屋商品照片 URL 转换为 CDN URL(webp 格式)
示例:
输入: https://www.suruga-ya.jp/database/photo.php?shinaban=220035970&size=m
输出: https://cdn.suruga-ya.jp/pics_webp/boxart_m/220035970m.jpg.webp
当 photo_url 无法解析时,回退使用 item_id 拼接 CDN 地址;
两者都为空时返回空字符串。
"""
# 如果无法从 photo_url 解析出商品编号,则用 item_id 直接拼接
if not photo_url:
if item_id:
return f"https://cdn.suruga-ya.jp/pics_webp/boxart_m/{normalize_goods_id(item_id)}m.jpg.webp"
return ""
parsed = urlparse(photo_url)
qs = parse_qs(parsed.query)
shinaban = (qs.get("shinaban") or [None])[0]
if not shinaban:
# photo_url 中没有 shinaban 参数,回退到 item_id
if item_id:
return f"https://cdn.suruga-ya.jp/pics_webp/boxart_m/{normalize_goods_id(item_id)}m.jpg.webp"
return photo_url
size = (qs.get("size") or ["m"])[0] or "m"
size = str(size).strip().lower()
folder = f"boxart_{size}"
filename = f"{normalize_goods_id(shinaban)}{size}"
return f"https://cdn.suruga-ya.jp/pics_webp/{folder}/{filename}.jpg.webp"
def surugaya_photo_url_to_cdn(photo_url: str | None = None, item_id: str | None = None) -> str:
"""将骏河屋商品照片 URL 转换为 CDN URL(webp 格式)
示例:
输入: https://www.suruga-ya.jp/database/photo.php?shinaban=220035970&size=m
输出: https://cdn.suruga-ya.jp/database/pics_webp/game/220035970.jpg.webp
优先使用 item_id 拼接 CDN 地址;
两者都为空时返回空字符串。
"""
if item_id:
return f"https://cdn.suruga-ya.jp/database/pics_webp/game/{normalize_goods_id(item_id)}.jpg.webp"
if photo_url:
parsed = urlparse(photo_url)
qs = parse_qs(parsed.query)
shinaban = (qs.get("shinaban") or [None])[0]
if not shinaban:
return photo_url
return f"https://cdn.suruga-ya.jp/database/pics_webp/game/{normalize_goods_id(shinaban)}.jpg.webp"
return ""
def extract_product_id(url: str | None = None) -> str:
"""从各种商品URL或路径中精准提取商品ID"""
if not url:
return ""
# 核心逻辑:匹配 /detail/ 后面紧跟的非斜杠、非问号、非点号的连续字符
match = re.search(r"/detail/([^/?.]+)", url)
if match:
return match.group(1)
return ""
def get_shipping_fee(amount: int) -> int:
"""
根据定义的运费规则计算运费
日本境内运费统一为固定价格(部分地区除外)。
• 订单金额低于1000日元:运费440日元
• 订单金额低于1500日元:运费385日元
• 订单金额1500日元及以上:免运费
*北海道、冲绳、离岛及其他部分地区可能需额外支付运费。
货到付款需另付550日元货到付款手续费。银行转账付款需由顾客承担转账手续费。信用卡付款无需支付手续费。
*通过电商平台购买的商品,运费因店铺而异。
"""
if amount > 1500:
return 0
elif amount > 1000:
return 440
else:
return 385
def get_handle_fee(amount: int) -> int:
"""
根据定义的规则计算手续费
https://www.suruga-ya.jp/man/qa/hanbai_qa/shiharai.html#shiharai_1_2
A. 运费根据购买金额而定。
■本州、四国、九州:
购买金额低于5,000日元…240日元;
购买金额5,000日元及以上…免运费。
■北海道、冲绳:
购买金额低于5,000日元…570日元;
购买金额5,000日元至低于10,000日元…285日元;
购买金额10,000日元及以上…免
运费。偏远岛屿及其他部分地区可能需额外收费。
这里按照本州计算手续费。
"""
if amount > 5000:
return 0
else:
return 240
+22
View File
@@ -0,0 +1,22 @@
networks:
jp:
driver: bridge
services:
jp_surugaya:
build: .
container_name: surugaya
restart: unless-stopped
volumes:
- .:/app
env_file:
- .env
environment:
SURUGAYA_APP_ENV: prod
SURUGAYA_BROWSER_HEADLESS: "true"
SURUGAYA_BROWSER_CHANNEL: "" # 容器内使用 Playwright 默认自带的 chromium,不使用外部 chrome
TZ: Asia/Tokyo
networks:
- jp
ports:
- "31106:31106"
+31
View File
@@ -0,0 +1,31 @@
[project]
name = "surugaya-scraper-service"
version = "0.1.0"
description = "API service for Surugaya scraping tasks"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0,<1.0.0",
"loguru>=0.7.0,<1.0.0",
"playwright>=1.54.0,<2.0.0",
"playwright-stealth>=1.0.0,<3.0.0",
"pydantic>=2.0.0,<3.0.0",
"pydantic-settings>=2.4.0,<3.0.0",
"redis>=5.0.0,<6.0.0",
"selectolax>=0.3.21,<1.0.0",
"starlette>=0.37.0,<1.0.0",
"uvicorn[standard]>=0.30.0,<1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0,<9.0.0",
"pytest-asyncio>=0.24.0,<1.0.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
[tool.uv]
package = false
+75
View File
@@ -0,0 +1,75 @@
param(
[string]$HostName = "0.0.0.0",
[int]$Port = 31106,
[switch]$NoKillExisting,
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
function Get-ListeningProcessIds([int]$TargetPort) {
$listeners = Get-NetTCPConnection -LocalPort $TargetPort -State Listen -ErrorAction SilentlyContinue
if (-not $listeners) {
return @()
}
return ($listeners | Select-Object -ExpandProperty OwningProcess -Unique)
}
function Stop-ListeningProcesses([int]$TargetPort) {
$pids = Get-ListeningProcessIds -TargetPort $TargetPort
if (-not $pids -or $pids.Count -eq 0) {
Write-Host "[start] port $TargetPort is free."
return
}
foreach ($pidValue in $pids) {
$proc = Get-Process -Id $pidValue -ErrorAction SilentlyContinue
if ($null -eq $proc) {
continue
}
Write-Host "[start] port $TargetPort is used by PID=$pidValue Name=$($proc.ProcessName)"
if ($DryRun) {
Write-Host "[start] DryRun: skip stopping PID=$pidValue"
continue
}
Stop-Process -Id $pidValue -Force
Write-Host "[start] stopped PID=$pidValue"
}
Start-Sleep -Milliseconds 400
}
function Ensure-PortAvailable([int]$TargetPort) {
$pids = Get-ListeningProcessIds -TargetPort $TargetPort
if ($pids.Count -gt 0) {
throw "port $TargetPort is still occupied, cannot start service."
}
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $scriptDir
Write-Host "[start] project dir: $scriptDir"
Write-Host "[start] target: http://$HostName`:$Port"
if (-not $NoKillExisting) {
Stop-ListeningProcesses -TargetPort $Port
} else {
$existing = Get-ListeningProcessIds -TargetPort $Port
if ($existing.Count -gt 0) {
throw "port $Port is occupied and -NoKillExisting is set."
}
}
Ensure-PortAvailable -TargetPort $Port
if ($DryRun) {
Write-Host "[start] DryRun: checks passed, skip launching."
exit 0
}
Write-Host "[start] launching service..."
$env:SURUGAYA_APP_HOST = $HostName
$env:SURUGAYA_APP_PORT = "$Port"
# python -m app.main
uvicorn app.main:app --host $HostName --port $Port
Generated
+763
View File
@@ -0,0 +1,763 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "click"
version = "8.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.136.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
]
[[package]]
name = "greenlet"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
{ url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
{ url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" },
{ url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
{ url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" },
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
{ url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" },
{ url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
{ url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
{ url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" },
{ url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
{ url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
{ url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
{ url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" },
{ url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
{ url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
{ url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
{ url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" },
{ url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "idna"
version = "3.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
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 = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "playwright"
version = "1.59.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/48/abab23f40643b4de8f2665816f0a1bf0994eeecda39d6d62f0f292b2ad01/playwright-1.59.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:bfc6940100b57423175c819ce2422ec5880d55fa2769987f62ab7a1f5fe6783e", size = 43156922, upload-time = "2026-04-29T08:11:08.921Z" },
{ url = "https://files.pythonhosted.org/packages/08/71/5e4d98b2ce3641b4343623c6450ff33b9de1c979d12a957505e392338b07/playwright-1.59.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:af068143a0c045ec11608b67d6c42e58db7e9cf65a742dd21fddedc1a9802c47", size = 41947177, upload-time = "2026-04-29T08:11:12.867Z" },
{ url = "https://files.pythonhosted.org/packages/80/91/fd219aa78ca03d37e93aaedaed4e224131e3090a9264f9bb773c8271d67e/playwright-1.59.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:4a4a2d4842b0e4120de3fa48636e4b69085a05b81d8a35ad4353f530ade72ed6", size = 43156922, upload-time = "2026-04-29T08:11:16.595Z" },
{ url = "https://files.pythonhosted.org/packages/73/0c/1e513d37c5be07d12829ebce93dbfe7baee230084cb66966c423432799c4/playwright-1.59.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c5792aad9e22b91a09264b9edbc18553cf05ea5a39404d65dc19a012c6b2e51d", size = 47151793, upload-time = "2026-04-29T08:11:19.979Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2d/15f72288cb65d690134e18fefb9483cc4976f7579b580648c45e494481a7/playwright-1.59.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c881a19377d2b900af855fb525b5f22a27bf3cfbecba6d1edb36766d56cb100", size = 46877615, upload-time = "2026-04-29T08:11:23.863Z" },
{ url = "https://files.pythonhosted.org/packages/72/a1/717ac5bc99f387c0f60def91271ea4262125c0815d764a5d1776a272275c/playwright-1.59.0-py3-none-win32.whl", hash = "sha256:6989c476be2b9cd3e24a18cc9dcf202e266fb3d91e3e5395cd668c54ea54b119", size = 37713698, upload-time = "2026-04-29T08:11:27.251Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a5/4e630ee05d8b46b840f943268e86d6063703e8dadb2d3eb405c7b9b2e48c/playwright-1.59.0-py3-none-win_amd64.whl", hash = "sha256:d5a5cc064b82ca92996080025710844e417f44df8fda9001102c28f44174171c", size = 37713704, upload-time = "2026-04-29T08:11:30.41Z" },
{ url = "https://files.pythonhosted.org/packages/eb/0c/3ece41761ba13c8321009aefcaec7a016eb42799c42eef5e03ace7f2de5b/playwright-1.59.0-py3-none-win_arm64.whl", hash = "sha256:93581ad515728cadc8af39b288a5633ba6d36e7d72048e79d890ce01ea2156f9", size = 33956745, upload-time = "2026-04-29T08:11:34.738Z" },
]
[[package]]
name = "playwright-stealth"
version = "2.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "playwright" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/db/6ade5d539c7d151b9defc78fafa8b65aa52352617d0e7699b47008bd801f/playwright_stealth-2.0.3.tar.gz", hash = "sha256:1d8e488fbdd8f190f1269ea8cf5d57d14df3a9f1af1001c41ee3588b2aac3133", size = 25751, upload-time = "2026-04-04T02:50:33.88Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/10/607c409712c02a26c4cb794820514cb7fdaaeac15fb05bed917fb8a354b3/playwright_stealth-2.0.3-py3-none-any.whl", hash = "sha256:1887ade423ab7ff8ae16d363a30a38de0b5817e1e4a29d47b74bf3a0e3dbfcb4", size = 34385, upload-time = "2026-04-04T02:50:35.246Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyjwt"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pytest-asyncio"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156, upload-time = "2025-03-25T06:22:28.883Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "redis"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyjwt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/cf/128b1b6d7086200c9f387bd4be9b2572a30b90745ef078bd8b235042dc9f/redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c", size = 4626200, upload-time = "2025-07-25T08:06:27.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/26/5c5fa0e83c3621db835cfc1f1d789b37e7fa99ed54423b5f519beb931aa7/redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97", size = 272833, upload-time = "2025-07-25T08:06:26.317Z" },
]
[[package]]
name = "selectolax"
version = "0.4.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/46/b2956203e943c6417ff7da9ad9bcb5860d60372de6e05934af1b3f0a4950/selectolax-0.4.9.tar.gz", hash = "sha256:15c3de54f75d0d13726fee140bbfec8805050a3d34d298f2e9c05c9da1b87e7a", size = 4879844, upload-time = "2026-05-15T08:59:12.802Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/a9/ef84d5879bdc25ee830e57d030b559bc9e86e9d8f25f6c315511a39c5a67/selectolax-0.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1ee28b8f1326e3504a055a8f8a894e1180f5d3522f61391670c8b2d5e768cbc2", size = 2241162, upload-time = "2026-05-15T08:57:55.593Z" },
{ url = "https://files.pythonhosted.org/packages/40/1d/22554d2ba0d818ba07402dbab9561ff3b438fb8272727e7dfd21e6cad926/selectolax-0.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:668e243607d8bc2d130283506333f63b65bfba83bb32a59cf89f634031f02e0d", size = 2292306, upload-time = "2026-05-15T08:57:57.838Z" },
{ url = "https://files.pythonhosted.org/packages/83/5c/8ef75e1ee59df6f640f79fcf7abc2df012a67587ce0890e5be96e06276ca/selectolax-0.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9451274ccdb46f691f90538e2ede26e67d20f1ab17aa25083db4983208451609", size = 2374496, upload-time = "2026-05-15T08:57:59.69Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f2/bd9e07a8e51b8f4ddcfe4b2f6bbd7100d763d784910eebe15e48bfa813db/selectolax-0.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c37ae18495338320064920144185e3a434d780ddc8654a44f2c611b2e1aa291", size = 2421157, upload-time = "2026-05-15T08:58:01.695Z" },
{ url = "https://files.pythonhosted.org/packages/ba/db/8c715ef0e58cf7a38cd91610a45c19cdb78314584881eaf1499198f69095/selectolax-0.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:77fd2ab190a5a13ce1c3f616538f325bd0f51760c924c3244f27897d23f0817a", size = 2378875, upload-time = "2026-05-15T08:58:03.711Z" },
{ url = "https://files.pythonhosted.org/packages/7e/f9/55ac766dcf574e5f590fc0774486f7bd52e6e5c08c364f08cc18626533c1/selectolax-0.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:428bbfc0375607dc07ef9086ff6c7fb8e6af051876409e3fcac7d8c856b0fa91", size = 2441299, upload-time = "2026-05-15T08:58:05.288Z" },
{ url = "https://files.pythonhosted.org/packages/c8/06/d8d1a0bfb17ec697904bd33be72d9d29e322359f167aaf111e3ce9026131/selectolax-0.4.9-cp312-cp312-win32.whl", hash = "sha256:7d1afdd14451d7a0d9e095e28df2af11ea2066821170aaca1d7db0e56a44f5dc", size = 1763258, upload-time = "2026-05-15T08:58:06.935Z" },
{ url = "https://files.pythonhosted.org/packages/1c/f5/0727cae946c6dc387f683357c3a48e3f39443f3f0b076513eb4bc4766d3f/selectolax-0.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:592bba3ea66658b2080a6978839887eb1a890d8244441f64b9baf0ca74ed7918", size = 1859646, upload-time = "2026-05-15T08:58:08.417Z" },
{ url = "https://files.pythonhosted.org/packages/82/ab/f6995eedda5f3b591d57ab1cf964de0dca01cf77e11ce8433ca9bafc11bd/selectolax-0.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:9ececc7acdb4d16d217d83a55c41e0f4f74fb0e74858005a542e7bd88090aa0d", size = 1809708, upload-time = "2026-05-15T08:58:09.908Z" },
{ url = "https://files.pythonhosted.org/packages/b3/aa/cf3ef1ae4f9a53f53a0add5dcf7c5b241f56f9a778feb7c13cf65e73331b/selectolax-0.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b1ca6686d0199ca3ef2c919421116d9d34f56964e2162a0d8757a4e9a7bdfb4a", size = 2240742, upload-time = "2026-05-15T08:58:11.596Z" },
{ url = "https://files.pythonhosted.org/packages/da/81/16ff9754e720769fe3f723baee9e207b2f7b7cf2612d509a9ae84b357ec5/selectolax-0.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f49b7391544f60cc891587bcc504daed4995be2b02af9c6e3054a564a1b25d0", size = 2291271, upload-time = "2026-05-15T08:58:13.057Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c0/b9dd13726bb13a6d439c1fef14d70345da9d15155f058edf9bb124952cd2/selectolax-0.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6833df2a2f74cd89872a2915f5ba6b9e56e4bed31c0b3e26b593ca086f10d298", size = 2373955, upload-time = "2026-05-15T08:58:14.983Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d6/aad9d222d2599bad16f1ee936ccddec3e5e8299a140ea56af2359ed72631/selectolax-0.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:010ff30cc2266074801d92fa717a3c333cc1cf9c98b699a46fc649a9707acc69", size = 2420661, upload-time = "2026-05-15T08:58:16.437Z" },
{ url = "https://files.pythonhosted.org/packages/50/cc/07b6190d0b20627880c612450d1211ff22cfc9ad1e845882846b0f97be97/selectolax-0.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:395261e997f0d1d9d0232cf9ba7d69f46229e027ad7eb3ad0afba57c8eec7413", size = 2378677, upload-time = "2026-05-15T08:58:18.221Z" },
{ url = "https://files.pythonhosted.org/packages/de/70/a2592179765f948146952fbea67ccf9b8e32f125847bf30632b0a8574af5/selectolax-0.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:19ee1f7de842be9dcdae7c7d27b01c91a0a717a06b136984234bc5b3048fc3d4", size = 2440935, upload-time = "2026-05-15T08:58:20.129Z" },
{ url = "https://files.pythonhosted.org/packages/01/76/4d8ceecdb2265ec3a2e64bef83e8a2d96b7c8d9b3505dc504b02be64c684/selectolax-0.4.9-cp313-cp313-win32.whl", hash = "sha256:2cd98512c3de597a6b5db9bd90d10b35244c4ba7b99c1149874111f453ae82d2", size = 1763205, upload-time = "2026-05-15T08:58:21.663Z" },
{ url = "https://files.pythonhosted.org/packages/28/23/9ac462b19e296a426e7b3fecc2ce65cf3c2db6d405653a96a0a978e1ae44/selectolax-0.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:35097ce93a954a95ac82d930758cfc32c03421c0f173de7185e6381fbd00be28", size = 1861512, upload-time = "2026-05-15T08:58:23.792Z" },
{ url = "https://files.pythonhosted.org/packages/b9/f9/cda79bdea85a231cc1c9aafb0c21fda0d56b056b67b1e571c26a5c39f832/selectolax-0.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:bca470e8f6bf6b4b41f841e87fcc729c9c2fbed20850ef4bb1b7b9055c1f8a55", size = 1809709, upload-time = "2026-05-15T08:58:25.623Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/fe04c5466577bd23433f5ea8de16a3e1aa273b547616a6141591641c109c/selectolax-0.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b65f589b8e613089e77b6f735d956f6690021c66acaba0973df0e59b7fb6ab22", size = 2256543, upload-time = "2026-05-15T08:58:27.333Z" },
{ url = "https://files.pythonhosted.org/packages/53/aa/95211bc61a84d2f42678791654bde6a3526520f423c1450b735ef54bcf8d/selectolax-0.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e1a2a70a02960efc7112a99b81031fb60bca557e62f99806dd3a6354e6c888e3", size = 2308274, upload-time = "2026-05-15T08:58:28.961Z" },
{ url = "https://files.pythonhosted.org/packages/57/c7/f1c63a427346f27ca9a94c63f5f6f83a0130e011f7d6e0bd44a9e073cb31/selectolax-0.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85c463dbd4f1a5b9a1219ea7895ed3307231cb4edcf46e60de99dde93d9dc65b", size = 2373791, upload-time = "2026-05-15T08:58:30.462Z" },
{ url = "https://files.pythonhosted.org/packages/d3/e5/a86685d5bf6ae5d8d1068247aae9a64e5b455b2ffe0d7178d1d780fbcbf3/selectolax-0.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:432dca308603fcb39600fcbf9e02d8917596fde2d7b0fd752d437953bbb83ff0", size = 2419455, upload-time = "2026-05-15T08:58:32.07Z" },
{ url = "https://files.pythonhosted.org/packages/3b/01/14b5a9a425438d8a40c97b08d3221dd2315470ac6edf586abe1c49f3f8b0/selectolax-0.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:25da6d4afec67efcb5b9f228a7f318163e65bc7f4956448cfc994b45894a062f", size = 2394963, upload-time = "2026-05-15T08:58:33.748Z" },
{ url = "https://files.pythonhosted.org/packages/90/f2/6b0b7434b850415adbe050e82d20a6f119dd05c912022a9964b4db47b880/selectolax-0.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:718872445d44b58b0e6bc029993ac79396b5588b13d0c12c8b585f5a1a490c2d", size = 2458094, upload-time = "2026-05-15T08:58:35.553Z" },
{ url = "https://files.pythonhosted.org/packages/53/0d/3b36c76e478deb6fdc7deb105e99e68a7eecef716611a3ef6776472e3057/selectolax-0.4.9-cp314-cp314-win32.whl", hash = "sha256:2affc47bbe2faff46410ce4edd82e9e6fe6a7ac7a84b4e572c1b22fc2fb1832f", size = 1874907, upload-time = "2026-05-15T08:58:37.162Z" },
{ url = "https://files.pythonhosted.org/packages/f7/8c/d140ed3bb7ab6254628e8078c6c7f42d6f857436548c5a1851d6d8e1ccf1/selectolax-0.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0077fe3d5324a345839c8c9da7ac41b577e13a08266b4a24e7c25e16fb2b6229", size = 1969835, upload-time = "2026-05-15T08:58:39.017Z" },
{ url = "https://files.pythonhosted.org/packages/23/f6/97f68770f73b9d65b3584e12d588fda3e4ee86dcfdcbfb05de7c4c6ccf0c/selectolax-0.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:f214d7c10112c818506e2aa72b309acee76e23b6ff677e91e5fd12fb60176abd", size = 1920477, upload-time = "2026-05-15T08:58:40.982Z" },
{ url = "https://files.pythonhosted.org/packages/17/38/e0f0bdacc94dc868e4f7b542f459a34bb71330b1351cfe177cfa749abfee/selectolax-0.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fb3eef8d522534b59498e2aff67d30b879ffb70961406fc6359336d846409f24", size = 2271971, upload-time = "2026-05-15T08:58:43.055Z" },
{ url = "https://files.pythonhosted.org/packages/99/b0/6fd60519f100051e58f83c604452c997b0ce4e25c279405bdd80ba1c8ef9/selectolax-0.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dd270f3c008eddd9b93d6875b05fda569b9943e94b5c918ce5d06123330d35ec", size = 2317744, upload-time = "2026-05-15T08:58:44.521Z" },
{ url = "https://files.pythonhosted.org/packages/f1/5e/f95857eab0a0e213f46eec42ae74cfe939b8aae507e23af3fa17e111ba0a/selectolax-0.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fe69c7d4a510574746fae68d00016d4ff10cffdba845dfa2a40296534390a91", size = 2378938, upload-time = "2026-05-15T08:58:45.975Z" },
{ url = "https://files.pythonhosted.org/packages/20/aa/92f66653993e9b2006dc37c202dbfa0e49803266dead36301461957e6345/selectolax-0.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8720dce153464c1d8172160a2dd96e97dfae0a64534aa2b54eafbe8371ba85b1", size = 2429671, upload-time = "2026-05-15T08:58:47.63Z" },
{ url = "https://files.pythonhosted.org/packages/2a/f2/4d0e8a5b875ca822121a4f3cba66a652379903e2cff1beaace48fa7004db/selectolax-0.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:44d07709c7ddc49702af5661d717aaeab469121328ce1131e182bbe98020c504", size = 2404125, upload-time = "2026-05-15T08:58:49.065Z" },
{ url = "https://files.pythonhosted.org/packages/16/4e/99b4962e5a4171c32ae6747a8d84d5d549a85e9cc48a1f8200e35415acec/selectolax-0.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:919fbb7ba30e9e172913e029e453be68215122bbb3e7e8cedf524e525c24556b", size = 2466287, upload-time = "2026-05-15T08:58:50.889Z" },
{ url = "https://files.pythonhosted.org/packages/71/f4/8a764ebfd95bc1c30d542f32d8eda19e78baa07e2de8f6c355b11de6ee56/selectolax-0.4.9-cp314-cp314t-win32.whl", hash = "sha256:596513c191ec4771357abc21160414e2a0054f064ba543e33e1a7ebd35c96ea6", size = 1923506, upload-time = "2026-05-15T08:58:52.528Z" },
{ url = "https://files.pythonhosted.org/packages/d8/7c/aa021b3ed7c9fce5f16e0a0b4c8897e7970772ba8688754e5813214017d1/selectolax-0.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:008206609f542cbad133570330e51cb4bb666d532b05111914072673de60f33a", size = 2038829, upload-time = "2026-05-15T08:58:53.904Z" },
{ url = "https://files.pythonhosted.org/packages/c2/d7/525f6a3560d13199d65f1c9f1816ad307d4ed2e1ad05daba08e024fc5364/selectolax-0.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:7ab014959dcf68a0be71484dc87b8d4002756c979836d1d873ba81302b6bffe0", size = 1943672, upload-time = "2026-05-15T08:58:55.808Z" },
]
[[package]]
name = "starlette"
version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]]
name = "surugaya-scraper-service"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "loguru" },
{ name = "playwright" },
{ name = "playwright-stealth" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "redis" },
{ name = "selectolax" },
{ name = "starlette" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
{ name = "loguru", specifier = ">=0.7.0,<1.0.0" },
{ name = "playwright", specifier = ">=1.54.0,<2.0.0" },
{ name = "playwright-stealth", specifier = ">=1.0.0,<3.0.0" },
{ name = "pydantic", specifier = ">=2.0.0,<3.0.0" },
{ name = "pydantic-settings", specifier = ">=2.4.0,<3.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0,<9.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0,<1.0.0" },
{ name = "redis", specifier = ">=5.0.0,<6.0.0" },
{ name = "selectolax", specifier = ">=0.3.21,<1.0.0" },
{ name = "starlette", specifier = ">=0.37.0,<1.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<1.0.0" },
]
provides-extras = ["dev"]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.47.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "win32-setctime"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
]