first commit

This commit is contained in:
zmq
2026-05-26 14:16:16 +08:00
committed by Jerry Yan
commit 535d7f4ac5
40 changed files with 4571 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
# ==========================================
# JP Purchase Automation Tool - Example Settings
# ==========================================
#
# 使用方式:
# 1) 复制本文件为 .env.dev / .env.prod(按需)
# 2) 填写敏感信息(Redis 密码、回调密钥等)
#
# 运行环境
APP_ENV=dev
# 日志配置
LOG_LEVEL=INFO
LOG_DIR=./logs
# Redis 配置
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_STREAM_KEY=jp_purchase_task_dev
REDIS_CONSUMER_GROUP=purchase_group
REDIS_CONSUMER_NAME=purchase_worker_dev
REDIS_DELAY_ZSET_KEY=jp_purchase_delay_task_dev
REDIS_DELAY_POLL_INTERVAL=3600
REDIS_DELAY_BATCH_SIZE=10
# 浏览器配置
BROWSER_HEADLESS=false
BROWSER_EXECUTABLE_PATH=
BROWSER_KEEP_OPEN=false
# 账号配置
ACCOUNTS_CONFIG_PATH=./accounts.yaml
SCREENSHOT_DIR=./archives
# 任务上报配置
CALLBACK_URL=
APPID=
APP_SECRET=
# 通知配置
NOTIFY_ENABLED=false
NOTIFY_CHANNELS=email
NOTIFY_ON_TASK_FAILED=true
# 邮件通知配置
MAIL_ENABLED=true
MAIL_SMTP_HOST=
MAIL_SMTP_PORT=465
MAIL_SMTP_USE_SSL=true
MAIL_SMTP_STARTTLS=false
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS=
MAIL_FROM_NAME=JP Purchase Bot
MAIL_TO_ADDRESSES=ops@example.com,owner@example.com
MAIL_SUBJECT_PREFIX=[JP-Purchase]
MAIL_TIMEOUT_SECONDS=10
+42
View File
@@ -0,0 +1,42 @@
# Python
__pycache__/
*.py[cod]
# Virtual environments
.venv/
venv/
env/
ENV/
# Environment files(允许提交示例)
.env
.env.*
*.env
!.env.example
!.env.*.example
# Logs & runtime artifacts
logs/
src/logs/
*.log
archives/
src/archives/
.debug.log
## Browser profiles / caches
.tmp_video_profile*/
# Tool caches
.pytest_cache/
.mypy_cache/
# IDE
.vscode/
.idea/
.trae/
.cursor/
# OS
.DS_Store
Thumbs.db
/tmp/
+205
View File
@@ -0,0 +1,205 @@
# 日本跨境电商采购自动化下单工具 (首期:骏河屋)
本项目是一个为日本电商网站(首期重点支持:**骏河屋 Surugaya**)设计的生产级、高稳定性、长期可持续运行的浏览器自动化采购下单系统。
## 🪐 技术架构与设计亮点
1. **核心驱动**:基于 **Python 3.12+ (Asyncio) + Playwright (Async API)**。全部使用异步协同技术,资源消耗低、性能极高。
2. **多账号串行锁隔离**
- 内存中维护针对 `account_id` 维度的 `asyncio.Lock` 协程锁。
- 实现**同一个账号串行执行(规避多开浏览器登录冲突/风控),不同账号并发运行**。
3. **隔离数据环境 (Session 隔离)**
- 每个账号在配置中独占一个 `user_data_dir` 浏览器环境。
- 自动持久化 Cookie、购物车和登录状态,无须每次做短信验证码登录。
4. **强特征隐藏防爬**
- 抹除网页端的 `navigator.webdriver` 痕迹。
- 注入防指纹脚本(WebGL供应商混淆、多语言偏好 `ja-JP`、标准 Chrome 插件列表伪装)。
- 配合首选日语区域、东京时区,深度规避 Cloudflare, Akamai 等反爬检测。
5. **结构化非阻塞日志 (Loguru)**
- 支持设置 `DEBUG``INFO``ERROR` 日志级别。
- 日志在独立的日志缓冲队列中异步写入,绝不拖慢自动化线程。
- 每天 `00:00` 自动切割日志文件,默认最多保留 14 天并压缩打包成 zip。
6. **故障截图与自动归档**
- 在采购关键节点(如购买前确认)及发生**任何执行崩溃时**,系统自动捕获紧急截图。
- 自动按照日期、Topic 智能规划保存目录:
`archives/YYYYMMDD/{topic}/{scene}_{timestamp}.png`
7. **任务级桌面录屏 (FFmpeg,可选)**
- 录屏开关通过采购消息中的 `record_video` 字段控制,默认关闭。
- 开启后使用 FFmpeg 录制整个桌面,可覆盖浏览器窗口、系统弹层和鼠标操作过程。
- 任务结束后自动归档视频到:
`archives/YYYYMMDD/{topic}/video_{task_id}_{timestamp}.mp4`
---
## 📂 项目目录结构
```text
jp_purchase_zdh_5/
├── .env.dev # 本地开发环境环境变量
├── .env.prod # 生产部署环境环境变量
├── pyproject.toml # 现代依赖管理配置文件 (PEP 621)
├── accounts.yaml # 站点多账号隔离配置文件
├── README.md # 项目运行配置手册
├── logs/ # 自动生成的日志切片目录 (Git已忽略)
├── archives/ # 自动截图及归档素材目录 (Git已忽略)
└── src/ # 核心源代码
├── __init__.py
├── main.py # 进程启动入口 (主信号拦截、服务初始化)
├── config/
│ ├── __init__.py
│ ├── settings.py # Pydantic Settings 全局配置管理
│ └── accounts.py # Pydantic 账号解析器 (YAML 结构化解析)
├── queue/
│ ├── __init__.py
│ ├── listener.py # Redis Stream 持续监听器 (PEL 奔溃恢复、优雅停机)
│ └── dispatcher.py # 采购任务分发器 (基于账号协程锁并发串行控制)
├── browser/
│ ├── __init__.py
│ └── manager.py # Playwright 浏览器实例管理 (数据隔离、隐藏测试特征)
├── tasks/
│ ├── notifications/
│ │ ├── __init__.py
│ │ ├── service.py # 通知服务入口 (事件路由、配置校验、渠道分发)
│ │ └── channels/
│ │ └── email.py # SMTP 邮件通知渠道
│ ├── __init__.py
│ ├── base.py # 任务基类 (Playwright 生命周期、通用异常捕获、截图归档)
│ └── surugaya.py # 骏河屋 (Surugaya) 采购业务骨架实现
└── utils/
└── utils/
├── __init__.py
└── logger.py # Loguru 结构化分割日志配置
```
---
## 🛠 快速上手指南
### 1. 安装项目依赖
本项目推荐使用 [uv](https://github.com/astral-sh/uv) 进行极速的依赖和环境管理。确保您的操作系统上已安装 `uv`(且 Python 版本 >= 3.12):
```bash
# 使用 uv 同步并安装所有依赖(自动创建并管理 .venv 虚拟环境)
uv sync
# 安装 Playwright 核心驱动浏览器
uv run playwright install chromium
```
> **传统模式(不使用 uv):**
> 如果您没有安装 `uv`,也可以使用传统的 `pip` 方式进行安装:
> ```bash
> # 1. 激活您的虚拟环境 (venv)
> python -m venv .venv
> source .venv/bin/activate # Windows 下使用: .venv\Scripts\activate
>
> # 2. 安装项目核心库 (建议使用 pip)
> pip install -e .
>
> # 3. 安装 Playwright 核心驱动浏览器
> playwright install chromium
> ```
### 2. 账号的配置、新增与调整
系统所有的可用账号均在根目录的 `accounts.yaml` 中定义:
- **新增账号**:在 `surugaya` 节点下增加 `- account_id: "surugaya_acc_0X"`,并给出一个独立的 `user_data_dir` 缓存路径(如 `./user_data/surugaya/acc_0X`)。重启程序即可被正常识别。
- **调整密码/用户名**:直接编辑对应的参数。
- **重置登录状态 (清除 Cookie 重新登录)**:无需更改任何代码,直接在物理硬盘上**删除该账号对应的缓存文件夹**(例如 `./user_data/surugaya/acc_01`),再次运行任务时 Playwright 会为您初始化全新的纯净浏览器环境。
### 3. 通知模块配置
通知模块与采购业务解耦,作为独立基础设施存在。第一期仅支持邮件通知,并且默认只在采购任务失败时触发。
`.env.dev``.env.prod` 中新增以下配置:
```text
NOTIFY_ENABLED=true
NOTIFY_CHANNELS=email
NOTIFY_ON_TASK_FAILED=true
MAIL_ENABLED=true
MAIL_SMTP_HOST=smtp.example.com
MAIL_SMTP_PORT=465
MAIL_SMTP_USE_SSL=true
MAIL_SMTP_STARTTLS=false
MAIL_USERNAME=bot@example.com
MAIL_PASSWORD=your-password
MAIL_FROM_ADDRESS=bot@example.com
MAIL_FROM_NAME=JP Purchase Bot
MAIL_TO_ADDRESSES=ops@example.com,owner@example.com
MAIL_SUBJECT_PREFIX=[JP-Purchase]
MAIL_TIMEOUT_SECONDS=10
```
说明:
- `NOTIFY_ENABLED` 是通知总开关。
- `NOTIFY_CHANNELS` 预留多渠道扩展能力,第一期请填写 `email`
- `MAIL_TO_ADDRESSES` 支持多个收件人,使用英文逗号分隔。
- 若邮件配置不完整,程序会打印告警日志,但不会阻止主进程启动,也不会影响任务 ACK。
---
## ⚡ 运行采购守护进程
通过 `APP_ENV` 环境变量区分本地开发与生产配置。
### 开发环境下启动 (加载 `.env.dev`):
```bash
# Windows Power Shell (使用 uv 推荐)
$env:APP_ENV="dev"; uv run python -m src.main
# Linux / macOS CMD (使用 uv 推荐)
APP_ENV=dev uv run python -m src.main
# --- 传统模式 (不使用 uv,需确保已激活虚拟环境) ---
# Windows Power Shell
# $env:APP_ENV="dev"
# python -m src.main
#
# Linux / macOS CMD
# APP_ENV=dev python -m src.main
```
### 生产环境下启动 (加载 `.env.prod`):
```bash
# Windows Power Shell (使用 uv 推荐)
$env:APP_ENV="prod"; uv run python -m src.main
# Linux / macOS CMD (使用 uv 推荐)
APP_ENV=prod uv run python -m src.main
# --- 传统模式 (不使用 uv,需确保已激活虚拟环境) ---
# Windows Power Shell
# $env:APP_ENV="prod"
# python -m src.main
#
# Linux / macOS CMD
# APP_ENV=prod python -m src.main
```
---
## 🧪 模拟测试指南
Redis Stream 采购任务的消息结构体设计如下,你可以通过 Redis CLI 向 Stream 发送任务消息:
- 推荐将 `record_video` 放在消息顶层;系统也兼容 `payload.record_video`
- 开启录屏前请确保运行环境已安装并可直接调用 `ffmpeg`
```bash
# 向 Stream (purchase:tasks) 中投递一个骏河屋采购任务,并开启整桌面录屏
XADD purchase:tasks * task_id "task_test_1001" topic "surugaya_tasks" purchase_account "surugaya_acc_01" site_id "surugaya" record_video "1" payload "{\"item_list\":[{\"id\":\"m72275988725\",\"price_limit\":6500,\"number\":1}]}"
```
服务启动后,系统会:
1. 监听到上述消息并将其解析。
2. 调度 `surugaya_tasks` 的任务处理器 `SurugayaPurchaseTask`
3. 获取该账号 `surugaya_acc_01` 的协程串行锁,隔离并发启动 Playwright 缓存环境。
4. 打开目标商品,到达确认环节截图并按目录保存:`archives/YYYYMMDD/surugaya_tasks/购买前确认_{timestamp}.png`
5.`record_video="1"`,任务结束后会输出视频文件名与绝对路径,并归档到:`archives/YYYYMMDD/surugaya_tasks/video_task_test_1001_{timestamp}.mp4`
6. 任务结束后自动进行 `XACK` 确认并安全关舱。
7. 若任务执行失败且通知模块已启用,系统会向 `MAIL_TO_ADDRESSES` 中配置的收件人发送失败邮件,邮件正文包含任务 ID、账号、错误原因、截图路径和录屏路径。
+25
View File
@@ -0,0 +1,25 @@
# ==========================================
# Site Accounts Configuration (accounts.yaml)
# ==========================================
# This file stores the credentials and browser persistent directories for various sites.
# Modify or add accounts here. For security, passwords can be optional if you rely
# on cookies/persistent browser session directory.
#
# NEW OPTIMIZATION FOR CLOUDFLARE:
# You can now specify an optional 'proxy' field for each account!
# Proxy format:
# - "http://username:password@proxy_host:proxy_port" (authenticated proxy)
# - "http://proxy_host:proxy_port" (unauthenticated proxy)
# Highly recommended for server environments to use premium Japanese residential proxies.
sites:
surugaya:
- account_id: "trdian023@tokugen.jp"
username: "trdian023@tokugen.jp"
password: "trdian023.."
user_data_dir: "F:\\user\\chrome\\chrome-profile\\787235076"
proxy: "" # Optional residential proxy (e.g. "http://127.0.0.1:10808")
# Future sites can be appended here
mercari: []
yahoo: []
+32
View File
@@ -0,0 +1,32 @@
[project]
name = "jp-purchase-zdh-5"
version = "0.1.0"
description = "A stable and highly-sustainable browser purchase automation service for Japanese e-commerce sites (Surugaya)."
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [
{ name = "Antigravity Development Team" }
]
dependencies = [
"playwright>=1.43.0",
"pydantic>=2.7.0",
"pydantic-settings>=2.2.0",
"loguru>=0.7.2",
"pyyaml>=6.0.1",
"redis==8.0.0",
"opencv-python>=4.13.0.92",
"numpy>=2.4.6",
"pyautogui>=0.9.54",
"pyscreeze>=1.0.1",
"pillow>=12.2.0",
"httpx>=0.27.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# 👇 在文件里加上这一行,告诉 uv 这只是个普通的脚本项目,不需要打包
[tool.uv]
package = false
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

+1
View File
@@ -0,0 +1 @@
# 根包初始化
+1
View File
@@ -0,0 +1 @@
# 浏览器模块包初始化
+237
View File
@@ -0,0 +1,237 @@
import os
from pathlib import Path
from typing import Tuple
from playwright.async_api import async_playwright, Playwright, BrowserContext
from loguru import logger
from src.config.settings import get_settings
from src.config.accounts import AccountConfig
# 注入到所有页面的 JS 脚本,用于绕过 Cloudflare、Akamai 等自动化检测
STEALTH_JS = """
// 1. 隐藏 navigator.webdriver
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
// 2. 覆盖语言列表,优先日语(与目标地区一致)
Object.defineProperty(navigator, 'languages', {
get: () => ['ja-JP', 'ja', 'en-US', 'en']
});
// 3. 伪造 WebGL 渲染器信息,伪装为标准硬件
const getParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(parameter) {
// UNMASKED_VENDOR_WEBGL (37445)
if (parameter === 37445) {
return 'Intel Inc.';
}
// UNMASKED_RENDERER_WEBGL (37446)
if (parameter === 37446) {
return 'Intel(R) UHD Graphics';
}
return getParameter.apply(this, arguments);
};
// 4. 伪造设备规格
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
// 5. 模拟完整插件列表(无头浏览器常见特征为空列表)
const mockPlugins = () => {
const makePlugin = (name, filename, description) => {
const p = Object.create(Plugin.prototype);
Object.defineProperties(p, {
name: { get: () => name },
filename: { get: () => filename },
description: { get: () => description },
length: { get: () => 0 }
});
return p;
};
const plugins = [
makePlugin("PDF Viewer", "internal-pdf-viewer", "Portable Document Format"),
makePlugin("Chrome PDF Viewer", "mhjfbgoacfdegokjbbpboihdaibdccjg", "Chrome PDF Viewer"),
makePlugin("Chromium PDF Viewer", "internal-pdf-viewer", "Chromium PDF Viewer")
];
const pList = Object.create(PluginArray.prototype);
Object.defineProperties(pList, {
length: { get: () => plugins.length },
item: { value: (index) => plugins[index] },
namedItem: { value: (name) => plugins.find(p => p.name === name) }
});
for (let i = 0; i < plugins.length; i++) {
Object.defineProperty(pList, i, { get: () => plugins[i] });
}
return pList;
};
Object.defineProperty(navigator, 'plugins', {
get: () => mockPlugins()
});
// 6. 模拟 window.chrome(无头浏览器通常没有,Cloudflare 会检测)
window.chrome = {
app: {
isInstalled: false,
InstallState: {
DISABLED: 'disabled',
INSTALLED: 'installed',
NOT_INSTALLED: 'not_installed'
},
RunningState: {
CANNOT_RUN: 'cannot_run',
READY_TO_RUN: 'ready_to_run',
RUNNING: 'running'
}
},
runtime: {
OnInstalledReason: {
CHROME_UPDATE: 'chrome_update',
INSTALL: 'install',
SHARED_MODULE_UPDATE: 'shared_module_update',
UPDATE: 'update'
},
OnRestartRequiredReason: {
APP_UPDATE: 'app_update',
OS_UPDATE: 'os_update',
PERIODIC: 'periodic'
},
PlatformArch: {
ARM: 'arm',
ARM64: 'arm64',
MIPS: 'mips',
MIPS64: 'mips64',
X86_32: 'x86-32',
X86_64: 'x86-64'
},
PlatformNaclArch: {
ARM: 'arm',
MIPS: 'mips',
MIPS64: 'mips64',
X86_32: 'x86-32',
X86_64: 'x86-64'
},
PlatformOs: {
ANDROID: 'android',
CROS: 'cros',
LINUX: 'linux',
MAC: 'mac',
OPENBSD: 'openbsd',
WIN: 'win'
},
RequestUpdateCheckStatus: {
NO_UPDATE: 'no_update',
THROTTLED: 'throttled',
UPDATE_AVAILABLE: 'update_available'
}
}
};
// 7. 模拟 Permissions 查询行为一致性
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications' ?
Promise.resolve({ state: Notification.permission }) :
originalQuery(parameters)
);
"""
class BrowserManager:
"""
浏览器管理器:负责初始化 Playwright 上下文。
按账号隔离数据环境(user_data_dir),并注入反爬脚本以规避 Cloudflare、Akamai 等检测。
"""
def __init__(self) -> None:
self.settings = get_settings()
async def get_context(self, account: AccountConfig) -> Tuple[Playwright, BrowserContext]:
"""
为指定账号启动持久化浏览器上下文。
返回 (Playwright, BrowserContext) 元组。
调用方必须在 try-finally 中确保关闭 context 与 playwright 实例。
Args:
account: 包含 account_id、user_data_dir 及可选 proxy 的账号配置。
Returns:
Tuple[Playwright, BrowserContext]: 已启动的驱动与隔离浏览器上下文实例。
"""
# 1. 解析并确保本地持久化目录存在
data_dir = Path(account.user_data_dir)
try:
data_dir.mkdir(parents=True, exist_ok=True)
# logger.debug(f"持久化用户数据目录: {data_dir.absolute()}")
except Exception as e:
logger.error(f"创建账号 '{account.account_id}' 的持久化目录失败: {e}")
raise e
# 2. 配置隐身 Chromium 启动参数
chrome_args = [
"--disable-blink-features=AutomationControlled", # 浏览器层面移除 navigator.webdriver
"--disable-infobars", # 隐藏「Chrome 正受到自动测试软件控制」提示条
"--no-sandbox", # Docker/服务器环境兼容
"--disable-setuid-sandbox",
"--disable-dev-shm-usage", # 内存受限环境避免资源崩溃
"--start-maximized", # 启动时全屏(最大化)
"--disable-gpu", # 服务器无头模式稳定渲染
"--use-mock-keychain", # 避免本地密钥环弹窗(macOS/Linux)
"--password-store=basic",
]
executable_path_raw = self.settings.browser_executable_path
executable_path = None
if executable_path_raw:
candidate_path = Path(executable_path_raw).expanduser()
if candidate_path.is_file():
executable_path = str(candidate_path)
logger.info(f"使用自定义浏览器可执行文件: {executable_path}")
else:
logger.warning(
f"配置的浏览器可执行文件无效,将使用 Playwright 默认浏览器: {executable_path_raw}"
)
headless_mode = self.settings.browser_headless
# 使用较新的 Windows Chrome User-Agent
user_agent = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
logger.info(f"正在为账号 {account.account_id} 启动 Playwright | 无头模式: {headless_mode}")
# 3. 按账号配置可选代理(服务器 IP 段绕过 Cloudflare 封锁时常用)
proxy_config = None
if account.proxy:
proxy_config = {"server": account.proxy}
logger.info(f"账号 '{account.account_id}' 已启用代理: {account.proxy}")
playwright_driver = await async_playwright().start()
try:
# 4. 使用隔离目录创建持久化上下文
launch_options = {
"user_data_dir": str(data_dir.absolute()),
"headless": headless_mode,
"args": chrome_args,
"user_agent": user_agent,
"no_viewport": True, # 配合 --start-maximized 使得网页视口也铺满全屏
"locale": "ja-JP", # 区域语言一致
"timezone_id": "Asia/Tokyo", # 日本本地时区
"accept_downloads": True, # 允许自动下载(如需要)
"proxy": proxy_config # 住宅代理等
}
if executable_path:
launch_options["executable_path"] = executable_path
context = await playwright_driver.chromium.launch_persistent_context(**launch_options)
# 5. 注入高级反指纹隐身脚本
await context.add_init_script(STEALTH_JS)
logger.success(f"账号 '{account.account_id}' 的 Playwright 上下文启动成功。")
return playwright_driver, context
except Exception as e:
logger.error(f"账号 '{account.account_id}' 启动持久化上下文失败: {e}")
# 启动失败时清理驱动进程
await playwright_driver.stop()
raise e
+1
View File
@@ -0,0 +1 @@
# 配置模块包初始化
+74
View File
@@ -0,0 +1,74 @@
import os
from functools import lru_cache
from pathlib import Path
from pydantic import BaseModel, Field
import yaml
from loguru import logger
from src.config.settings import get_settings
class AccountConfig(BaseModel):
"""
单个浏览器电商账号的配置模型(Pydantic Schema)。
"""
account_id: str
username: str
password: str | None = None
user_data_dir: str
proxy: str | None = None # 可选代理地址,例如 "http://127.0.0.1:8080"
class SiteAccounts(BaseModel):
"""
按电商站点分组的账号列表。
"""
surugaya: list[AccountConfig] = Field(default_factory=list)
mercari: list[AccountConfig] = Field(default_factory=list)
yahoo: list[AccountConfig] = Field(default_factory=list)
class MultiAccountConfig(BaseModel):
"""
YAML 根结构:将 'sites' 映射到各站点的账号配置。
"""
sites: SiteAccounts
@lru_cache
def load_accounts_config() -> MultiAccountConfig:
"""
使用 PyYAML 加载并解析 accounts.yaml,经 Pydantic 校验后返回配置对象。
"""
settings = get_settings()
config_path = Path(settings.accounts_config_path)
if not config_path.exists():
logger.warning(f"未找到账号配置文件: {config_path.absolute()},将使用空配置。")
# 返回空配置
return MultiAccountConfig(sites=SiteAccounts())
try:
with open(config_path, "r", encoding="utf-8") as f:
raw_data = yaml.safe_load(f) or {}
config = MultiAccountConfig.model_validate(raw_data)
logger.info(f"已成功加载账号配置: {config_path.absolute()}"
f"骏河屋账号 {len(config.sites.surugaya)} 个。")
return config
except Exception as e:
logger.error(f"加载或解析账号配置失败: {config_path.absolute()},错误: {e}")
raise e
def get_account_config(site: str, account_id: str) -> AccountConfig | None:
"""
根据站点名与 account_id 查找并返回对应账号配置。
"""
config = load_accounts_config()
site_accounts = getattr(config.sites, site.lower(), None)
if site_accounts is None:
logger.warning(f"站点 '{site}' 未在账号配置中定义。")
return None
for acc in site_accounts:
if acc.account_id == account_id:
return acc
logger.warning(f"站点 '{site}' 下未找到账号 ID '{account_id}'")
return None
+109
View File
@@ -0,0 +1,109 @@
import os
from functools import lru_cache
from typing import Annotated, Any
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
# 根据 APP_ENV 确定环境配置文件(默认为 dev)
APP_ENV = os.getenv("APP_ENV", "dev").lower()
ENV_FILE = f".env.{APP_ENV}"
class Settings(BaseSettings):
"""
全局应用配置模型。
使用 pydantic-settings 校验类型并自动解析复杂数据类型。
"""
# 运行环境
app_env: str = Field(default="dev", alias="APP_ENV")
# 日志配置
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
log_dir: str = Field(default="./logs", alias="LOG_DIR")
# 节点配置
node_id: str = Field(default="node_1", alias="NODE_ID")
# Redis 配置
redis_host: str = Field(default="127.0.0.1", alias="REDIS_HOST")
redis_port: int = Field(default=6379, alias="REDIS_PORT")
redis_password: str | None = Field(default=None, alias="REDIS_PASSWORD")
redis_db: int = Field(default=0, alias="REDIS_DB")
redis_stream_key: str = Field(default="jp_surugaya_purchase_task", alias="REDIS_STREAM_KEY")
redis_consumer_group: str = Field(default="surugaya_purchase_task_group", alias="REDIS_CONSUMER_GROUP")
redis_consumer_name: str = Field(default="surugaya_purchase_task_worker", alias="REDIS_CONSUMER_NAME")
redis_delay_zset_key: str = Field(default="jp_surugaya_trade_delay", alias="REDIS_DELAY_ZSET_KEY")
redis_delay_poll_interval: int = Field(default=3600, alias="REDIS_DELAY_POLL_INTERVAL")
redis_delay_batch_size: int = Field(default=10, alias="REDIS_DELAY_BATCH_SIZE")
# 浏览器控制
browser_headless: bool = Field(default=True, alias="BROWSER_HEADLESS")
browser_executable_path: str | None = Field(default=None, alias="BROWSER_EXECUTABLE_PATH")
browser_keep_open: bool = Field(default=False, alias="BROWSER_KEEP_OPEN")
# 配置与资源路径
accounts_config_path: str = Field(default="./accounts.yaml", alias="ACCOUNTS_CONFIG_PATH")
screenshot_dir: str = Field(default="./archives", alias="SCREENSHOT_DIR")
# 任务上报配置
callback_url: str | None = Field(default=None, alias="CALLBACK_URL")
appid: str | None = Field(default=None, alias="APPID")
app_secret: str | None = Field(default=None, alias="APP_SECRET")
# PayPay 配置
paypay_phone: str | None = Field(default=None, alias="PAYPAY_PHONE")
paypay_password: str | None = Field(default=None, alias="PAYPAY_PASSWORD")
# 通知配置
notify_enabled: bool = Field(default=False, alias="NOTIFY_ENABLED")
notify_channels: Annotated[list[str], NoDecode] = Field(
default_factory=lambda: ["email"],
alias="NOTIFY_CHANNELS",
)
notify_on_task_failed: bool = Field(default=True, alias="NOTIFY_ON_TASK_FAILED")
# 邮件通知配置
mail_enabled: bool = Field(default=True, alias="MAIL_ENABLED")
mail_smtp_host: str | None = Field(default=None, alias="MAIL_SMTP_HOST")
mail_smtp_port: int = Field(default=465, alias="MAIL_SMTP_PORT")
mail_smtp_use_ssl: bool = Field(default=True, alias="MAIL_SMTP_USE_SSL")
mail_smtp_starttls: bool = Field(default=False, alias="MAIL_SMTP_STARTTLS")
mail_username: str | None = Field(default=None, alias="MAIL_USERNAME")
mail_password: str | None = Field(default=None, alias="MAIL_PASSWORD")
mail_from_address: str | None = Field(default=None, alias="MAIL_FROM_ADDRESS")
mail_from_name: str = Field(default="JP Purchase Bot", alias="MAIL_FROM_NAME")
mail_to_addresses: Annotated[list[str], NoDecode] = Field(
default_factory=list,
alias="MAIL_TO_ADDRESSES",
)
mail_subject_prefix: str = Field(default="[JP-Purchase]", alias="MAIL_SUBJECT_PREFIX")
mail_timeout_seconds: int = Field(default=10, alias="MAIL_TIMEOUT_SECONDS")
@field_validator("notify_channels", "mail_to_addresses", mode="before")
@classmethod
def _parse_csv_list(cls, value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
if isinstance(value, (list, tuple, set)):
return [str(item).strip() for item in value if str(item).strip()]
return [str(value).strip()] if str(value).strip() else []
# Pydantic Settings 配置项
model_config = SettingsConfigDict(
env_file=ENV_FILE,
env_file_encoding="utf-8",
extra="ignore" # 忽略其他系统环境变量
)
@lru_cache
def get_settings() -> Settings:
"""
获取缓存的全局配置单例。
"""
return Settings()
+89
View File
@@ -0,0 +1,89 @@
import asyncio
import signal
import sys
from loguru import logger
from src.config.settings import get_settings
from src.config.accounts import load_accounts_config
from src.notifications.service import NotificationService
from src.utils.logger import setup_logger
from src.task_queue.dispatcher import TaskDispatcher
from src.task_queue.delay_zset_listener import RedisZSetDelayListener
from src.task_queue.listener import RedisStreamListener
from src.task_queue.redis_manager import get_redis_manager
async def main() -> None:
"""
应用程序主入口函数。
- 初始化定制的 Loguru 结构化日志系统。
- 校验 Pydantic 配置及多站点账号 YAML 配置文件是否正确。
- 针对不同操作系统平台注册优雅退出信号处理器。
- 启动 Redis Stream 消息队列持续消费监听循环。
"""
# 1. 初始化按天分割日志系统
setup_logger()
logger.info("正在初始化日本采购自动化守护进程...")
# 2. 启动前快速校验:验证全局环境变量设置与账号配置文件
try:
settings = get_settings()
logger.info(f"应用配置加载成功: APP_ENV={settings.app_env.upper()}")
NotificationService(settings).validate_configuration()
# 解析并校验 accounts.yaml
load_accounts_config()
await get_redis_manager().initialize()
except Exception as e:
logger.critical(f"由于配置错误,守护进程启动失败: {e}")
sys.exit(1)
# 3. 实例化核心任务分发器与 Stream 监听器
dispatcher = TaskDispatcher()
stream_listener = RedisStreamListener(dispatcher)
delay_listener = RedisZSetDelayListener(dispatcher)
# 4. 优雅停机信号处理 (跨平台支持)
loop = asyncio.get_running_loop()
def shutdown_handler(sig_name: str) -> None:
logger.warning(f"接收到系统退出信号 '{sig_name}'。正在启动优雅停机流程...")
# 触发监听器停止的协程任务
asyncio.create_task(stream_listener.stop())
asyncio.create_task(delay_listener.stop())
# Windows 默认的 Proactor 事件循环不支持 add_signal_handler。
if sys.platform != "win32":
logger.debug("正在注册 Unix 系统信号监听器 (SIGINT / SIGTERM)。")
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, lambda s=sig: shutdown_handler(s.name))
except NotImplementedError:
pass
else:
logger.debug("运行于 Windows 平台。跳过系统信号注册,将直接捕获 KeyboardInterrupt。")
# 5. 启动消息监听消费主循环
try:
stream_task = asyncio.create_task(stream_listener.listen())
delay_task = asyncio.create_task(delay_listener.listen())
await asyncio.gather(stream_task, delay_task)
except asyncio.CancelledError:
logger.warning("监听器轮询执行被事件循环取消。")
except Exception as e:
logger.exception(f"主守护进程轮询中发生致命崩溃: {e}")
finally:
# 保证安全回收与连接断开
await asyncio.gather(
stream_listener.stop(),
delay_listener.stop(),
get_redis_manager().close(),
return_exceptions=True,
)
logger.success("日本采购自动化守护进程已安全优雅退出。")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.warning("捕获到键盘手动中断。立即退出程序...")
sys.exit(0)
+4
View File
@@ -0,0 +1,4 @@
from src.notifications.models import NotificationEvent
from src.notifications.service import NotificationService
__all__ = ["NotificationEvent", "NotificationService"]
+14
View File
@@ -0,0 +1,14 @@
from abc import ABC, abstractmethod
from src.notifications.models import NotificationEvent
class NotificationChannel(ABC):
channel_name: str
@abstractmethod
async def send(self, event: NotificationEvent) -> None:
"""
发送通知事件。
"""
raise NotImplementedError
+3
View File
@@ -0,0 +1,3 @@
from src.notifications.channels.email import EmailNotificationChannel
__all__ = ["EmailNotificationChannel"]
+54
View File
@@ -0,0 +1,54 @@
import asyncio
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from src.config.settings import Settings
from src.notifications.base import NotificationChannel
from src.notifications.models import NotificationEvent
class EmailNotificationChannel(NotificationChannel):
channel_name = "email"
def __init__(self, settings: Settings) -> None:
self.settings = settings
async def send(self, event: NotificationEvent) -> None:
await asyncio.to_thread(self._send_sync, event)
def _send_sync(self, event: NotificationEvent) -> None:
message = EmailMessage()
from_display = formataddr(
(
self.settings.mail_from_name,
self.settings.mail_from_address or "",
)
)
message["Subject"] = event.title
message["From"] = from_display
message["To"] = ", ".join(self.settings.mail_to_addresses)
message.set_content(event.content_text)
host = self.settings.mail_smtp_host or ""
port = self.settings.mail_smtp_port
timeout = self.settings.mail_timeout_seconds
if self.settings.mail_smtp_use_ssl:
with smtplib.SMTP_SSL(host, port, timeout=timeout) as server:
self._login_and_send(server, message)
return
with smtplib.SMTP(host, port, timeout=timeout) as server:
if self.settings.mail_smtp_starttls:
server.starttls()
self._login_and_send(server, message)
def _login_and_send(self, server: smtplib.SMTP, message: EmailMessage) -> None:
username = self.settings.mail_username or ""
password = self.settings.mail_password or ""
if username:
server.login(username, password)
server.send_message(message)
+11
View File
@@ -0,0 +1,11 @@
from typing import Any, Literal
from pydantic import BaseModel, Field
class NotificationEvent(BaseModel):
event_type: str
title: str
content_text: str
severity: Literal["info", "warning", "error"] = "info"
metadata: dict[str, Any] = Field(default_factory=dict)
+131
View File
@@ -0,0 +1,131 @@
from datetime import datetime
import json
from typing import Any
from loguru import logger
from src.config.settings import Settings, get_settings
from src.notifications.base import NotificationChannel
from src.notifications.channels import EmailNotificationChannel
from src.notifications.models import NotificationEvent
class NotificationService:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
def validate_configuration(self) -> None:
if not self.settings.notify_enabled:
return
channels = self._get_enabled_channel_names()
if not channels:
logger.warning("通知模块已启用,但未配置任何有效通知渠道。")
return
logger.info(f"通知模块已启用,当前渠道: {', '.join(channels)}")
if "email" in channels and self._get_email_configuration_issues():
logger.warning(
"邮件通知配置不完整,失败通知将被跳过: "
+ "; ".join(self._get_email_configuration_issues())
)
async def notify_task_failed(self, task_context: dict[str, Any]) -> None:
"""
通知任务执行失败。
:param task_context: 任务上下文,包含任务ID、失败原因等信息。
"""
if not self.settings.notify_on_task_failed:
return
title = f"{self.settings.mail_subject_prefix} {task_context.get("title", "")}"
event = NotificationEvent(
event_type="task_failed",
title=title,
content_text=self._render_task_failed_content(task_context),
severity="error",
metadata=task_context,
)
await self.notify(event)
async def notify(self, event: NotificationEvent) -> None:
if not self.settings.notify_enabled:
logger.debug("通知模块未启用,跳过发送通知。")
return
channels = self._build_channels()
if not channels:
logger.warning("通知模块未找到可用渠道,跳过发送通知。")
return
for channel in channels:
try:
await channel.send(event)
logger.success(
f"通知发送成功,渠道={channel.channel_name},事件={event.event_type}"
)
except Exception as exc:
logger.error(
f"通知发送失败,渠道={channel.channel_name},事件={event.event_type},错误: {exc}"
)
def _build_channels(self) -> list[NotificationChannel]:
channels: list[NotificationChannel] = []
for channel_name in self._get_enabled_channel_names():
if channel_name == "email":
if not self.settings.mail_enabled:
logger.debug("邮件渠道已在配置中关闭。")
continue
issues = self._get_email_configuration_issues()
if issues:
logger.warning(
"邮件通知配置不完整,跳过邮件渠道: " + "; ".join(issues)
)
continue
channels.append(EmailNotificationChannel(self.settings))
return channels
def _get_enabled_channel_names(self) -> list[str]:
return [channel.lower() for channel in self.settings.notify_channels if channel.strip()]
def _get_email_configuration_issues(self) -> list[str]:
issues: list[str] = []
if not self.settings.mail_smtp_host:
issues.append("缺少 MAIL_SMTP_HOST")
if not self.settings.mail_from_address:
issues.append("缺少 MAIL_FROM_ADDRESS")
if not self.settings.mail_to_addresses:
issues.append("缺少 MAIL_TO_ADDRESSES")
if not self.settings.mail_username:
issues.append("缺少 MAIL_USERNAME")
if self.settings.mail_username and self.settings.mail_password is None:
issues.append("缺少 MAIL_PASSWORD")
return issues
def _render_task_failed_content(self, task_context: dict[str, Any]) -> str:
message = task_context.get("message", "")
message_txt = ""
# 格式化为json字符串
if message:
message_txt = json.dumps(message, ensure_ascii=False, indent=4)
lines = [
"采购任务失败通知",
"",
f"任务ID: {task_context.get('task_id', 'N/A')}",
f"Topic: {task_context.get('topic', 'unknown')}",
f"采购账号: {task_context.get('account_id', '')}",
f"失败原因: {task_context.get('error_message', '') or '未知错误'}",
f"截图路径: {task_context.get('screenshot_path', '')}",
f"录屏路径: {task_context.get('video_path', '')}",
f"发生时间: {task_context.get('occurred_at', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))}",
f"原始消息: {message_txt}",
]
return "\n".join(lines)
+1
View File
@@ -0,0 +1 @@
"""页面解析器包。"""
+18
View File
@@ -0,0 +1,18 @@
from .models import (
SurugayaOrderDetail,
SurugayaOrderItem,
SurugayaOrderStatus,
SurugayaOrderSummary,
SurugayaShippingInfo,
)
from .order_detail import SurugayaOrderDetailParser, parse_surugaya_order_detail
__all__ = [
"SurugayaOrderDetail",
"SurugayaOrderItem",
"SurugayaOrderStatus",
"SurugayaOrderSummary",
"SurugayaShippingInfo",
"SurugayaOrderDetailParser",
"parse_surugaya_order_detail",
]
+69
View File
@@ -0,0 +1,69 @@
from typing import Any, Literal
from pydantic import BaseModel, Field
class SurugayaOrderStatus(BaseModel):
"""交易状态表的结构化结果。"""
trade_status: str | None = None
status_message: str | None = None
# 保留日文原始键值,便于排障和后续补字段。
raw: dict[str, str] = Field(default_factory=dict)
class SurugayaOrderSummary(BaseModel):
"""订单摘要表的结构化结果。"""
trade_number: str | None = None
order_date: str | None = None
items_subtotal: int | None = None
item_count: int | None = None
payment_method: str | None = None
cash_on_delivery_fee: int | None = None
shipping_date: str | None = None
inquiry_number: str | None = None
shipping_and_handling_fee: int | None = None
digital_gift_certificate_amount: int | None = None
grand_total: int | None = None
# 保留订单摘要表的原始日文字段。
raw: dict[str, str] = Field(default_factory=dict)
class SurugayaShippingInfo(BaseModel):
"""收件信息表的结构化结果。"""
recipient_name: str | None = None
recipient_address: str | None = None
recipient_phone_number: str | None = None
recipient_email: str | None = None
# 保留配送信息表的原始日文字段。
raw: dict[str, str] = Field(default_factory=dict)
class SurugayaOrderItem(BaseModel):
"""商品明细表中的单行商品记录。"""
product_code: str | None = None
condition: str | None = None
branch_number: str | None = None
product_title: str | None = None
unit_price: int | None = None
quantity: int | None = None
discount_amount: int | None = None
line_total: int | None = None
remarks: str | None = None
# 每一行商品同时保留原始列名和值。
raw: dict[str, str] = Field(default_factory=dict)
class SurugayaOrderDetail(BaseModel):
"""骏河屋交易详情页的完整结构化结果。"""
site: Literal["surugaya"] = "surugaya"
status: SurugayaOrderStatus
summary: SurugayaOrderSummary
shipping: SurugayaShippingInfo
items: list[SurugayaOrderItem] = Field(default_factory=list)
# 以业务分段聚合保留原始表格结果。
raw_tables: dict[str, Any] = Field(default_factory=dict)
+315
View File
@@ -0,0 +1,315 @@
import re
from typing import Any
from playwright.async_api import Locator, Page
from .models import (
SurugayaOrderDetail,
SurugayaOrderItem,
SurugayaShippingInfo,
SurugayaOrderStatus,
SurugayaOrderSummary,
)
# 订单摘要表中的日文字段到英文字段映射。
SUMMARY_FIELD_MAP = {
"取引番号": "trade_number",
"注文日": "order_date",
"商品合計": "items_subtotal",
"商品点数": "item_count",
"支払方法": "payment_method",
"代引き手数料": "cash_on_delivery_fee",
"発送日": "shipping_date",
"お問い合わせ番号": "inquiry_number",
"送料・通信販売手数料": "shipping_and_handling_fee",
"電子商品券": "digital_gift_certificate_amount",
"総合計": "grand_total",
}
# 收件信息表的日文字段到英文字段映射。
SHIPPING_FIELD_MAP = {
"氏名": "recipient_name",
"住所": "recipient_address",
"電話番号": "recipient_phone_number",
"メールアドレス": "recipient_email",
}
# 商品明细表的列名到英文字段映射。
ITEM_FIELD_MAP = {
"品番": "product_id",
"状態": "status",
"枝番": "branch_number",
"商品タイトル": "product_title",
"単価": "unit_price",
"数量": "number",
"値引額": "discount_amount",
"金額": "product_total_amount",
"備考": "remarks",
}
# 需要按金额规则清洗的字段集合。
MONEY_FIELDS = {
"items_subtotal",
"cash_on_delivery_fee",
"shipping_and_handling_fee",
"digital_gift_certificate_amount",
"grand_total",
"unit_price",
"discount_amount",
"line_total",
}
# 需要按整数规则清洗的字段集合。
INT_FIELDS = {
"item_count",
"quantity",
}
class SurugayaOrderDetailParser:
"""骏河屋交易详情页解析器。"""
@classmethod
async def parse_page(cls, page: Page) -> SurugayaOrderDetail:
"""对外主入口:传入已加载详情页的 `Page`,返回结构化订单对象。"""
# 适配 `SurugayaOrderDetailParser.parse_page(page)` 这种类调用方式。
parser = cls()
if await page.locator("#pageCont").count() < 1:
raise ValueError("Surugaya order detail page not found")
return await parser._parse_page_content(page)
async def _parse_page_content(self, page: Page) -> SurugayaOrderDetail:
"""解析正常交易详情页中的 `#pageCont` 核心区域。"""
page_cont = page.locator("#pageCont").first
tables = page_cont.locator("table")
table_count = await tables.count()
if table_count < 4:
raise ValueError("Expected 4 trade tables under #pageCont")
status_table = tables.nth(0)
summary_table = tables.nth(1)
shipping_table = tables.nth(2)
items_table = tables.nth(3)
status = await self._parse_status_table(status_table)
summary = await self._parse_summary_table(summary_table)
shipping = await self._parse_shipping_table(shipping_table)
items = await self._parse_items_table(items_table)
return SurugayaOrderDetail(
status=status,
summary=summary,
shipping=shipping,
items=items,
raw_tables={
"status": status.raw,
"summary": summary.raw,
"shipping": shipping.raw,
"items": [item.raw for item in items],
},
)
async def _parse_status_table(self, table: Locator) -> SurugayaOrderStatus:
"""解析第 1 张交易状态表。"""
rows = table.locator("tr")
row_count = await rows.count()
if row_count < 1:
return SurugayaOrderStatus()
first_row = await self._extract_row_cells(rows.nth(0))
raw: dict[str, str] = {}
trade_status = None
status_message = None
if len(first_row) >= 2 and first_row[0]["tag"] == "th" and first_row[1]["tag"] == "td":
label = first_row[0]["text"]
value = first_row[1]["text"]
raw[label] = value
lines = self._split_lines(value)
trade_status = lines[0] if lines else None
status_message = " ".join(lines[1:]) or None
return SurugayaOrderStatus(
trade_status=trade_status,
status_message=status_message,
raw=raw,
)
async def _parse_summary_table(self, table: Locator) -> SurugayaOrderSummary:
"""解析第 2 张订单摘要表。"""
raw_pairs = await self._parse_key_value_rows(table)
normalized = self._normalize_mapped_values(raw_pairs, SUMMARY_FIELD_MAP)
return SurugayaOrderSummary(**normalized, raw=raw_pairs)
async def _parse_shipping_table(self, table: Locator) -> SurugayaShippingInfo:
"""解析第 3 张收件信息表。"""
raw_pairs = await self._parse_key_value_rows(table)
normalized = self._normalize_mapped_values(raw_pairs, SHIPPING_FIELD_MAP)
return SurugayaShippingInfo(**normalized, raw=raw_pairs)
async def _parse_items_table(self, table: Locator) -> list[SurugayaOrderItem]:
"""解析第 4 张商品明细表。"""
rows = table.locator("tr")
row_count = await rows.count()
if row_count < 2:
return []
header_cells = await self._extract_row_cells(rows.nth(0))
headers = [cell["text"] for cell in header_cells if cell["tag"] == "th" and cell["text"]]
if not headers:
return []
items: list[SurugayaOrderItem] = []
expected_columns = len(headers)
for index in range(1, row_count):
cells = await self._extract_row_cells(rows.nth(index))
if not cells:
continue
td_cells = [cell for cell in cells if cell["tag"] == "td"]
if len(td_cells) != expected_columns:
continue
raw_item = {
header: td_cells[position]["text"]
for position, header in enumerate(headers)
}
if not any(raw_item.values()):
continue
normalized = self._normalize_mapped_values(raw_item, ITEM_FIELD_MAP)
items.append(SurugayaOrderItem(**normalized, raw=raw_item))
return items
async def _parse_key_value_rows(self, table: Locator) -> dict[str, str]:
"""通用解析:按 `th -> td` 配对方式抽取键值。"""
rows = table.locator("tr")
row_count = await rows.count()
result: dict[str, str] = {}
for index in range(row_count):
cells = await self._extract_row_cells(rows.nth(index))
if not cells:
continue
pointer = 0
while pointer < len(cells):
current = cells[pointer]
if current["tag"] != "th":
pointer += 1
continue
label = current["text"]
pointer += 1
if not label:
continue
while pointer < len(cells) and cells[pointer]["tag"] != "td":
pointer += 1
if pointer >= len(cells):
break
result[label] = cells[pointer]["text"]
pointer += 1
return result
async def _extract_row_cells(self, row: Locator) -> list[dict[str, str]]:
"""提取一行中的所有单元格,并保留标签名与纯文本。"""
return await row.locator(":scope > th, :scope > td").evaluate_all(
"""cells => cells.map((cell) => ({
tag: cell.tagName.toLowerCase(),
text: (cell.innerText || "").replace(/\\u00a0/g, " ").trim(),
}))"""
)
def _normalize_mapped_values(
self,
raw_data: dict[str, str],
field_map: dict[str, str],
) -> dict[str, Any]:
"""将日文键值映射并清洗为结构化字段。"""
normalized: dict[str, Any] = {}
for label, value in raw_data.items():
english_key = field_map.get(label)
if not english_key:
continue
cleaned_value = self._clean_text(value)
if english_key in MONEY_FIELDS:
normalized[english_key] = self._normalize_money(cleaned_value)
elif english_key in INT_FIELDS:
normalized[english_key] = self._normalize_int(cleaned_value)
elif english_key.endswith("_date"):
normalized[english_key] = self._normalize_date(cleaned_value)
else:
normalized[english_key] = cleaned_value
return normalized
def _split_lines(self, text: str | None) -> list[str]:
"""按行拆分文本,并清理每一行空白。"""
if not text:
return []
lines = [self._clean_text(part) for part in text.splitlines()]
return [line for line in lines if line]
def _clean_text(self, text: str | None) -> str | None:
"""清洗文本中的多余空白并统一空值。"""
if text is None:
return None
cleaned = re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip()
return cleaned or None
def _normalize_money(self, text: str | None) -> int | None:
"""将 `¥1,234` / `1,234円` 这类金额文本转成整数。"""
if not text:
return None
digits = re.findall(r"-?\d[\d,]*", text)
if not digits:
return None
value = digits[0].replace(",", "")
try:
return int(value)
except ValueError:
return None
def _normalize_int(self, text: str | None) -> int | None:
"""提取文本中的首个整数值。"""
if not text:
return None
digits = re.findall(r"\d+", text)
if not digits:
return None
return int(digits[0])
def _normalize_date(self, text: str | None) -> str | None:
"""将 `YYYY.MM.DD` / `YYYY/MM/DD` 统一为 `YYYY-MM-DD`。"""
if not text:
return None
match = re.search(r"(\d{4})[./-](\d{2})[./-](\d{2})", text)
if not match:
return text
year, month, day = match.groups()
return f"{year}-{month}-{day}"
async def parse_surugaya_order_detail(page: Page) -> dict[str, Any]:
"""便捷函数:直接返回可 JSON 序列化的订单字典。"""
parser = SurugayaOrderDetailParser()
result = await parser.parse_page(page)
return result.model_dump(mode="json")
+1
View File
@@ -0,0 +1 @@
# 消息队列模块包初始化
+137
View File
@@ -0,0 +1,137 @@
import asyncio
import json
import time
from typing import Any, Dict
import redis.asyncio as aioredis
from loguru import logger
from src.config.settings import get_settings
from src.task_queue.dispatcher import TaskDispatcher
_CLAIM_DUE_MEMBERS_LUA = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local count = tonumber(ARGV[2])
local vals = redis.call('ZRANGEBYSCORE', key, '-inf', now, 'LIMIT', 0, count)
if #vals > 0 then
redis.call('ZREM', key, unpack(vals))
end
return vals
"""
class RedisZSetDelayListener:
def __init__(self, dispatcher: TaskDispatcher) -> None:
self.settings = get_settings()
self.dispatcher = dispatcher
self.redis: aioredis.Redis | None = None
self.running = False
self.node_id = self.settings.node_id
self.redis_delay_zset_key = self.settings.redis_delay_zset_key
self.zset_key = f"{self.redis_delay_zset_key}:{self.node_id}"
self.batch_size = self.settings.redis_delay_batch_size
async def initialize(self) -> None:
logger.debug(f"正在连接 Redis(延时队列): {self.settings.redis_host}:{self.settings.redis_port}...")
self.redis = aioredis.Redis(
host=self.settings.redis_host,
port=self.settings.redis_port,
password=self.settings.redis_password,
db=self.settings.redis_db,
)
await self.redis.ping()
logger.debug("Redis(延时队列) 连接成功。")
async def listen(self) -> None:
if not self.redis:
await self.initialize()
self.running = True
logger.info(f"Redis ZSET 延时队列监听循环已启动,key='{self.zset_key}'")
while self.running:
try:
due_members = await self._claim_due_members()
if not due_members:
await asyncio.sleep(3)
continue
for member in due_members:
member_str = self._decode_member(member)
message_data = self._parse_member_json(member_str)
logger.debug(f"解析到到期任务: {message_data}")
await self.dispatcher.dispatch(message_data)
except aioredis.ConnectionError as ce:
logger.error(f"Redis(延时队列) 连接断开: {ce},5 秒后尝试重连...")
await self._handle_reconnect()
except Exception as e:
logger.exception(f"延时队列监听循环发生未预期错误: {e},5 秒后重试...")
await asyncio.sleep(5)
async def _claim_due_members(self) -> list[Any]:
if not self.redis:
return []
now_seconds = int(time.time())
try:
members = await self.redis.eval(
_CLAIM_DUE_MEMBERS_LUA,
1,
self.zset_key,
now_seconds,
self.batch_size,
)
return members or []
except Exception as e:
logger.error(f"延时队列认领到期任务失败: {e}")
return []
def _decode_member(self, member: Any) -> str:
if isinstance(member, bytes):
return member.decode("utf-8")
return str(member)
def _parse_member_json(self, member_str: str) -> Dict[str, Any]:
try:
data = json.loads(member_str)
if not isinstance(data, dict):
raise ValueError("member JSON 不是对象结构")
now = int(time.time())
task_data = {
"task_id": f"id_{now}",
"topic": "surugaya_trade_monitor",
"purchase_account": "trdian023@tokugen.jp",
"site_id": "7",
"record_video": "",
"payload": {
"trade_code": data.get("trade_code"),
},
}
return task_data
except Exception as e:
raise ValueError(f"延时队列 member 解析失败: {e} | member={member_str}")
async def _handle_reconnect(self) -> None:
self.redis = None
while self.running and not self.redis:
try:
await asyncio.sleep(5)
await self.initialize()
except Exception as e:
logger.error(f"重连失败: {e},继续重试...")
async def stop(self) -> None:
logger.info("正在停止 Redis ZSET 延时队列监听器...")
self.running = False
if self.redis:
try:
await self.redis.close()
logger.info("Redis(延时队列) 连接已关闭。")
except Exception as e:
logger.error(f"关闭 Redis(延时队列) 连接时出错: {e}")
finally:
self.redis = None
+119
View File
@@ -0,0 +1,119 @@
import asyncio
from collections import defaultdict
from typing import Callable, Coroutine, Dict, Any, Type
from loguru import logger
from src.tasks.surugaya import SurugayaPurchaseTask
from src.tasks.surugaya_trade_monitor import SurugayaTradeMonitorTask
# topic 到采购任务类的映射注册表
TOPIC_TO_TASK_MAP: Dict[str, Type[Any]] = {
"surugaya": SurugayaPurchaseTask, # 骏河屋采购任务
"surugaya_trade_monitor": SurugayaTradeMonitorTask, # 骏河屋交易订单获取任务
}
class TaskDispatcher:
"""
任务分发器:处理来自消息队列的采购任务。
- 在后台协程中执行任务,与 Redis 监听器解耦。
- 按账号维度使用 asyncio.Lock 串行化浏览器操作,避免冲突。
- 支持按 topic 模块化路由到不同工作流。
"""
def __init__(self) -> None:
# 按 account_id 维护锁字典,新账号会自动创建锁
# 单线程事件循环下完全异步安全
self._account_locks: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
logger.info("任务分发器已初始化(支持按账号串行锁)。")
async def dispatch(
self,
message_data: Dict[str, Any],
on_complete_callback: Callable[[str, bool, Any], Coroutine[Any, Any, None]] = None
) -> None:
"""
启动非阻塞后台协程处理任务,使监听器无需等待浏览器自动化完成即可继续轮询 Redis。
Args:
message_data: 已解析的任务消息字典。
on_complete_callback: 任务完成(成功或失败)时执行的异步回调。
"""
task_id = message_data.get("task_id", "N/A")
# logger.debug(f"正在为任务 '{task_id}' 启动后台协程...")
# 投递到事件循环,fire-and-forget
asyncio.create_task(self._execute_task_safe(message_data, on_complete_callback))
async def _execute_task_safe(
self,
message_data: Dict[str, Any],
on_complete_callback: Callable[[str, bool, Any], Coroutine[Any, Any, None]] = None
) -> None:
"""
在账号锁约束下执行具体采购流程,并统一记录异常。
"""
task_id = message_data.get("task_id", "N/A")
topic = str(message_data.get("topic"))
account_id = str(message_data.get("purchase_account"))
callback_id = message_data.get("_stream_msg_id") or message_data.get("_callback_id") or task_id
logger.info(f"[任务 {task_id}] 开始分发,topic='{topic}',账号='{account_id}'")
# 1. 校验必填字段
if not topic or not account_id:
logger.error(f"[任务 {task_id}] 缺少必填字段 topic 或 purchase_account,任务已中止。")
if on_complete_callback:
await on_complete_callback(str(callback_id), False, {"error": "缺少关键路由字段。", "result": None})
return
# 2. 根据 topic 匹配已注册的工作流
task_class = TOPIC_TO_TASK_MAP.get(topic.lower())
if not task_class:
logger.error(f"[任务 {task_id}] 未找到 topic '{topic}' 对应的采购任务类,任务已中止。")
if on_complete_callback:
await on_complete_callback(
str(callback_id),
False,
{"error": f"未知的 topic: {topic}", "result": None},
)
return
# 3. 获取该账号的串行锁
lock = self._account_locks[account_id]
logger.info(f"[任务 {task_id}] 账号 '{account_id}' 忙碌中,已进入串行锁队列等待...")
# 异步锁保证同一 account_id 同一时刻只有一个协程在执行
async with lock:
logger.success(f"[任务 {task_id}] 已获取账号 '{account_id}' 的锁,正在启动浏览器工作流...")
success = False
error_msg = None
result_data: Any = None
task_instance: Any = None
try:
# 实例化任务并执行
task_instance = task_class(message_data)
await task_instance.run()
success = True
result_data = getattr(task_instance, "task_result_data", None)
logger.success(f"[任务 {task_id}] 工作流执行成功。")
except Exception as e:
error_msg = str(e)
if task_instance:
result_data = getattr(task_instance, "task_result_data", None)
logger.error(f"[任务 {task_id}] 工作流因未处理异常而终止: {e}")
finally:
# 4. 触发完成回调(例如 Redis Stream ACK)
if on_complete_callback:
try:
await on_complete_callback(
str(callback_id),
success,
{"error": error_msg, "result": result_data},
)
except Exception as cb_err:
logger.error(f"[任务 {task_id}] 完成回调执行失败: {cb_err}")
logger.debug(f"[任务 {task_id}] 已释放账号 '{account_id}' 的锁。")
+205
View File
@@ -0,0 +1,205 @@
import asyncio
import json
from typing import Dict, Any
import redis.asyncio as aioredis
from loguru import logger
from src.config.settings import get_settings
from src.task_queue.dispatcher import TaskDispatcher
class RedisStreamListener:
"""
Redis Stream 持续消息监听器。
- 使用 Redis 消费者组在多消费者间分担负载。
- 启动时读取 Pending 任务,恢复崩溃前未完成的任务。
- 非阻塞地将任务交给 TaskDispatcher,执行完毕后再 ACK。
- 连接异常时安全重连。
"""
def __init__(self, dispatcher: TaskDispatcher) -> None:
self.settings = get_settings()
self.dispatcher = dispatcher
self.redis: aioredis.Redis | None = None
self.running = False
# 从全局配置读取 Stream 相关参数
self.stream_key = self.settings.redis_stream_key
self.group_name = self.settings.redis_consumer_group
self.consumer_name = self.settings.redis_consumer_name
async def initialize(self) -> None:
"""
连接 Redis,若消费者组不存在则创建。
"""
logger.debug(f"正在连接 Redis: {self.settings.redis_host}:{self.settings.redis_port}...")
self.redis = aioredis.Redis(
host=self.settings.redis_host,
port=self.settings.redis_port,
password=self.settings.redis_password,
db=self.settings.redis_db
)
# 测试连接
await self.redis.ping()
logger.debug("Redis 连接成功。")
# 确保 Stream 消费者组存在
try:
# mkstream=True:若 Stream 不存在则自动创建
await self.redis.xgroup_create(
name=self.stream_key,
groupname=self.group_name,
id="0",
mkstream=True
)
logger.debug(f"已创建消费者组 '{self.group_name}',Stream: '{self.stream_key}'")
except Exception as e:
# BUSYGROUP 表示消费者组已存在,属于正常情况
if "BUSYGROUP" in str(e):
logger.debug(f"消费者组 '{self.group_name}' 已存在。")
else:
logger.error(f"创建消费者组失败: {e}")
raise e
async def listen(self) -> None:
"""
启动监听循环:先处理 Pending 任务,再持续读取新消息。
"""
if not self.redis:
await self.initialize()
self.running = True
logger.info("Redis Stream 监听循环已启动。")
# 1. 先恢复分配给本消费者但未 ACK 的 Pending 消息(崩溃重启清理)
await self._consume_pending_messages()
# 2. 主循环:持续消费新任务
while self.running:
try:
# 读取新消息(">" 表示从未投递给其他消费者的消息)
streams = await self.redis.xreadgroup(
groupname=self.group_name,
consumername=self.consumer_name,
streams={self.stream_key: ">"},
count=1,
block=3000 # 阻塞 3 秒,保持事件循环响应
)
if not streams:
# 超时无新消息,继续下一轮
continue
for stream, messages in streams:
for msg_id, raw_data in messages:
parsed_msg = self._parse_message(msg_id, raw_data)
# 交给分发器处理,并注册 ACK 回调
await self.dispatcher.dispatch(parsed_msg, on_complete_callback=self.ack_message)
except aioredis.ConnectionError as ce:
logger.error(f"Redis 连接断开: {ce},5 秒后尝试重连...")
await self._handle_reconnect()
except Exception as e:
logger.exception(f"监听循环发生未预期错误: {e},5 秒后重试...")
await asyncio.sleep(5)
async def _consume_pending_messages(self) -> None:
"""
检查并处理本消费者在 PEL(Pending Entries List)中的未确认任务。
保证崩溃后可恢复中断的作业。
"""
logger.info("正在检查 PEL 中的待恢复任务...")
try:
# 读取已投递给本消费者但未 ACK 的消息(id="0")
streams = await self.redis.xreadgroup(
groupname=self.group_name,
consumername=self.consumer_name,
streams={self.stream_key: "0"},
count=10,
block=500
)
if not streams:
logger.info("没有需要恢复的待处理任务。")
return
for stream, messages in streams:
if messages:
logger.warning(f"发现 {len(messages)} 条待恢复任务,正在重新处理...")
for msg_id, raw_data in messages:
parsed_msg = self._parse_message(msg_id, raw_data)
await self.dispatcher.dispatch(parsed_msg, on_complete_callback=self.ack_message)
except Exception as e:
logger.error(f"恢复待处理任务失败: {e}")
def _parse_message(self, msg_id: bytes, raw_data: Dict[bytes, bytes]) -> Dict[str, Any]:
"""
将 Redis 二进制数据解码为字符串,并组装为任务字典。
"""
decoded_data: Dict[str, Any] = {}
for k, v in raw_data.items():
key_str = k.decode("utf-8")
val_str = v.decode("utf-8")
decoded_data[key_str] = val_str
# 若 payload 为 JSON 字符串,则反序列化
if "payload" in decoded_data:
try:
payload_value = decoded_data["payload"]
if isinstance(payload_value, str):
decoded_data = json.loads(payload_value)
except Exception:
# 非 JSON 格式时保持原字符串
pass
# 注入 Stream 消息 ID,供后续 ACK 使用
decoded_data["_stream_msg_id"] = msg_id.decode("utf-8")
logger.debug(f"解析消息 '{msg_id.decode('utf-8')}',内容: {decoded_data}")
return decoded_data
async def ack_message(self, msg_id: str, success: bool, error_msg: Any = None) -> None:
"""
任务结束后的回调:执行 Redis Stream XACK,从 PEL 中移除已处理消息。
"""
if not self.redis:
logger.error(f"Redis 未连接,无法 ACK 消息 '{msg_id}'")
return
try:
await self.redis.xack(self.stream_key, self.group_name, msg_id)
if success:
logger.debug(f"消息 '{msg_id}' 已 ACK,任务执行成功。")
else:
detail = error_msg
if isinstance(error_msg, dict) and "error" in error_msg:
detail = error_msg.get("error")
logger.warning(f"消息 '{msg_id}' 已 ACK,任务执行失败: {detail}")
except Exception as e:
logger.error(f"ACK 消息 '{msg_id}' 失败: {e}")
async def _handle_reconnect(self) -> None:
"""
周期性尝试重连 Redis,直至成功。
"""
self.redis = None
while not self.redis:
try:
await asyncio.sleep(5)
await self.initialize()
except Exception as e:
logger.error(f"重连失败: {e},继续重试...")
async def stop(self) -> None:
"""
停止监听循环并安全关闭 Redis 连接。
"""
logger.info("正在停止 Redis Stream 监听器...")
self.running = False
if self.redis:
try:
await self.redis.close()
logger.info("Redis 连接已关闭。")
except Exception as e:
logger.error(f"关闭 Redis 连接时出错: {e}")
finally:
self.redis = None
+59
View File
@@ -0,0 +1,59 @@
from functools import lru_cache
import redis.asyncio as aioredis
from loguru import logger
from src.config.settings import get_settings
class RedisManager:
"""
管理任务侧共享 Redis 客户端的生命周期。
"""
def __init__(self) -> None:
self.settings = get_settings()
self._redis: aioredis.Redis | None = None
async def initialize(self) -> None:
if self._redis is not None:
return
logger.debug(
f"正在初始化任务侧 RedisManager: {self.settings.redis_host}:{self.settings.redis_port}..."
)
redis_client = aioredis.Redis(
host=self.settings.redis_host,
port=self.settings.redis_port,
password=self.settings.redis_password,
db=self.settings.redis_db,
health_check_interval=30, # 定期心跳保活,防止长连接被防火墙静默掐断
max_connections=50, # 限制最大连接数,防止突发并发耗尽 Redis 资源
)
await redis_client.ping()
self._redis = redis_client
logger.info("RedisManager 初始化成功。")
def get_client(self) -> aioredis.Redis:
if self._redis is None:
raise RuntimeError("RedisManager 尚未初始化,请先在应用启动阶段调用 initialize()。")
return self._redis
async def close(self) -> None:
if self._redis is None:
return
try:
await self._redis.close()
logger.info("任务侧 RedisManager 已关闭。")
finally:
self._redis = None
@lru_cache
def get_redis_manager() -> RedisManager:
return RedisManager()
def get_redis_client() -> aioredis.Redis:
return get_redis_manager().get_client()
+1
View File
@@ -0,0 +1 @@
# 任务模块包初始化
+325
View File
@@ -0,0 +1,325 @@
import asyncio
from abc import ABC, abstractmethod
from datetime import datetime
import json
from pathlib import Path
from typing import Dict, Any
import redis.asyncio as aioredis
from playwright.async_api import Playwright, BrowserContext, Page
from loguru import logger
from src.config.settings import get_settings
from src.config.accounts import get_account_config, AccountConfig
from src.browser.manager import BrowserManager
from src.notifications.service import NotificationService
from src.utils.video_recorder import TaskVideoRecorder
class BasePurchaseTask(ABC):
"""
采购任务基类:定义工作流骨架与生命周期。
- 从配置文件解析账号信息。
- 启动并管理隔离的 Playwright 上下文。
- 支持自定义截图,失败时自动紧急截图。
- 任何情况下都强制回收浏览器资源,防止泄漏。
"""
def __init__(self, message_data: Dict[str, Any]) -> None:
self.message = message_data
self.settings = get_settings()
self.browser_manager = BrowserManager()
self.notification_service = NotificationService(self.settings)
# 从消息中提取的任务参数
self.task_id: str = message_data.get("task_id", "N/A")
self.topic: str = message_data.get("topic", "unknown")
self.account_id: str = message_data.get("purchase_account", "")
self.site_id: str = message_data.get("site_id", "unknown")
# 需在结束时清理的 Playwright 对象
self.playwright_driver: Playwright | None = None
self.context: BrowserContext | None = None
self.page: Page | None = None
self.video_recorder: TaskVideoRecorder | None = None
self.video_output_path: Path | None = None
self.last_screenshot_path: Path | None = None
self.record_video_enabled = self._resolve_record_video_enabled()
# 记录任务执行状态与错误信息
self.is_success: bool = False
self.error_message: str = ""
self.task_result_data: Any = None
# 上报的事件,有值才上报
self.report_event: str = ""
# 是否上报任务结果
self.is_report: bool = False
async def run(self) -> None:
"""
执行采购任务完整生命周期:初始化、业务逻辑、异常处理、资源清理。
"""
logger.info(f"[任务 {self.task_id}] 正在解析账号配置,账号 ID: '{self.account_id}'...")
# 1. 查找账号配置(先骏河屋,后续可扩展其他站点)
account_config = get_account_config("surugaya", self.account_id)
if not account_config:
err_msg = f"无法解析账号 '{self.account_id}' 的配置。"
logger.error(f"[任务 {self.task_id}] {err_msg}")
raise ValueError(err_msg)
logger.debug(
f"[任务 {self.task_id}] 使用账号: {account_config.username} | 数据目录: {account_config.user_data_dir}")
try:
# 开启任务录屏(如果配置)
if self.record_video_enabled:
self.video_recorder = TaskVideoRecorder(
archives_root=self.settings.screenshot_dir,
topic=self.topic,
task_id=self.task_id,
)
self.video_output_path = await self.video_recorder.start()
logger.warning(
f"[任务 {self.task_id}] 已开启任务录屏,输出文件: "
f"{self.video_output_path.name} | 路径: {self.video_output_path.absolute()}"
)
# 通过 BrowserManager 启动浏览器
self.playwright_driver, self.context = await self.browser_manager.get_context(account_config)
# 打开新标签页
self.page = await self.context.new_page()
# 交由子类实现具体站点逻辑
self.task_result_data = await self.execute()
self.is_success = True
except Exception as e:
self.error_message = str(e)
# 未预期失败时拍摄紧急截图留存现场
logger.error(f"[任务 {self.task_id}] 执行出错: {e},延迟关闭并截图")
if self.page:
try:
await asyncio.sleep(0.5)
await self.take_screenshot(scene="任务执行出错")
except Exception as ss_err:
logger.error(f"[任务 {self.task_id}] 截图失败: {ss_err}")
# 在挂起排障前,必须先结束录屏,否则录像进程会无休止录制占用硬盘
await self._stop_video_record_safely()
# 通知平台人员任务失败
await self._notify_task_failed()
# raise
finally:
# 正常结束或未被 except 拦截的其他情况(如主动 Cancel),确保停止录屏
await self._stop_video_record_safely()
# 无论成败都回收 Playwright 后台进程
logger.debug(f"[任务 {self.task_id}] 正在清理浏览器上下文并停止驱动...")
try:
if self.context:
await self.context.close()
if self.playwright_driver:
await self.playwright_driver.stop()
logger.debug(f"[任务 {self.task_id}] 浏览器资源已安全释放。")
except Exception as close_err:
logger.error(f"[任务 {self.task_id}] 释放浏览器资源时出错: {close_err}")
# 上报任务结果
if self.is_report:
await self.report_task_result()
@abstractmethod
async def execute(self) -> Any:
"""
抽象方法:各站点具体的采购业务流程。
由子类实现(例如 SurugayaPurchaseTask),可返回执行结果数据以供上报。
"""
pass
async def report_task_result(self) -> None:
"""
上报任务执行结果。
无论成功还是失败都会调用,其失败不影响消息的 ACK。
"""
url = self.settings.callback_url
if not url:
return
payload = {
"event": self.report_event,
"task_id": self.task_id,
"is_success": 1 if self.is_success else 0,
"error_message": self.error_message,
"result_data": self.task_result_data,
"original_message": self.message,
}
logger.info(f"[任务 {self.task_id}] 正在上报任务结果 (is_success={payload['is_success']}) 至 {url} ...")
logger.debug(f"[任务 {self.task_id}] 上报内容: {json.dumps(payload, ensure_ascii=False)}")
try:
await self._send_http_report_async(url, payload)
logger.success(f"[任务 {self.task_id}] 任务结果上报成功。")
except Exception as e:
logger.error(f"[任务 {self.task_id}] 任务结果上报失败: {e}")
async def _send_http_report_async(self, url: str, data: dict) -> None:
"""
使用 httpx 发送异步 HTTP 请求,并附带签名认证(AppId + 签名摘要)。
"""
import json
import httpx
from src.utils.http_utils import generate_signed_headers
# 1. 准备基础数据
app_id = self.settings.appid or ""
app_secret = self.settings.app_secret or ""
# 2. 组装 Headers
headers = generate_signed_headers(app_id, app_secret, data)
headers["Content-Type"] = "application/json; charset=utf-8"
# 3. 序列化 Body,需与生成签名时的序列化方式一致
payload_bytes = json.dumps(data, ensure_ascii=False).encode("utf-8")
# 4. 异步发送请求
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, content=payload_bytes, headers=headers)
response.raise_for_status()
# 5. 解析响应内容(根据实际业务接口文档可作调整)
response_data = response.json()
logger.info(f"[任务 {self.task_id}] 任务结果上报成功,响应内容: {response_data}")
async def _stop_video_record_safely(self) -> None:
"""
安全停止录屏的幂等方法,确保任务出错挂起或自然结束时都能正确停止 FFmpeg。
"""
if self.video_recorder and self.video_recorder.is_recording:
try:
saved_video_path = await self.video_recorder.stop()
if saved_video_path:
self.video_output_path = saved_video_path
logger.warning(
f"[任务 {self.task_id}] 任务录屏已保存: "
f"{saved_video_path.name} | 路径: {saved_video_path.absolute()}"
)
else:
logger.warning(f"[任务 {self.task_id}] 录屏已停止,但未找到输出文件。")
except Exception as video_err:
logger.error(f"[任务 {self.task_id}] 停止录屏时出错: {video_err}")
async def take_screenshot(self, scene: str, **screenshot_options: Any) -> Path | None:
"""
截取当前页面并保存至:
archives/{YYYYMMDD}/{topic}/{task_id}_{scene}_{timestamp}.png
额外的 screenshot 参数会透传给 Playwright 的 page.screenshot(),
例如:full_page=True
"""
if not self.page:
logger.warning(f"[任务 {self.task_id}] 无法截图 '{scene}':当前没有可用的 Page 对象。")
return None
# 1. 按日期组织目录
now = datetime.now()
date_str = now.strftime("%Y%m%d")
timestamp_ms = int(now.timestamp() * 1000)
# 2. 清理路径中的非法字符(仅保留字母数字、连字符、下划线)
topic_clean = "".join(c for c in self.topic if c.isalnum() or c in ("_", "-"))
scene_clean = "".join(c for c in scene if c.isalnum() or c in ("_", "-"))
task_id_clean = "".join(c for c in self.task_id if c.isalnum() or c in ("_", "-"))
# 3. 创建目标目录
screenshot_dir = Path(self.settings.screenshot_dir) / date_str / topic_clean
try:
screenshot_dir.mkdir(parents=True, exist_ok=True)
filename = f"{task_id_clean}_{scene_clean}_{timestamp_ms}.png"
filepath = screenshot_dir / filename
logger.debug(f"[任务 {self.task_id}] 保存截图至: {filepath.absolute()}")
# 加入默认超时时间,避免默认的 30s 导致任务长时间挂起
if "timeout" not in screenshot_options:
screenshot_options["timeout"] = 10000
try:
await self.page.screenshot(
path=str(filepath.absolute()),
**screenshot_options,
)
except Exception as e:
# Playwright 截图时常会因为等待 Web 字体加载 (waiting for fonts to load) 而超时挂起
if "Timeout" in str(e) or "fonts" in str(e):
logger.warning(f"[任务 {self.task_id}] 截图遇到超时或字体加载阻塞,尝试强制停止页面网络后重试...")
# 强制停止页面中所有还在 pending 的网络请求(如加载极慢的外部字体)
await self.page.evaluate("window.stop()")
await self.page.screenshot(
path=str(filepath.absolute()),
timeout=5000, # 重试给 5 秒
)
else:
raise e
logger.success(f"[任务 {self.task_id}] 截图已保存: {filename}")
self.last_screenshot_path = filepath
return filepath
except Exception as e:
logger.error(f"[任务 {self.task_id}] 保存场景 '{scene}' 的截图失败: {e}")
return None
def _resolve_record_video_enabled(self) -> bool:
top_level_value = self.message.get("record_video")
if top_level_value is not None:
return str(top_level_value).strip() == "1"
payload = self.message.get("payload")
if isinstance(payload, dict) and "record_video" in payload:
return str(payload.get("record_video")).strip() == "1"
return False
def _build_failure_notification_context(self) -> Dict[str, Any]:
return {
"title": "任务执行失败",
"task_id": self.task_id,
"topic": self.topic,
"account_id": self.account_id,
"site_id": self.site_id,
"error_message": self.error_message,
"screenshot_path": str(self.last_screenshot_path.absolute()) if self.last_screenshot_path else "",
"video_path": str(self.video_output_path.absolute()) if self.video_output_path else "",
"occurred_at": datetime.now().isoformat(timespec="seconds"),
"message": self.message,
}
async def _notify_task_failed(self) -> None:
try:
await self.notification_service.notify_task_failed(
self._build_failure_notification_context()
)
except Exception as notify_err:
logger.error(f"[任务 {self.task_id}] 发送失败通知时出错: {notify_err}")
def get_delay_zset_key(self) -> str:
return f"{self.settings.redis_delay_zset_key}:{self.settings.node_id}"
def get_hash_key(self) -> str:
"""
生成Redis Hash表的key,包含节点ID和交易单号。
"""
return "jp_surugaya_trade"
def get_delay_interval_seconds(self) -> int:
"""
获取延时队列间隔,单位秒。
"""
intervals = self.settings.redis_delay_poll_interval or 3600
return intervals
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
import json
import time
from typing import Any
from loguru import logger
from parsers.surugaya import SurugayaOrderDetailParser
from src.tasks.base import BasePurchaseTask
from task_queue.redis_manager import get_redis_client
class SurugayaTradeMonitorTask(BasePurchaseTask):
"""
骏河屋交易订单获取任务。
入参约定:
- topic: surugaya_trade_monitor
- payload.trade_list: 交易单号列表
"""
async def execute(self) -> dict[str, Any]:
self.report_event = "surugaya_trade_monitor"
self.is_report = False
redis_client = None
trade_code = ""
try:
logger.debug(f"[任务 {self.task_id}] 骏河屋交易订单获取工作流已启动。")
if not self.page:
raise ValueError("页面对象未初始化,无法执行交易订单获取任务。")
payload = self.message.get("payload", {})
trade_code = payload.get("trade_code", "")
if not trade_code:
err_msg = f"无法解析交易单号 '{trade_code}'"
logger.error(err_msg)
raise ValueError(err_msg)
page = self.page
await page.goto(f"https://www.suruga-ya.jp/pcmypage/action_sell_search/detail?trade_code={trade_code}",
wait_until="domcontentloaded", timeout=30000)
order_detail_model = await SurugayaOrderDetailParser.parse_page(page)
order_detail = order_detail_model.model_dump(mode="json") if order_detail_model else None
logger.info(f"[任务 {self.task_id}] 骏河屋交易订单获取工作流执行成功,订单详情:{order_detail}")
# 写入hash表
if not order_detail:
raise Exception(f"交易单号 '{trade_code}' 未解析到订单详情。")
new_trade_status = order_detail_model.status.trade_status or ""
trade_number = order_detail_model.summary.trade_number or None
redis_client = get_redis_client()
# 更新hash表
hash_key = self.get_hash_key()
delay_zset_key = self.get_delay_zset_key()
requeue_member = json.dumps({"trade_code": trade_code}, ensure_ascii=False)
# 获取上次保存的订单状态
old_trade_status = None
existing_order_detail = await redis_client.hget(hash_key, trade_code)
if existing_order_detail:
old_trade_status = json.loads(existing_order_detail)["status"]["trade_status"]
if old_trade_status and old_trade_status != new_trade_status:
logger.info(f"[任务 {self.task_id}] 订单状态发生改变,需要上报。")
self.is_report = True
await redis_client.hset(
hash_key,
trade_code,
json.dumps(order_detail, ensure_ascii=False),
)
logger.info(
f"[任务 {self.task_id}] 已将订单详情写入 Redis Hash '{hash_key}',field='{trade_code}'"
)
enqueue = True # 是否需要再次入队
if trade_number is not None:
logger.warning(
f"[任务 {self.task_id}] 订单物流单号已获取到 '{trade_number}',放弃再次入队。"
)
enqueue = False
elif new_trade_status == "発送済み" or new_trade_status == "取引終了":
logger.warning(f"[任务 {self.task_id}] 订单状态为 '{new_trade_status}',放弃再次入队。")
enqueue = False
# 任务再次入队
if enqueue:
next_score = int(time.time()) + self.get_delay_interval_seconds()
await redis_client.zadd(delay_zset_key, {requeue_member: next_score})
logger.info(
f"[任务 {self.task_id}] 延时任务已重新入队,key='{delay_zset_key}',score={next_score}"
)
return {
"trade_code": trade_code,
"order_detail": order_detail,
}
except Exception as e:
self.is_report = False
logger.error(f"[任务 {self.task_id}] 骏河屋交易订单获取工作流执行失败,错误信息:{e}")
raise e
+1
View File
@@ -0,0 +1 @@
# 工具模块包初始化
+4
View File
@@ -0,0 +1,4 @@
def is_valid_str(s):
"""判断字符串是否有效,即非空且非空格。"""
return isinstance(s, str) and len(s.strip()) > 0
+38
View File
@@ -0,0 +1,38 @@
import json
import time
import hmac
import secrets
import hashlib
from typing import Dict, Any
def generate_signed_headers(app_id: str, app_secret: str, data: Dict[str, Any], method: str = "POST") -> Dict[str, str]:
timestamp = str(int(time.time() * 1000))
nonce = secrets.token_hex(16)
payload_str = json.dumps(data, ensure_ascii=False)
payload_bytes = payload_str.encode("utf-8")
actual_body_hash = hashlib.sha256(payload_bytes).hexdigest()
string_to_sign = "\n".join([
method.upper(),
app_id,
timestamp,
nonce,
actual_body_hash
])
signature = hmac.new(
app_secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha256
).hexdigest()
headers = {
"X-Ca-Appid": app_id,
"X-Ca-Timestamp": timestamp,
"X-Ca-Nonce": nonce,
"X-Ca-Signature": signature,
"Content-Type": "application/json; charset=utf-8"
}
return headers
+71
View File
@@ -0,0 +1,71 @@
import sys
from pathlib import Path
from loguru import logger
from src.config.settings import get_settings
# 统一日志格式:日期时间 | 级别 | 消息(不含模块/函数/行号)
_LOG_FORMAT = "{time:YYYY-MM-DD HH:mm:ss} | {level: <5} | {message}"
# 控制台在相同格式上叠加颜色(内容与文件一致)
_CONSOLE_FORMAT = (
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <5}</level> | "
"<level>{message}</level>"
)
def setup_logger() -> None:
"""
配置并初始化 Loguru 日志系统,具备以下能力:
- 控制台与文件使用相同的时间与字段布局。
- 非阻塞异步日志写入(内部任务队列)。
- 每天 00:00 自动按日切割日志文件。
- 保留最近 14 天历史,旧日志自动压缩为 zip。
- 支持通过配置自定义日志目录。
"""
settings = get_settings()
log_dir = Path(settings.log_dir)
# 确保日志目录存在
try:
log_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
print(f"严重错误: 无法创建日志目录 '{log_dir}': {e}", file=sys.stderr)
raise e
# 移除默认 handler,避免重复输出
logger.remove()
# 覆盖 INFO 级别的控制台颜色为白色
logger.level("INFO", color="<green>")
logger.level("DEBUG", color="<white>")
is_dev = settings.app_env.lower() == "dev"
# 1. 控制台输出
logger.add(
sys.stderr,
format=_CONSOLE_FORMAT,
level=settings.log_level.upper(),
enqueue=True,
backtrace=is_dev,
diagnose=is_dev,
)
# 2. 文件输出(仅在非 dev 环境,或特定配置下启用)
# 按日切割:logs/purchase_YYYY-MM-DD.log
if not is_dev:
log_file_pattern = log_dir / "app_{time:YYYY-MM-DD}.log"
logger.add(
str(log_file_pattern),
format=_LOG_FORMAT,
level=settings.log_level.upper(),
rotation="00:00", # 每天午夜 00:00 切割
retention="14 days", # 自动清理 14 天前的日志
compression="zip", # 历史日志压缩为 zip
enqueue=True, # 异步线程/协程安全写入
backtrace=True,
encoding="utf-8",
)
logger.info(f"日志系统已初始化。级别: {settings.log_level.upper()},目录: {log_dir.absolute()}")
+116
View File
@@ -0,0 +1,116 @@
from playwright.async_api import Page
import asyncio
import random
import re
async def human_scroll(page: Page, mode: str, value: int = 0):
"""
模拟人类行为的网页滚动方法
:param page: Playwright 的 Page 对象
:param mode: 滚动模式 -> 'top'(滚动到顶部), 'bottom'(滚动到底部), 'random'(随机上下滚动), 'distance'(滚动指定距离)
:param value: 当 mode='distance' 时,代表要滚动的像素距离(正数向下,负数向上)
"""
async def smooth_scroll_step(pixel_distance: int):
"""核心拟真算法:将一个大距离拆分成带有加速度和随机抖动的微小步长"""
if pixel_distance == 0:
return
direction = 1 if pixel_distance > 0 else -1
remaining = abs(pixel_distance)
while remaining > 0:
# 模拟人类滚动时越接近目标速度越慢的特征(减速缓冲)
if remaining > 100:
step = random.randint(30, 70)
elif remaining > 30:
step = random.randint(10, 25)
else:
step = remaining
remaining -= step
scroll_y = step * direction
# 执行单步微调滚动
await page.evaluate(f"window.scrollBy(0, {scroll_y});")
# 随机微小停顿,模拟手指滑动的摩擦阻力(20ms - 50ms)
await asyncio.sleep(random.uniform(0.02, 0.05))
# 0.5% 的极低概率触发人类的“视线停留”
if random.random() < 0.005:
await asyncio.sleep(random.uniform(0.3, 0.8))
# ==================== 模式 0:滚动到顶部 ====================
if mode == 'top':
# print("[HumanScroll] 正在模拟向上滚动至页面顶部...")
last_scroll = -1
while True:
# 获取当前滚动条位置
current_scroll = await page.evaluate("window.scrollY")
# 防死循环:如果位置不再变化,说明已经到顶或页面无法滚动
if current_scroll <= 0 or current_scroll == last_scroll:
break
last_scroll = current_scroll
chunk = random.randint(300, 700)
await smooth_scroll_step(-chunk)
await asyncio.sleep(random.uniform(0.5, 1.2)) # 每滑一下停顿看一眼
# ==================== 模式 1:滚动到底部 ====================
elif mode == 'bottom':
# print("[HumanScroll] 正在模拟向下滚动至页面底部...")
last_scroll = -1
while True:
# 合并 evaluate 请求,减少与浏览器的 IPC 通信开销
scroll_info = await page.evaluate("() => [window.scrollY + window.innerHeight, document.body.scrollHeight]")
current_scroll, total_height = scroll_info[0], scroll_info[1]
# 防死循环:如果位置不再变化,说明已经到底或页面无法滚动
if current_scroll >= total_height - 10 or current_scroll == last_scroll:
break
last_scroll = current_scroll
chunk = random.randint(300, 700)
await smooth_scroll_step(chunk)
await asyncio.sleep(random.uniform(0.5, 1.2)) # 每滑一下停顿看一眼
# ==================== 模式 2:随机上下滚动几下 ====================
elif mode == 'random':
# print("[HumanScroll] 正在执行人类迷惑行为:随机上下滚动...")
# 随机决定滚动 3 到 6 下
scroll_times = random.randint(3, 6)
for _ in range(scroll_times):
# 70% 概率向下,30% 概率向上
if random.random() > 0.3:
distance = random.randint(200, 500) # 向下滚
else:
distance = random.randint(-400, -150) # 向上回滚
await smooth_scroll_step(distance)
await asyncio.sleep(random.uniform(0.6, 1.5)) # 停顿
# ==================== 模式 3:滚动指定距离 ====================
elif mode == 'distance':
# print(f"[HumanScroll] 正在精准模拟滚动指定距离: {value} 像素")
await smooth_scroll_step(value)
else:
raise ValueError("未知的滚动模式!请选择 'top', 'bottom', 'random''distance'")
def format_japanese_price(price_str: str | None) -> int:
"""
将日本电商网站的价格字符串格式化为标准整数
支持的格式示例: "600円 (税込)", "1,250円", "¥ 3,000", "300"
"""
if not price_str:
return 0
digits = re.findall(r'\d', price_str)
if not digits:
return 0
return int("".join(digits))
+136
View File
@@ -0,0 +1,136 @@
import asyncio
import shutil
from datetime import datetime
from pathlib import Path
import pyautogui
def _sanitize_path_component(value: str) -> str:
sanitized = "".join(c for c in value if c.isalnum() or c in ("_", "-"))
return sanitized or "unknown"
class TaskVideoRecorder:
"""
使用 FFmpeg 进行 Windows 整桌面录制的轻量封装。
生命周期由任务基类控制:start() 开始录制,stop() 停止并返回产物路径。
"""
def __init__(
self,
archives_root: str,
topic: str,
task_id: str,
ffmpeg_bin: str = "ffmpeg",
fps: int = 12,
) -> None:
self.archives_root = Path(archives_root)
self.topic = _sanitize_path_component(topic)
self.task_id = _sanitize_path_component(task_id)
self.ffmpeg_bin = ffmpeg_bin
self.fps = fps
self.process: asyncio.subprocess.Process | None = None
self.output_path: Path | None = None
@property
def is_recording(self) -> bool:
return self.process is not None and self.process.returncode is None
async def start(self) -> Path:
if self.is_recording and self.output_path is not None:
return self.output_path
ffmpeg_path = shutil.which(self.ffmpeg_bin)
if not ffmpeg_path:
raise RuntimeError("未找到 FFmpeg 可执行文件,请先确保 ffmpeg 已安装并可通过 PATH 调用。")
now = datetime.now()
date_str = now.strftime("%Y%m%d")
timestamp_ms = int(now.timestamp() * 1000)
output_dir = self.archives_root / date_str / self.topic
output_dir.mkdir(parents=True, exist_ok=True)
self.output_path = output_dir / f"video_{self.task_id}_{timestamp_ms}.mp4"
# 动态获取主屏幕的分辨率,避免录制到第二显示器
screen_width, screen_height = pyautogui.size()
command = [
ffmpeg_path,
"-y",
"-loglevel",
"error",
"-f",
"gdigrab",
"-draw_mouse",
"1",
"-framerate",
str(self.fps),
"-video_size",
f"{screen_width}x{screen_height}",
"-offset_x",
"0",
"-offset_y",
"0",
"-i",
"desktop",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
str(self.output_path),
]
self.process = await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
# 短暂等待,尽早暴露 FFmpeg 启动失败(编码器/权限/设备占用等)。
await asyncio.sleep(0.5)
if self.process.returncode is not None:
_, stderr_data = await self.process.communicate()
stderr_text = stderr_data.decode("utf-8", errors="ignore").strip()
self.process = None
failed_path = self.output_path
self.output_path = None
raise RuntimeError(
"FFmpeg 录屏启动失败"
+ (f": {stderr_text}" if stderr_text else "")
+ (f" | 目标文件: {failed_path}" if failed_path else "")
)
return self.output_path
async def stop(self) -> Path | None:
if not self.process:
return self.output_path
process = self.process
self.process = None
try:
if process.returncode is None and process.stdin:
process.stdin.write(b"q\n")
await process.stdin.drain()
await asyncio.wait_for(process.wait(), timeout=10)
except asyncio.TimeoutError:
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=5)
except asyncio.TimeoutError:
process.kill()
await process.wait()
except ProcessLookupError:
pass
finally:
if process.stdin and not process.stdin.is_closing():
process.stdin.close()
return self.output_path if self.output_path and self.output_path.exists() else None
Generated
+714
View File
@@ -0,0 +1,714 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[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 = "certifi"
version = "2026.5.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[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 = "greenlet"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" },
{ url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" },
{ url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" },
{ url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" },
{ url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" },
{ url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" },
{ url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" },
{ url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" },
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" },
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" },
{ url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" },
{ url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" },
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" },
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" },
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" },
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
{ url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" },
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" },
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" },
{ url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" },
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" },
{ url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" },
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" },
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" },
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" },
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" },
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" },
{ url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" },
{ url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" },
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" },
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" },
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
]
[[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 = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
]
[[package]]
name = "jp-purchase-zdh-5"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "httpx" },
{ name = "loguru" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
{ name = "playwright" },
{ name = "pyautogui" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyscreeze" },
{ name = "pyyaml" },
{ name = "redis" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "loguru", specifier = ">=0.7.2" },
{ name = "numpy", specifier = ">=2.4.6" },
{ name = "opencv-python", specifier = ">=4.13.0.92" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "playwright", specifier = ">=1.43.0" },
{ name = "pyautogui", specifier = ">=0.9.54" },
{ name = "pydantic", specifier = ">=2.7.0" },
{ name = "pydantic-settings", specifier = ">=2.2.0" },
{ name = "pyscreeze", specifier = ">=1.0.1" },
{ name = "pyyaml", specifier = ">=6.0.1" },
{ name = "redis", specifier = "==8.0.0" },
]
[[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 = "mouseinfo"
version = "0.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyperclip" },
{ name = "python3-xlib", marker = "sys_platform == 'linux'" },
{ name = "rubicon-objc", marker = "sys_platform == 'darwin'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/fa/b2ba8229b9381e8f6381c1dcae6f4159a7f72349e414ed19cfbbd1817173/MouseInfo-0.1.3.tar.gz", hash = "sha256:2c62fb8885062b8e520a3cce0a297c657adcc08c60952eb05bc8256ef6f7f6e7", size = 10850, upload-time = "2020-03-27T21:20:10.136Z" }
[[package]]
name = "numpy"
version = "2.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
{ url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
{ url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
{ url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
{ url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
{ url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
{ url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
{ url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
{ url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
{ url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
{ url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
{ url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
{ url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
{ url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
{ url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
{ url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
{ url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
{ url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
{ url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
{ url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
{ url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
{ url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
{ url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
{ url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
{ url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
{ url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
{ url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
{ url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
{ url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
{ url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
{ url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
{ url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
{ url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
{ url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
{ url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
{ url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
]
[[package]]
name = "opencv-python"
version = "4.13.0.92"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" },
{ url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" },
{ url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" },
{ url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" },
{ url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" },
{ url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]]
name = "playwright"
version = "1.60.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" },
{ url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" },
{ url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" },
{ url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" },
{ url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" },
{ url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" },
{ url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" },
]
[[package]]
name = "pyautogui"
version = "0.9.54"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mouseinfo" },
{ name = "pygetwindow" },
{ name = "pymsgbox" },
{ name = "pyobjc-core", marker = "sys_platform == 'darwin'" },
{ name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" },
{ name = "pyscreeze" },
{ name = "python3-xlib", marker = "sys_platform == 'linux'" },
{ name = "pytweening" },
]
sdist = { url = "https://files.pythonhosted.org/packages/65/ff/cdae0a8c2118a0de74b6cf4cbcdcaf8fd25857e6c3f205ce4b1794b27814/PyAutoGUI-0.9.54.tar.gz", hash = "sha256:dd1d29e8fd118941cb193f74df57e5c6ff8e9253b99c7b04f39cfc69f3ae04b2", size = 61236, upload-time = "2023-05-24T20:11:32.972Z" }
[[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 = "pygetwindow"
version = "0.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyrect" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/70/c7a4f46dbf06048c6d57d9489b8e0f9c4c3d36b7479f03c5ca97eaa2541d/PyGetWindow-0.0.9.tar.gz", hash = "sha256:17894355e7d2b305cd832d717708384017c1698a90ce24f6f7fbf0242dd0a688", size = 9699, upload-time = "2020-10-04T02:12:50.806Z" }
[[package]]
name = "pymsgbox"
version = "2.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/6a/e80da7594ee598a776972d09e2813df2b06b3bc29218f440631dfa7c78a8/pymsgbox-2.0.1.tar.gz", hash = "sha256:98d055c49a511dcc10fa08c3043e7102d468f5e4b3a83c6d3c61df722c7d798d", size = 20768, upload-time = "2025-09-09T00:38:56.863Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/3e/08c8cac81b2b2f7502746e6b9c8e5b0ec6432cd882c605560fc409aaf087/pymsgbox-2.0.1-py3-none-any.whl", hash = "sha256:5de8ec19bca2ca7e6c09d39c817c83f17c75cee80275235f43a9931db699f73b", size = 9994, upload-time = "2025-09-09T00:38:55.672Z" },
]
[[package]]
name = "pyobjc-core"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" },
{ url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" },
{ url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" },
{ url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" },
]
[[package]]
name = "pyobjc-framework-cocoa"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" },
{ url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" },
{ url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" },
{ url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" },
]
[[package]]
name = "pyobjc-framework-quartz"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798, upload-time = "2025-11-14T10:00:01.236Z" },
{ url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206, upload-time = "2025-11-14T10:00:15.623Z" },
{ url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317, upload-time = "2025-11-14T10:00:30.703Z" },
{ url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" },
]
[[package]]
name = "pyperclip"
version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
]
[[package]]
name = "pyrect"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/04/2ba023d5f771b645f7be0c281cdacdcd939fe13d1deb331fc5ed1a6b3a98/PyRect-0.2.0.tar.gz", hash = "sha256:f65155f6df9b929b67caffbd57c0947c5ae5449d3b580d178074bffb47a09b78", size = 17219, upload-time = "2022-03-16T04:45:52.36Z" }
[[package]]
name = "pyscreeze"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/f0/cb456ac4f1a73723d5b866933b7986f02bacea27516629c00f8e7da94c2d/pyscreeze-1.0.1.tar.gz", hash = "sha256:cf1662710f1b46aa5ff229ee23f367da9e20af4a78e6e365bee973cad0ead4be", size = 27826, upload-time = "2024-08-20T23:03:07.291Z" }
[[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 = "python3-xlib"
version = "0.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/c6/2c5999de3bb1533521f1101e8fe56fd9c266732f4d48011c7c69b29d12ae/python3-xlib-0.15.tar.gz", hash = "sha256:dc4245f3ae4aa5949c1d112ee4723901ade37a96721ba9645f2bfa56e5b383f8", size = 132828, upload-time = "2014-05-31T12:28:59.603Z" }
[[package]]
name = "pytweening"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/79/0c/c16bc93ac2755bac0066a8ecbd2a2931a1735a6fffd99a2b9681c7e83e90/pytweening-1.2.0.tar.gz", hash = "sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b", size = 171241, upload-time = "2024-02-20T03:37:56.809Z" }
[[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 = "8.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/ae/ed461cca5780b5fc8b9fe8ca0ed98d89508645fb9d880c24cc42c087678f/redis-8.0.0.tar.gz", hash = "sha256:a00c5355432051ac14e593b8b197fc76c887ee12d55a0984f69328a1115fdc49", size = 5101591, upload-time = "2026-05-28T12:45:13.5Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/e3/b519734372d305bd547534a9f32e4ce9f98552af753dce72cf3483a0ff0b/redis-8.0.0-py3-none-any.whl", hash = "sha256:c938c18338585009f0bc310f4c7e4e4b4d37639356c4ac072cedf3af570c8dc7", size = 499870, upload-time = "2026-05-28T12:45:11.697Z" },
]
[[package]]
name = "rubicon-objc"
version = "0.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/37/fa4718811f5f33cb43fbd9e78209b4105f86022965ae9ceed08ef23560a9/rubicon_objc-0.5.4.tar.gz", hash = "sha256:9ba5eb203df2b2e6c7f2d93f671b583ef5ea0f61b17a3ff44cd9c68673ad7916", size = 188492, upload-time = "2026-05-06T00:57:50.457Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/56/ba4987a64ceaae27f629644c4c52d2291eeab0755486d976c4bd1c781d86/rubicon_objc-0.5.4-py3-none-any.whl", hash = "sha256:f7c755388d5709d7079152892137b13bcc6b4bdd796c9f0d9b3a1eb5bea715c4", size = 64553, upload-time = "2026-05-06T00:57:49.032Z" },
]
[[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 = "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" },
]