From 518c6b3c7a8c84ad34265f09d7a25e10ea73908d Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 8 Jun 2026 16:09:13 +0800 Subject: [PATCH] Init --- .env.example | 46 ++ .gitignore | 16 + AGENTS.md | 29 ++ Dockerfile | 28 ++ README.md | 113 +++++ app/__init__.py | 1 + app/api/__init__.py | 1 + app/api/dependencies.py | 41 ++ app/api/routes/__init__.py | 1 + app/api/routes/health.py | 31 ++ app/api/routes/scrape.py | 354 +++++++++++++ app/core/__init__.py | 1 + app/core/config.py | 131 +++++ app/core/container.py | 25 + app/core/errors.py | 78 +++ app/core/logging_setup.py | 82 ++++ app/main.py | 181 +++++++ app/models/__init__.py | 1 + app/models/scrape.py | 207 ++++++++ app/services/__init__.py | 1 + app/services/browser_pool.py | 299 +++++++++++ app/services/cargo_service.py | 383 +++++++++++++++ app/services/cloudflare_session.py | 252 ++++++++++ app/services/session_store.py | 139 ++++++ app/services/surugaya_client.py | 615 +++++++++++++++++++++++ app/utils/app_utils.py | 8 + app/utils/http_utils.py | 38 ++ app/utils/parse_util.py | 163 ++++++ docker-compose.yml | 22 + pyproject.toml | 31 ++ start-server.ps1 | 75 +++ uv.lock | 763 +++++++++++++++++++++++++++++ 32 files changed, 4156 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/dependencies.py create mode 100644 app/api/routes/__init__.py create mode 100644 app/api/routes/health.py create mode 100644 app/api/routes/scrape.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/container.py create mode 100644 app/core/errors.py create mode 100644 app/core/logging_setup.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/scrape.py create mode 100644 app/services/__init__.py create mode 100644 app/services/browser_pool.py create mode 100644 app/services/cargo_service.py create mode 100644 app/services/cloudflare_session.py create mode 100644 app/services/session_store.py create mode 100644 app/services/surugaya_client.py create mode 100644 app/utils/app_utils.py create mode 100644 app/utils/http_utils.py create mode 100644 app/utils/parse_util.py create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 start-server.ps1 create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f26159f --- /dev/null +++ b/.env.example @@ -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 +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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e21007c --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.pytest_cache/ +__pycache__/ +*.pyc +*.log +.env +*.egg-info/ +/logs/ + +./vscode/ +.trae/ +.idea/ +.codebuddy/ +.deepseek/ +.claude/ + + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..40f82e0 --- /dev/null +++ b/AGENTS.md @@ -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 + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5f726de --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..bac59e6 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Surugaya Scraper Service + +一个用于执行骏河屋(suruga-ya.jp)抓取任务的 HTTP API 服务。首版目标是技术验证:在 Cloudflare 保护场景下,稳定抓取搜索列表与商品详情,并支持 API 被并发调用。 + +## 功能概览 + +- FastAPI 提供 HTTP API +- Bearer Token 接口鉴权(通过 `Authorization: Bearer ` 请求头校验) +- 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 " ^ + -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" +} +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..7d833c5 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Surugaya scraper service package.""" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..4d14ef9 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""API routing package.""" diff --git a/app/api/dependencies.py b/app/api/dependencies.py new file mode 100644 index 0000000..2bebd6f --- /dev/null +++ b/app/api/dependencies.py @@ -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") diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..fb0a2f8 --- /dev/null +++ b/app/api/routes/__init__.py @@ -0,0 +1 @@ +"""API route modules.""" diff --git a/app/api/routes/health.py b/app/api/routes/health.py new file mode 100644 index 0000000..014daba --- /dev/null +++ b/app/api/routes/health.py @@ -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, + ) diff --git a/app/api/routes/scrape.py b/app/api/routes/scrape.py new file mode 100644 index 0000000..77b094a --- /dev/null +++ b/app/api/routes/scrape.py @@ -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, + ) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..eddd513 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Core configuration and infrastructure helpers.""" diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..b1f44d6 --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/core/container.py b/app/core/container.py new file mode 100644 index 0000000..4f78f89 --- /dev/null +++ b/app/core/container.py @@ -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 diff --git a/app/core/errors.py b/app/core/errors.py new file mode 100644 index 0000000..ff97ae9 --- /dev/null +++ b/app/core/errors.py @@ -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) diff --git a/app/core/logging_setup.py b/app/core/logging_setup.py new file mode 100644 index 0000000..ba4869d --- /dev/null +++ b/app/core/logging_setup.py @@ -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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..81ab5b8 --- /dev/null +++ b/app/main.py @@ -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, + ) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..3eabce2 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +"""Pydantic models used by the API and services.""" diff --git a/app/models/scrape.py b/app/models/scrape.py new file mode 100644 index 0000000..916ab85 --- /dev/null +++ b/app/models/scrape.py @@ -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 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..968667d --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Service layer implementations.""" diff --git a/app/services/browser_pool.py b/app/services/browser_pool.py new file mode 100644 index 0000000..2d6ecc1 --- /dev/null +++ b/app/services/browser_pool.py @@ -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() diff --git a/app/services/cargo_service.py b/app/services/cargo_service.py new file mode 100644 index 0000000..f3859e1 --- /dev/null +++ b/app/services/cargo_service.py @@ -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) diff --git a/app/services/cloudflare_session.py b/app/services/cloudflare_session.py new file mode 100644 index 0000000..a9acc4f --- /dev/null +++ b/app/services/cloudflare_session.py @@ -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, + ) diff --git a/app/services/session_store.py b/app/services/session_store.py new file mode 100644 index 0000000..4a059e1 --- /dev/null +++ b/app/services/session_store.py @@ -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) diff --git a/app/services/surugaya_client.py b/app/services/surugaya_client.py new file mode 100644 index 0000000..c905fac --- /dev/null +++ b/app/services/surugaya_client.py @@ -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 diff --git a/app/utils/app_utils.py b/app/utils/app_utils.py new file mode 100644 index 0000000..c97aec4 --- /dev/null +++ b/app/utils/app_utils.py @@ -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 diff --git a/app/utils/http_utils.py b/app/utils/http_utils.py new file mode 100644 index 0000000..377e8d6 --- /dev/null +++ b/app/utils/http_utils.py @@ -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 \ No newline at end of file diff --git a/app/utils/parse_util.py b/app/utils/parse_util.py new file mode 100644 index 0000000..9f058cd --- /dev/null +++ b/app/utils/parse_util.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2c8ec03 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..185c53f --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/start-server.ps1 b/start-server.ps1 new file mode 100644 index 0000000..527c170 --- /dev/null +++ b/start-server.ps1 @@ -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 \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..cb1c11b --- /dev/null +++ b/uv.lock @@ -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" }, +]