Init
This commit is contained in:
@@ -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
@@ -0,0 +1,16 @@
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.log
|
||||
.env
|
||||
*.egg-info/
|
||||
/logs/
|
||||
|
||||
./vscode/
|
||||
.trae/
|
||||
.idea/
|
||||
.codebuddy/
|
||||
.deepseek/
|
||||
.claude/
|
||||
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
"""Surugaya scraper service package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API routing package."""
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""API route modules."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Core configuration and infrastructure helpers."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic models used by the API and services."""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Service layer implementations."""
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user