From 535d7f4ac5a238f5650727ab5ee9d3435b18513c Mon Sep 17 00:00:00 2001 From: zmq Date: Tue, 26 May 2026 14:16:16 +0800 Subject: [PATCH] first commit --- .env.example | 60 + .gitignore | 42 + README.md | 205 ++++ accounts.yaml | 25 + pyproject.toml | 32 + resources/images/google_pay_continue_btn.png | Bin 0 -> 2078 bytes resources/images/vias_opt.png | Bin 0 -> 6689 bytes src/__init__.py | 1 + src/browser/__init__.py | 1 + src/browser/manager.py | 237 ++++ src/config/__init__.py | 1 + src/config/accounts.py | 74 ++ src/config/settings.py | 109 ++ src/main.py | 89 ++ src/notifications/__init__.py | 4 + src/notifications/base.py | 14 + src/notifications/channels/__init__.py | 3 + src/notifications/channels/email.py | 54 + src/notifications/models.py | 11 + src/notifications/service.py | 131 +++ src/parsers/__init__.py | 1 + src/parsers/surugaya/__init__.py | 18 + src/parsers/surugaya/models.py | 69 ++ src/parsers/surugaya/order_detail.py | 315 ++++++ src/task_queue/__init__.py | 1 + src/task_queue/delay_zset_listener.py | 137 +++ src/task_queue/dispatcher.py | 119 ++ src/task_queue/listener.py | 205 ++++ src/task_queue/redis_manager.py | 59 + src/tasks/__init__.py | 1 + src/tasks/base.py | 325 ++++++ src/tasks/surugaya.py | 1042 ++++++++++++++++++ src/tasks/surugaya_trade_monitor.py | 106 ++ src/utils/__init__.py | 1 + src/utils/app_utils.py | 4 + src/utils/http_utils.py | 38 + src/utils/logger.py | 71 ++ src/utils/page_utils.py | 116 ++ src/utils/video_recorder.py | 136 +++ uv.lock | 714 ++++++++++++ 40 files changed, 4571 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 accounts.yaml create mode 100644 pyproject.toml create mode 100644 resources/images/google_pay_continue_btn.png create mode 100644 resources/images/vias_opt.png create mode 100644 src/__init__.py create mode 100644 src/browser/__init__.py create mode 100644 src/browser/manager.py create mode 100644 src/config/__init__.py create mode 100644 src/config/accounts.py create mode 100644 src/config/settings.py create mode 100644 src/main.py create mode 100644 src/notifications/__init__.py create mode 100644 src/notifications/base.py create mode 100644 src/notifications/channels/__init__.py create mode 100644 src/notifications/channels/email.py create mode 100644 src/notifications/models.py create mode 100644 src/notifications/service.py create mode 100644 src/parsers/__init__.py create mode 100644 src/parsers/surugaya/__init__.py create mode 100644 src/parsers/surugaya/models.py create mode 100644 src/parsers/surugaya/order_detail.py create mode 100644 src/task_queue/__init__.py create mode 100644 src/task_queue/delay_zset_listener.py create mode 100644 src/task_queue/dispatcher.py create mode 100644 src/task_queue/listener.py create mode 100644 src/task_queue/redis_manager.py create mode 100644 src/tasks/__init__.py create mode 100644 src/tasks/base.py create mode 100644 src/tasks/surugaya.py create mode 100644 src/tasks/surugaya_trade_monitor.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/app_utils.py create mode 100644 src/utils/http_utils.py create mode 100644 src/utils/logger.py create mode 100644 src/utils/page_utils.py create mode 100644 src/utils/video_recorder.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e11ca77 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e65f103 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a21294 --- /dev/null +++ b/README.md @@ -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、账号、错误原因、截图路径和录屏路径。 diff --git a/accounts.yaml b/accounts.yaml new file mode 100644 index 0000000..e0f7a89 --- /dev/null +++ b/accounts.yaml @@ -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: [] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..01b6e13 --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/resources/images/google_pay_continue_btn.png b/resources/images/google_pay_continue_btn.png new file mode 100644 index 0000000000000000000000000000000000000000..dce2b705c4eed27ce206218c9744245c82ad3ba0 GIT binary patch literal 2078 zcmb7_`#;p_7r-AXL&znvwx!xuY!jxWDHE+L5-tyzO+LYB628LbEpagn&w0Ji&9i6F>RZ3v z3II@dc5?6lK+y);A6Hg_J?lFmWNePKbF~BDDOvSv=oZLUx$NYR z2VmQa|1O1|*s5>_rpVebM&x!oYf|C?ZsWetri0or!rLolmG)6x(tRz`L*=xMJ zp6P;}{xmglpuKi%R0RcjV?1}b@9XvP=|)eP+G_ytdJE$R#=cQy15(rNA{6AY`>x47 zlZtfROJwp-!@m6F)CE%ja`8j+98z#tmCxE)xQ!L%w>O&rh`W90R^#b!u63i04|%?a z5^2NuI~Ex^g)9zO>Ok$$W`?<-qGm%SszOoUW%M5C=tj5J;wMc~5r(ql5|sEwKCbq| zM`W<~)Tks7OwgpraGPz^Cz*m=xC&I=9O9y73slRNQn|uCN zQab+UkjxN#-5jCE7rY%WSUd#~s0{7qJe&JJ7rYWr9;8K$N2CS&d!Fx6+DEt%^t1a- z8P=^6xhZv2g<;mwO=(6YCL9JNdUbzL6bgsFatg~f-|5!ywY`|+qa_yvX6teeN_iGN z?f#GN8}ZjUp=)hIMyN+H?V`sqHE?LlKlcvwZ5Vu$r z4!PJS6{wzsKUU>Mr!1lF9rQ>(F0 z?zBBq&qk|to*hri-*q+lsQ&%rjp+oQ^l-`wa}P03_afNyPmMMKy9MrO=sQ_CGrM%0 z7aJfSoO;zbV;wtUtMSO{fKJ@uVT+`XBKnTbAa-;c2RWE23f!iQ^Z&f=c0W$R5j!8XKQc)e&QeP3B+4^2GA`ozz6?FkF?sX!G#qN6VD$PS#{&&Kjp*ogrfdyA7?-GYBn{M zcQJODU$(pC+-u*;Hg=6LSJVWy>dl!kYr{&hM~Y$(JZ`v|v=tNT7nVYo+gXBG-KJ*tR=XC!xWW=7 zawI{terN2vIwtcA#+P4Zt@roK6payzuTEPgY~^cN7CsH^_Jksu#tTQL&3UqJ`2^UV z$KtyqeX0GGEvxxZLk6CFO24Ou@O1Az%?o^2Z<;_%dB8^sjv%Ama=ARtRHb!-PP&)2 zbolOFKm6F6bj}Z;kYSNf8ts~0y(IM_{YpMeqGd>_0OL~PXH^sVm67wD;RxSBeAc^h zN6qp}R+jv}{T-IXT?J!j;<~e3)K29lOO^*z*a{)-5CE;$XT2w4E- zTiQS9SIifI6GI5CUmAi#7TvM3=dQ4JeE?42xWtqSc(YXHcokaSg`I^%-wRGGxcjJV zMQP8Z+jE9Fp%4;tqwl!i@qVtXOL}}-j|-u3LJh)9f=Pu*O}f_p^&3`5lOzR@8dx4v zo~7+0`1A>bj6sEyJBO#45Sv(8>Uwv%EM_0w|7e*nX>fVVm+v8BT14zCLpN@-DK{^l zgZRzb9Fbd;ZrDp5vV5?>YzsrvRx~PcLC}CHG@qFmJoPDNbveA)d+~OC$94ictfXar z6em&vplf_^KSa+LgjiRI_r|Wq$8Fc`$uz%Q8XY=}5iXdXyFluT&@J0$tQN*fV<%EM zSAF{)TDBV1HeVgFx`M_c4qgbu%e^6B?=`bEXTNv++|tzEH4}Zlg339CXx8c0v{^_+?5TzaBxVw?mqW=-M(1k z;LtRvD9GygL;oxSf+jaI2oKCKgKF$&vbcCejkpZY@=rcPi5z`gri>CBa&uay8=RaP zeVqIj8(OA)${HA5q@GXDPZN_Vyx}d%#*MsWCuDx)$(YhyyML_4PVm4$SSSO~!S{(Y zV_inb@rvzOm@sxC@dq! zgkZ?q*jZRu{+07MIPl*JA%6RWY@e+F?}2gkUy{pmPDW_XnnV?t2?b%DjFnIl8;g{6 zn@FP?3F}%xJKTCX)c;U50+AjN^U)m*M8VK3a9>tYnlnG~m&jb@nx=&^y+B*QmmQ z9Y;q>*489R-KEhuJ|DnzDM94?nx2U8`h9v8Arf@lWm${1^p(MkOrliy7x=Ua%;4TOGcoqlP zjz#s+|1BncB!Y&TQ_V?q+B=C-dA*QvJA->4Ua%5HCBhXy@4lSeiv268m$Of%@F*Gw ztw|*=J`@aVC2GZZtoE(;epv56HlFLWwU$Cx(0LmE;iA18@1RCide)h~=#>=P%fL?tM&oqF4*jaFLyn)W+S8t9j`p@2mku^~fN zfGgDLR#ct57~u`LuN_RluiP#`@C&>KhI%j1o$^z`iC304lRJ8jp!gRuFt60>M{DDHS5 zzS29rD6OM&sp%j$8Y(Ya@e=>tV`Vg#=s;xb$d8`Q61%*@zI$21w9dGByt}1GtLPHa z&&Zyt#^#BNCP!P|Vzndl#bVMynkF2BVDctdYvd*ZT7iKSrlyM4Tabd)l{EO+a2E zs!=2}KP`SJNP{__$Q1`v)tGadi3$tnw^cwyACC+V3*-W81LY4nfBKfz)ZBBasOSxf zOEN1-IQ6x*vD>L6!mh`K)+N&8{rXz782rNr&}oUiruAWeIFlKZbGty#wl-r`ebM89 zX0hM&1X8YLdi4vuWqNfirl#!geJs~~s{lIpcEA30SV{V-Uju;D#AuZP?DlUe@c9Pa zzNeXfV*iE@;)lq*&}OXl*X3RkINL8j$g2T{1FTHb1;_4(R1*^~xuGYjrmfJ(y~`~5 z142~(K+X~o=`^9w#X+356enG#>yrLo;jBTAr&Z3~*;Lz_?L%c%T!`&C9L3aPE9VU2 z44Ub#=hgx~QQIqOXX#eg>v=0(Cr&lyHBJe-_7f+MMrh61*_!N6c%X@lAa`1tt# zr&}^sTWb;NFSm3a(uhw=a)^C}l4p}n?)BzJ?3>T04N(hPa|#Md65(CVHsw958VRPU zYCmF`E{BG)Q&f8%9|>oxxPRPqok!7lnZadcb~1IjM`txdX05^{-8N!u49@rFsYRT_ zBZaW57P^$+>&$63qb;fIeeNmdQq8atcw(1o0!*|@N=nYP>iPwsX*wMbS_qOCsLaeL zY3$xDJDjhT&Kb^q;)$!`Uj}Kq9+p&{c z6e(0;P*X`V(zf*WB&%(%WJA62bNOVnkjC-Kf`g&aDy{VQynQ>(Q29jeU&7p{7l%0k zkkb6lAe=3`_kQZdq&3t{Y$z_N*ILU`|UL%9leO!6obDvinsMIeJGqgMr&NCnao6uH{rc`u>UR zq%dBf9HfM=TuN{L?7U74`jEkT#vM4~B!E_i^%lked);4-^Ot|mOD&7wHmVMGE8(^2 z54I`VYO?i}c5N=Dmbo$#X!l!Z3Oe~JX99=ws>a$lXpG=eYyNCbsAB#-cw*d%2aOLLJ+mRT_^fN5jU0x{RuW_U2!n@@>b3y_>X>_ z;8WItz{{^?YA@%*!U)OInYLwiskMZi?7z$$=;<2f<-K;@;ZCKbWOu)2m~V6uUfn)e zZ*TO@1899}x*%*^Lq~0{E-oAsq$1_fQ1sT4Ef_Xp0rRAa+`wQZBrZ%mwv>U0=>JO;tXvfB^uSn~;HVTmP}yXP`uc?KQ6y)U|<<#?@(r=A)Ka z%6cC;rkcD&4^{kWZLCOnYXWuAV`<`}!VsChLRsdon_*sWVH_oL1P;)GjYOu+)?2bC zCdHUk0Nw}Fv|5X~98C(1Zk^=kTPErSg})C#++FKqHcgG29eD$;a?fSm zh63pmCI8|uoL{&39~~;yICcxVbW(Z7k!efadu-qQ3%gOV+vqI*Et6wo;yIt5(;U}lb@dh2g#~PPW9ycc^s4*VYu`~u$ z92*;Rg_Frs3s@HFoj8lMF!XNt?p%{g78&_o$D8#wZw52a(9pmnuw{*CYhH+;@Rw>@dDyUZ4U zH0HMCiojo2Le(L7hOBW7=i*MxRYK+xV$@rk3jy3R&NIb<^u&2*aD^WA-P!qQ;f+7y zr=2#P)4u?&*+Rk;0b(MB8E2d$6{3h&pWS*@)ntyn`UVDO;8nAp>q}CB)g88s>RFPM zVKIbRBUWSZ(v5+oi6Y61u**%c#NS90{ztFiaA^^@x%a&c!9;;)d(-Buz5XkH9o>Oo zDrQkWGF}87_$l9a`H=IrW;cC}^XO=4J5fCzvq%W0zVL(8Q)GfqZg^Q%M@U&@FDpWZ zuVd{wMQf!}JHSLPVeIGD6vNB13u3NfbqQ_xnYsG18V9T93*mE|{TGID=&&ez&eVa% zjzD7GjNpt@qDPP4Y$vVt25g#1e(u!oC*aE(ddEc}q?zs(^}63M5-)2Gg@fG+s$CkX z2L81>oF1Nj5#R!U8p=}!A%5?Yx%w|{5E z7p~n&pxMixA=l%VQxcN&jt?#DiZl{mpw(dAywV(mcAs*hoxKJ@$MOtS$9;nvDcSA^ z9*T)Hv1`fXIQLBou~(`_7Xc z9M6Zu9Ub|=vey^&^+d>6>d4OVk`1uzjNeVxdBSKbB6s`iWAkD;;1F+Zy2iG(<02& z-EKFK5h={>|ED~O;7kPM$0sz$;~?&8Imurl?bZP>^-RnA>j&_I(e|Vj?PwJ=?HvV~ zhzaTUzWzR(^zn1OuQuTx+*$^HtQjw|(&*EN%vg#)F&3}3Y+HCb{Y@{)u6I&}!R6aX zf1F;c0?*$c87aZ9<+?!>($*Po(&&thjWlmoVaH2HH4K49B}(!(C_a16I?V6i0)LsGT|_`+z?=isgg7uz+nXKvGwi zl87J-mS&kM*04-PV%OWU&&b?nRMq;Iih??ee`lPV-(It3iC5OS?4l~SGhdck$oE;X znv{ItEOySnjqHJu76`@L<_>Ed4k6bb@I2HXt=f|OCbWHAzhXbdPz#(?rKFG?aY*m;XYqJtbguuz*+0QDMKH@D`-|sO zpZx5}^mTQML{R$r`fFPp3|%3&M^xv_E%4~T{bpjms|_0Z8F!F;WLkcmH?^mim%!we ziK5=i?6EOwY|7S^>(x3}k8JkW2Wy9WXzX&PDA<8M zD3n5mbr2yF6N6{k4zem!sc|1kpmK64I$vXs^3jT!J2NhO-{v6Wc9a#qDpr>W+l{S^ zXmXTwz?;nFA~NtlDyfg3Al10ooPX@>R*J}$ z3B{2$`u@(YR;TzD{x?O%zs!lq{1ST6vs`2c?!Qc3tr8{-lN!hlE;{pEpLZLQ4-BL$ zR?kYv&R*D4dZs?zOH3Sla2?V{((x}h(0yKG|Jx4=_O+>IKFhHd;?e2pNQBuraT*Vd ziVErtfZs57bdJE`B?P(N`gI=H%Ig}LZ|U0qkzlv#lctR*nI4A{jjfAVkJihC*D#dh z+004?$L7j~UGDd$6!jiNWwZLSUtD%P7iII(Ky*nue};{my-hcizFp}qUve|=8lmzK zA7Bgtr4?qEyDr4A-K-?@BPrB!eD=3Lkwe27KC0THg=%1?zF-QE8()yGGuxm@19D>sanjQd^*H-R@ejje%QWkF|5tV za(MWMthRPaWVt+suXyle4jE98vyp+wb;A|rn51}Igf#dqZqULQrb>{tudDw|#GD zx<}voJXN6CG{H`^_r37ZK&tb#ww+wIWcwK2*IC8IPoGT1Asq|hHAfXt5*TtSX@T4L zfeNRyYkUIoH`p%SJSErcGvq|;O>*%G>koVU3!miYV>wbS-lNzGRWe!>8uGfNBML$^ z;qj2Y`o~~7Chcjf*uHSvZ7Sj>#%7;y%F%$O?+bB8FHqicLw`Gd1}Y%Cn+W@fvPp^T zisj8I%DwnmlKX8!V-TIKbo;&}$KNDtUqj3XnJ~u0l0V71Tt;B@o1Cl0X7sQy>sy^? zu!Qg4$%)Q~Uo2?lQr!l z!HF}LV@FR()R7$paqkUKUrF3LX7cY1Fn%Pd&!en!vfbEO$DW}CEO@_MD%v*jz=~~r ziy^(*{(|Lw0;}pGXK4rQ500Iwg*qehx)XBFfYL*0vTZ82IzGBZ0qyi=O-He`pJ8sq zY?q+^{Ro}vLK@Qs%m>9Q>>*&QK>~?b#ittTEdPmM?G0}jU+o$jUnuW_Y`}AyZ`Z)Q z5nWeHYT4coRKZa@PA8NB^fbl-Hrw76vnt&~n`g|68I|D3aRbJdIQo#}fZHN!F~^TL zh99`D1AX`9zW)cAFGf5Pq#)woQ8a)2< zr?%wzNuuf-$9FSy#98jQxlTjD%uS{awA+Q9KreKmDsbi~i{C%#zS)JGwCzYAdYzA< za|aUusbDtE%oZ28Uu*lz5eR{5C|3q!reKTi#tF=q!b|5G_^9hQ`!0#nL+n3;WoZwA z-jq+CZj-NkD4@4IBNLJRV*o~6Lxy1=ElLHK!R!|$juXJgk6-KVE=K4MEYIYcy*x&S z>tkb5@m*%DVeIoP&`El5(Z1gNRxnchd} z6c@UbRti{?S5m4g(TWF~q7=SlS@?zf$hGS<7s7lebR$sug$sTwny=`Ny#!sT_0tlF zr4jMq;h~=LV`)_1sbYyn-)JqjP5Re|uBnj5u;2yrTL+cT$pKJg#poEup?gnbdKAdU zPs#`_p5n!_$^1#td}mN`$exLb@5Yo=SRLWJdv6Yt4{F^5h#U6Ot|<$&SHUsQn!FWB z6-hMqGrEC74H!O`X_sJYSS!!{{#inR{hoJOLbyR&9zB*2iO1>UVk_hyZ>REc=@dyJ zz3GC8gyN*f6#F5gF;g3Vq^^xTF~6F%h3c7Q8dBd3uBDFk_GcgjC%{lYOtV;@kgPy) z$8#u_*LgSj>$Vao9E=M+qY_NB@?88P56b}%J@qcWQ;L^|I#Zq7?*P_m^HkWp(R zw5mqCyiqRi@D(u%l(HIc1SSp$GD9NsRW*})9{_?X=rIBo$H|x|ondQGM60hK8JrAY zheBBM&qcA@X+O-Hv-51mkA|8EsbHtHk&QAo+9K02`#G%IWRT63}C0uYN%QQ9~TRq8n zvKn&@@hMz$*)%@1tbK_cR#CFQBBF_qFBTZTdWWR7BBxli2~Zustvtw} zB2HIJl31fRX0?Xv`XJR;2dZHaKmIdQkc?FX0HAt?uDdsP$ez67S5p}rTTbEXGd&5F zd89adB{^<`o33JRdK7XOQ*NjZ(mJjMnw11(tt{Lq?Pvh)o~=-_%5pzRFhy~jlXs%_ zvCNz09~+y2N(8vaP=yZ>UZDUQWz-nQ`wtw4@~#fuQFjH7|369of50-)%I&@AjyJ_j U=x#&o-#R!duhkVQ<;>sxFY3V2xBvhE literal 0 HcmV?d00001 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..2de27c6 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +# 根包初始化 diff --git a/src/browser/__init__.py b/src/browser/__init__.py new file mode 100644 index 0000000..80dbdd5 --- /dev/null +++ b/src/browser/__init__.py @@ -0,0 +1 @@ +# 浏览器模块包初始化 diff --git a/src/browser/manager.py b/src/browser/manager.py new file mode 100644 index 0000000..4602ad7 --- /dev/null +++ b/src/browser/manager.py @@ -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 diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..84b34f4 --- /dev/null +++ b/src/config/__init__.py @@ -0,0 +1 @@ +# 配置模块包初始化 diff --git a/src/config/accounts.py b/src/config/accounts.py new file mode 100644 index 0000000..953d5e6 --- /dev/null +++ b/src/config/accounts.py @@ -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 diff --git a/src/config/settings.py b/src/config/settings.py new file mode 100644 index 0000000..74d144f --- /dev/null +++ b/src/config/settings.py @@ -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() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..319db1f --- /dev/null +++ b/src/main.py @@ -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) diff --git a/src/notifications/__init__.py b/src/notifications/__init__.py new file mode 100644 index 0000000..83d522e --- /dev/null +++ b/src/notifications/__init__.py @@ -0,0 +1,4 @@ +from src.notifications.models import NotificationEvent +from src.notifications.service import NotificationService + +__all__ = ["NotificationEvent", "NotificationService"] diff --git a/src/notifications/base.py b/src/notifications/base.py new file mode 100644 index 0000000..6ec4eef --- /dev/null +++ b/src/notifications/base.py @@ -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 diff --git a/src/notifications/channels/__init__.py b/src/notifications/channels/__init__.py new file mode 100644 index 0000000..2777abf --- /dev/null +++ b/src/notifications/channels/__init__.py @@ -0,0 +1,3 @@ +from src.notifications.channels.email import EmailNotificationChannel + +__all__ = ["EmailNotificationChannel"] diff --git a/src/notifications/channels/email.py b/src/notifications/channels/email.py new file mode 100644 index 0000000..7646d93 --- /dev/null +++ b/src/notifications/channels/email.py @@ -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) diff --git a/src/notifications/models.py b/src/notifications/models.py new file mode 100644 index 0000000..510ebe9 --- /dev/null +++ b/src/notifications/models.py @@ -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) diff --git a/src/notifications/service.py b/src/notifications/service.py new file mode 100644 index 0000000..befbb87 --- /dev/null +++ b/src/notifications/service.py @@ -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) diff --git a/src/parsers/__init__.py b/src/parsers/__init__.py new file mode 100644 index 0000000..8c3ff2b --- /dev/null +++ b/src/parsers/__init__.py @@ -0,0 +1 @@ +"""页面解析器包。""" diff --git a/src/parsers/surugaya/__init__.py b/src/parsers/surugaya/__init__.py new file mode 100644 index 0000000..8453c00 --- /dev/null +++ b/src/parsers/surugaya/__init__.py @@ -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", +] diff --git a/src/parsers/surugaya/models.py b/src/parsers/surugaya/models.py new file mode 100644 index 0000000..2a14105 --- /dev/null +++ b/src/parsers/surugaya/models.py @@ -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) diff --git a/src/parsers/surugaya/order_detail.py b/src/parsers/surugaya/order_detail.py new file mode 100644 index 0000000..f054b7d --- /dev/null +++ b/src/parsers/surugaya/order_detail.py @@ -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") diff --git a/src/task_queue/__init__.py b/src/task_queue/__init__.py new file mode 100644 index 0000000..d3fb2f6 --- /dev/null +++ b/src/task_queue/__init__.py @@ -0,0 +1 @@ +# 消息队列模块包初始化 diff --git a/src/task_queue/delay_zset_listener.py b/src/task_queue/delay_zset_listener.py new file mode 100644 index 0000000..c5c1635 --- /dev/null +++ b/src/task_queue/delay_zset_listener.py @@ -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 diff --git a/src/task_queue/dispatcher.py b/src/task_queue/dispatcher.py new file mode 100644 index 0000000..45b3461 --- /dev/null +++ b/src/task_queue/dispatcher.py @@ -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}' 的锁。") diff --git a/src/task_queue/listener.py b/src/task_queue/listener.py new file mode 100644 index 0000000..56204db --- /dev/null +++ b/src/task_queue/listener.py @@ -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 diff --git a/src/task_queue/redis_manager.py b/src/task_queue/redis_manager.py new file mode 100644 index 0000000..fea49e7 --- /dev/null +++ b/src/task_queue/redis_manager.py @@ -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() diff --git a/src/tasks/__init__.py b/src/tasks/__init__.py new file mode 100644 index 0000000..b48068e --- /dev/null +++ b/src/tasks/__init__.py @@ -0,0 +1 @@ +# 任务模块包初始化 diff --git a/src/tasks/base.py b/src/tasks/base.py new file mode 100644 index 0000000..e379cf4 --- /dev/null +++ b/src/tasks/base.py @@ -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 diff --git a/src/tasks/surugaya.py b/src/tasks/surugaya.py new file mode 100644 index 0000000..5f42f7c --- /dev/null +++ b/src/tasks/surugaya.py @@ -0,0 +1,1042 @@ +import asyncio +import json +from pathlib import Path +import random +import re +from typing import TypedDict, Any + +import time +from datetime import datetime +import cv2 +import numpy as np +import pyautogui +from loguru import logger +from playwright.async_api import Page +from playwright.async_api import TimeoutError as PlaywrightTimeoutError + +from notifications import NotificationEvent +from parsers.surugaya import SurugayaOrderDetail +from src.tasks.base import BasePurchaseTask +from src.config.accounts import get_account_config +from src.utils.page_utils import format_japanese_price, human_scroll +from src.parsers.surugaya.order_detail import SurugayaOrderDetailParser +from task_queue.redis_manager import get_redis_client +from utils.app_utils import is_valid_str + + +class TemplateMatchResult(TypedDict): + center_x: int + center_y: int + top_left: tuple[int, int] + bottom_right: tuple[int, int] + match_score: float + + +class SurugayaPurchaseTask(BasePurchaseTask): + """ + 骏河屋(Surugaya)站点自动采购任务实现。 + """ + + async def execute(self) -> dict[str, Any]: + self.report_event = "surugaya_purchase" + self.is_report = True + try: + logger.debug(f"[任务 {self.task_id}] 骏河屋采购工作流已启动。") + + if not self.page: + raise ValueError("页面对象未初始化,无法执行采购任务。") + + # 1. 从消息 payload 解析商品列表 + payload = self.message.get("payload", {}) + item_list = payload.get("item_list", []) + if not item_list: + raise Exception(f"[任务 {self.task_id}] 消息中未提供商品列表 (item_list),结束任务。") + + page = self.page + + logger.info(f"[任务 {self.task_id}] 购买任务开始,商品:{item_list}") + # 打开购物车 + await page.goto("https://www.suruga-ya.jp/cargo/detail", wait_until="domcontentloaded", timeout=30000) + + # 检查登录状态 + logger.debug(f"[任务 {self.task_id}] 正在校验账号 '{self.account_id}' 的登录状态...") + is_login = await self.check_login_status(page) + if not is_login: + logger.warning(f"[任务 {self.task_id}] 账号 '{self.account_id}' 未登录,执行登录流程。") + await self.surugaya_login(page) + is_login = await self.check_login_status(page) + if not is_login: + raise RuntimeError(f"账号 '{self.account_id}' 登录失败,无法继续执行采购流程。") + + # 重新进入购物车 + await page.goto("https://www.suruga-ya.jp/cargo/detail", wait_until="domcontentloaded", timeout=30000) + + # 判断购物车是否有商品 + cart_goods_num = await page.locator("table.item_box tbody tr.item").count() + # logger.debug(f"[任务 {self.task_id}] 购物车商品数量:{cart_goods_num}") + + # 清空购物车 + await asyncio.sleep(random.uniform(1, 3)) # 加入随机延迟,模拟人工操作间隔 + if cart_goods_num > 0: + await self.clear_cart(page) + + # 将待采购的商品加入购物车 + for index, item in enumerate(item_list): + product_id = item.get("id") + sku_id = item.get("sku_id") + tenpo_cd = item.get("tenpo_cd") + price_limit = int(item.get("price_limit") or 0) + if not product_id: + logger.warning(f"[任务 {self.task_id}] 索引 {index} 的商品未提供 id,跳过。") + continue + + number = int(item.get("number") or 1) + if number <= 0: + logger.warning(f"[任务 {self.task_id}] 索引 {index} 的商品数量为 0,跳过。") + continue + + detail_url = f"https://www.suruga-ya.jp/product/detail/{product_id}" + if tenpo_cd: + detail_url += f"?tenpo_cd={tenpo_cd}" + + logger.debug( + f"[任务 {self.task_id}] 进入商品 {product_id} 详情页,购买数量: {number}, URL: {detail_url}") + await page.goto(detail_url, wait_until="domcontentloaded", timeout=60_000) + + # 判断是否多规格商品 + price_group_dom = page.locator("#item_title ~ .price_group.mb-2").first + sku_items = price_group_dom.locator(".item-price") + sku_count = await sku_items.count() + if sku_count > 1: + if not sku_id: + raise Exception(f"[任务 {self.task_id}] 商品 {product_id} 是多规格商品,未提供 sku_id") + + logger.debug(f"[任务 {self.task_id}] 商品 {product_id} 是多规格商品,sku_id: {sku_id}") + + try: + sku_index = int(sku_id) - 1 + except (TypeError, ValueError) as e: + raise Exception( + f"[任务 {self.task_id}] 商品 {product_id} 是多规格商品,提供的 sku_id {sku_id} 不是有效数字" + ) from e + + if sku_index < 0 or sku_index >= sku_count: + raise Exception( + f"[任务 {self.task_id}] 商品 {product_id} 是多规格商品,提供 sku_id {sku_id} 不在多规格列表中, sku_index: {sku_index}") + + # 点击多规格商品 + if sku_index > 0: + sku_dom = sku_items.nth(sku_index) + await sku_dom.wait_for(state="visible", timeout=5000) + await sku_dom.click() + logger.info( + f"[任务 {self.task_id}] 商品 {product_id} 多规格商品切换规格,sku_id: {sku_id}, sku_index: {sku_index}") + # 规格切换通常是局部更新,networkidle 容易被长连接拖住,这里改为短暂等待页面完成联动刷新。 + await page.wait_for_timeout(random.randint(500, 1000)) + + # 判断是否缺货 + out_of_stock_dom = page.locator("div.out-of-stock-text") + is_out_of_stock = await out_of_stock_dom.is_visible() + if is_out_of_stock: + logger.warning(f"[任务 {self.task_id}] 商品 {product_id} 缺货,跳过") + await self.take_screenshot(scene=f"缺货_{product_id}", full_page=True) + continue + + # 判断是否为官方自营商品 + # shop_container = page.locator("div.mb-2:has-text('この商品は、'):has-text('が販売、発送します。')") + # is_not_official = await shop_container.is_visible() + # if is_not_official: + # logger.warning(f"[任务 {self.task_id}] 商品 {product_id} 不是官方自营商品,跳过") + # continue + + # 提取商品最大购买数量 + quantity_select = page.locator("#quantity_selection") + await quantity_select.wait_for(state="visible", timeout=5000) + last_option_locator = quantity_select.locator("option").last + buy_number_limit = await last_option_locator.get_attribute("value") or 0 + logger.debug(f"[任务 {self.task_id}] 商品 {product_id} 最大购买数量: {buy_number_limit}") + if number > int(buy_number_limit): + number = int(buy_number_limit) + + if price_limit > 0: + logger.debug(f"[任务 {self.task_id}] 商品 {product_id} 有价格限制检查商品价格:{price_limit}") + # 提取商品的价格 + price_dom = page.locator("#cart .price_group .text-price-detail") + price_txt = await price_dom.text_content() # 提取内容:600円 (税込) + price = format_japanese_price(price_txt) + logger.debug(f"[任务 {self.task_id}] 商品 {product_id} 价格: {price}") + + if price > price_limit: + logger.warning( + f"[任务 {self.task_id}] 商品 {product_id} 价格 {price} 超过价格限制 {price_limit},跳过") + continue + + try: + btn_buy = page.locator("button.btn_buy").first + await btn_buy.wait_for(state="visible", timeout=5000) + + if await btn_buy.is_disabled(): + logger.warning(f"[任务 {self.task_id}] 商品 {product_id} 已售罄或无法购买(按钮被禁用),跳过") + continue + + # 根据 number 循环点击加入购物车 + for i in range(number): + await btn_buy.click() + if i < number - 1: + # 多次点击之间加入随机延迟,防止被识别为机器人或触发频率限制 + await asyncio.sleep(random.uniform(0.5, 1.5)) + except PlaywrightTimeoutError: + logger.warning(f"[任务 {self.task_id}] 商品 {product_id} 找不到购买按钮(可能是已下架/缺货),跳过") + continue + except Exception as e: + logger.error(f"[任务 {self.task_id}] 商品 {product_id} 加入购物车失败: {e}") + continue + + # 再次进入购物车,确认商品是否加入成功 + await asyncio.sleep(random.uniform(1, 3)) + await page.goto("https://www.suruga-ya.jp/cargo/detail", wait_until="domcontentloaded", timeout=30000) + cart_goods_num = await page.locator("table.item_box tbody tr.item").count() + logger.debug(f"[任务 {self.task_id}] 再次进入购物车页面,购物车商品数量:{cart_goods_num}") + if cart_goods_num < 1: + logger.warning( + f"[任务 {self.task_id}] 购物车商品数量异常,当前数量为 {cart_goods_num}") + raise RuntimeError(f"购物车商品数量异常") + + # 检查一下页面中是否存在"PayPay注文処理中です。" + is_failed = await self._check_and_wait_for_paypay_processing(page, max_retries=3, check_interval_sec=30) + if not is_failed: + logger.error(f"[任务 {self.task_id}] 无法规避 PayPay 占用状态,放弃当前业务流程") + raise RuntimeError("规避占用状态失败,放弃当前业务流程") + + # 点击"前往订单页面" + try: + btn_order = page.get_by_role("link", name="注文画面に進む").first + await btn_order.wait_for(state="visible", timeout=5000) + await btn_order.click() + logger.debug(f"[任务 {self.task_id}] 进入订单结算页面") + except PlaywrightTimeoutError: + logger.error(f"[任务 {self.task_id}] 未找到'注文画面に進む'按钮") + raise RuntimeError("未找到前往订单页面的按钮") + + # 处理支付方式 + allowed_payment_methods = ["googlepay", "paypay"] + current_payment = await self.ensure_payment_method(page, allowed_payment_methods) + logger.debug(f"[任务 {self.task_id}] 最终使用的支付方式是: {current_payment}") + if current_payment == "paypay": + await self.do_pay_of_paypay(page) + elif current_payment == "googlepay": + await self.do_pay_of_google(page) + else: + raise ValueError(f"未知的支付方式: {current_payment}") + + # 判断下单结果 + logger.debug(f"[任务 {self.task_id}] 开始判断下单结果...") + trade_code = "" + try: + await page.wait_for_timeout(1000) + torihiki_num = page.locator(".torihiki_num").first + await torihiki_num.wait_for(state="visible", timeout=10000) + trade_code = await torihiki_num.locator("span").first.inner_text() + except PlaywrightTimeoutError as e: + logger.error(f"[任务 {self.task_id}] 解析支付结果失败") + raise RuntimeError("支付完成后未解析到交易编号") from e + + logger.debug(f"[任务 {self.task_id}] 解析交易编号:{trade_code}") + # 解析交易详情页 (含3次重试机制) + order_detail_model: SurugayaOrderDetail = None + if not is_valid_str(trade_code): + raise ValueError("交易编号为空") + + max_retries = 3 + for attempt in range(max_retries): + try: + logger.debug(f"[任务 {self.task_id}] 正在解析交易详情页,trade_code: {trade_code}") + order_detail_model = await self.parse_trx_content(page, trade_code) + break + except Exception as e: + if attempt < max_retries - 1: + logger.warning(f"[任务 {self.task_id}] 第 {attempt + 1} 次获取交易详情失败: {e},1秒后重试...") + await asyncio.sleep(1) + else: + logger.error(f"[任务 {self.task_id}] 获取交易详情失败,已达到最大重试次数 {max_retries}。") + raise RuntimeError("获取交易详情失败") from e + + order_detail = order_detail_model.model_dump(mode="json") if order_detail_model else None + + # 添加订单监控任务 + if is_valid_str(trade_code): + logger.debug(f"[任务 {self.task_id}] 正在添加订单监控任务,trade_code: {trade_code}") + redis_client = get_redis_client() + try: + hash_key = self.get_hash_key() + delay_zset_key = self.get_delay_zset_key() + next_score = int(time.time()) + self.get_delay_interval_seconds() + requeue_member = json.dumps({"trade_code": trade_code}, ensure_ascii=False) + await redis_client.zadd(delay_zset_key, {requeue_member: next_score}) + + if order_detail is not None: + logger.debug(f"[任务 {self.task_id}] 缓存订单详情数据,trade_code: {trade_code}") + await redis_client.hset( + hash_key, + trade_code, + json.dumps(order_detail, ensure_ascii=False), + ) + except Exception as e: + logger.error(f"[任务 {self.task_id}] 添加订单监控任务失败,错误信息:{e}") + raise e + + logger.success(f"[任务 {self.task_id}] 骏河屋采购工作流执行成功,交易编号:{trade_code}") + return { + "trade_code": trade_code, + "order_detail": order_detail, + } + except Exception as e: + logger.error(f"[任务 {self.task_id}] 骏河屋采购工作流执行失败,错误信息:{e}") + raise e + finally: + keep_browser_open = self.settings.browser_keep_open and self.settings.browser_headless is False + if keep_browser_open: + input("保持浏览器打开状态并阻塞进程,按任意键继续...") + + async def parse_trx_content(self, page: Page, trade_code: str) -> SurugayaOrderDetail: + """ + 解析交易详情页内容,提取交易详情。 + """ + 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 = await SurugayaOrderDetailParser.parse_page(page) + logger.debug( + f"[任务 {self.task_id}] 交易详情页解析成功: {order_detail.model_dump_json(ensure_ascii=False)}") + return order_detail + + async def check_login_status(self, page: Page) -> bool: + """ + 通过限定页头区域来精准判断登录状态 + """ + try: + text_login = page.locator(".top_nav .text-login").first + if await text_login.is_visible(): + logger.debug(f"[任务 {self.task_id}] 检测结果:[未登录]") + return False + else: + # logger.debug(f"[任务 {self.task_id}] 检测结果:[已登录] 用户名: {await text_login.inner_text()}") + return True + + except PlaywrightTimeoutError as e: + logger.warning(f"[任务 {self.task_id}] 检测失败: {e}") + return False + + async def surugaya_login(self, page: Page) -> None: + """ + 骏河屋 (Surugaya) 专用安全登录方法 + :param page: Playwright 的 Page 对象 + """ + account_config = get_account_config("surugaya", self.account_id) + if not account_config: + raise ValueError(f"未找到骏河屋账号配置: {self.account_id}") + if not account_config.password: + raise ValueError(f"账号 '{self.account_id}' 缺少密码配置,无法执行登录。") + + email = account_config.username + password = account_config.password + + logger.info(f"[任务 {self.task_id}] 正在初始化骏河屋登录流程...") + await page.goto("https://www.suruga-ya.jp/pcmypage", wait_until="domcontentloaded", timeout=30000) + + # 1. 确保表单和输入框在画面上完全可见 + # 骏河屋的账号框 id 是 'edit-mail' + email_input = page.locator("#edit-mail") + await email_input.wait_for(state="visible", timeout=10000) + + # 2. 密码框定位(id 是 'edit-password') + password_input = page.locator("#edit-password") + await password_input.wait_for(state="visible", timeout=10000) + + # 3. 登录提交按钮定位(id 是 'edit-submit',值是 'サインイン') + submit_button = page.locator("#edit-submit") + await submit_button.wait_for(state="visible", timeout=10000) + + # 4. 兼容当前任务流:使用异步 API 输入凭据,不阻塞其他 topic 的任务 + logger.debug(f"[任务 {self.task_id}] 正在输入骏河屋账号...") + await email_input.focus() + await email_input.clear() + await email_input.press_sequentially(email, delay=100) + await asyncio.sleep(0.4) + + # 5. 【高拟真输入】模拟真人输入密码 + logger.debug(f"[任务 {self.task_id}] 正在输入密码...") + await password_input.focus() + await password_input.clear() + await password_input.press_sequentially(password, delay=100) + await asyncio.sleep(0.6) + + # 6. 点击登录按钮("サインイン") + logger.debug(f"[任务 {self.task_id}] 正在点击登录按钮...") + await submit_button.click() + + # 7. 【防风控核心】等待登录结果跳转 + # 骏河屋成功登录后通常会跳转到 mypage (个人中心) + logger.info(f"[任务 {self.task_id}] 正在等待登录结果...") + try: + # 等待 URL 变成包含 pcmypage 的地址(代表成功进入个人中心) + await page.wait_for_url("**/pcmypage*", timeout=15000) + logger.info(f"[任务 {self.task_id}] 骏河屋登录成功。") + except Exception as e: + logger.error(f"[任务 {self.task_id}] 骏河屋登录超时或失败,可能触发了验证码或账号凭据异常。") + await self.take_screenshot(scene="surugaya_login_failed") + raise e + + async def clear_cart(self, page: Page) -> None: + """ + 清空购物车 + """ + logger.debug(f"[任务 {self.task_id}] 正在清空购物车...") + cart_goods_num = await page.locator("table.item_box tbody tr.item").count() + if cart_goods_num < 1: + return + + is_delete = 0 + # 购物车商品数量超过3个后会出现"全削除"按钮 + if cart_goods_num > 3: + try: + delete_button = page.locator("#total_box .delbtn-all").get_by_role("button", name="全削除") + # 3. 显式等待按钮在页面上可见(容错处理:比如给它最多 3000 毫秒的加载时间) + # 如果按钮是一开始就在静态 HTML 里的,这一步也可以省略,直接用下面的 is_visible() + await delete_button.wait_for(state="visible", timeout=3000) + if await delete_button.is_visible(): + logger.debug("检测到‘全削除’按钮存在,正在执行点击...") + await delete_button.click() + is_delete = 1 + except Exception: + logger.debug("未检测到‘全削除’按钮。") + + if is_delete == 1: + return + + # 遍历删除 + await page.wait_for_timeout(random.randint(1000, 3000)) + item_selector = "table.item_box tbody tr.item" + remove_selector = "table.item_box a.remove_product" + + while True: + cart_goods_num = await page.locator(item_selector).count() + if cart_goods_num == 0: + break + + remove_link = page.locator(remove_selector).first + href = await remove_link.get_attribute("href") + # logger.debug(f"[任务 {self.task_id}] 删除购物车商品:{href or '(no href)'},当前购物车数量:{cart_goods_num}") + + try: + await remove_link.click() + # 删除按钮不会触发弹窗,直接等待购物车条目数减少即可。 + await page.wait_for_function( + """({selector, expectedCount}) => { + return document.querySelectorAll(selector).length < expectedCount; + }""", + arg={"selector": item_selector, "expectedCount": cart_goods_num}, + timeout=15_000, + ) + await page.wait_for_timeout(random.randint(800, 1500)) + except PlaywrightTimeoutError: + latest_count = await page.locator(item_selector).count() + if latest_count < cart_goods_num: + logger.debug(f"[任务 {self.task_id}] 购物车商品已删除,当前购物车数量:{latest_count}") + continue + + logger.warning(f"[任务 {self.task_id}] 删除商品后数量未变化,等待后重试") + await page.wait_for_timeout(1000) + + cart_goods_num = await page.locator(item_selector).count() + # logger.debug(f"[任务 {self.task_id}] 购物车已清空。") + + async def get_show_payment_methods(self, page: Page) -> list: + """ + 获取所有显示的支付方式 + 先等待支付区域和 radio 渲染完成,再在浏览器上下文中一次性提取所有数据。 + """ + await page.locator("#payment").wait_for(state="visible", timeout=10000) + await page.wait_for_function( + """() => { + const payment = document.querySelector("#payment"); + return !!payment && payment.querySelectorAll("input[type='radio'][name='payment']").length > 0; + }""", + timeout=10000, + ) + + return await page.locator( + "#payment input[type='radio'][name='payment']" + ).evaluate_all( + """ + radios => radios.map(radio => { + let label = ""; + if (radio.id) { + const labelByFor = document.querySelector(`label[for="${radio.id}"]`); + if (labelByFor) { + label = labelByFor.innerText.trim(); + } + } + + if (!label) { + const siblingLabel = radio.parentElement?.querySelector("label"); + if (siblingLabel) { + label = siblingLabel.innerText.trim(); + } + } + + return { + name: label, + key: radio.value || "", + is_selected: radio.checked ? 1 : 0 + }; + }).filter(item => item.key) + """ + ) + + async def get_selected_payment_method(self, page: Page) -> str: + """ + 获取当前被选中的支付方式 value。 + """ + selected_locator = page.locator("#payment input[type='radio']:checked") + selected_payment = (await selected_locator.get_attribute("value") or "").strip() + if not selected_payment: + raise RuntimeError(f"[任务 {self.task_id}] 未获取到当前选中的支付方式") + return selected_payment + + async def ensure_payment_method(self, page: Page, allowed_payment_methods: list[str]) -> str: + """ + 根据系统支持的支付方式优先级,确认并返回最终使用的支付方式。 + """ + payment_methods = await self.get_show_payment_methods(page) + logger.debug(f"[任务 {self.task_id}] 页面可用支付方式: {json.dumps(payment_methods, ensure_ascii=False)}") + if not payment_methods: + raise RuntimeError(f"[任务 {self.task_id}] 页面上未找到任何支付方式") + + current_payment = next( + (method["key"] for method in payment_methods if method["is_selected"] == 1), + "", + ) + logger.debug(f"[任务 {self.task_id}] 当前默认选中的支付方式是: {current_payment}") + # 优先使用 googlepay + if current_payment == "googlepay": + return current_payment + + available_payment_keys = {method["key"] for method in payment_methods} + filtered_payment_methods = [ + payment_method + for payment_method in allowed_payment_methods + if payment_method in available_payment_keys + ] + logger.debug(f"[任务 {self.task_id}] 系统支持且页面可用的支付方式: {filtered_payment_methods}") + if not filtered_payment_methods: + raise RuntimeError( + f"[任务 {self.task_id}] 页面可用支付方式均不在系统支持范围内: {allowed_payment_methods}" + ) + + last_error: Exception | None = None + for payment_method in filtered_payment_methods: + try: + logger.debug(f"[任务 {self.task_id}] 尝试切换支付方式到: {payment_method}") + selected_payment = await self.select_payment_method(page, payment_method) + if selected_payment in allowed_payment_methods: + return selected_payment + except RuntimeError as e: + last_error = e + logger.warning(f"[任务 {self.task_id}] 切换支付方式 {payment_method} 失败: {e}") + + if last_error is not None: + raise RuntimeError(f"[任务 {self.task_id}] 支付方式切换失败") from last_error + raise RuntimeError(f"[任务 {self.task_id}] 未找到可切换的支付方式") + + async def select_payment_method(self, page: Page, payment_method: str) -> str: + """ + 选择指定的支付方式 + """ + payment_radio = page.locator(f"#payment input[type='radio'][name='payment'][value='{payment_method}']") + + try: + await payment_radio.wait_for(state="attached", timeout=5000) + if not await payment_radio.is_checked(): + # 点击支付方式标签,确保选中 + await payment_radio.locator("~label").click() + # 给一点时间让页面的 JS 响应选中事件 + await page.wait_for_timeout(500) + except PlaywrightTimeoutError as e: + raise RuntimeError(f"[任务 {self.task_id}] 未找到支付方式 {payment_method}") from e + + if not await payment_radio.is_checked(): + raise RuntimeError(f"[任务 {self.task_id}] 支付方式 {payment_method} 选择失败") + + selected_payment = await self.get_selected_payment_method(page) + logger.info( + f"[任务 {self.task_id}] 已选择支付方式 {payment_method},当前选中的支付方式是: {selected_payment}" + ) + return selected_payment + + def _save_debug_image(self, file_path: Path, image: np.ndarray) -> None: + """ + 使用 `cv2.imencode(...).tofile(...)` 保存图片,兼容 Windows 中文路径。 + """ + file_path.parent.mkdir(parents=True, exist_ok=True) + suffix = file_path.suffix or ".png" + success, encoded = cv2.imencode(suffix, image) + if not success: + raise RuntimeError(f"[任务 {self.task_id}] 图片编码失败: {file_path}") + encoded.tofile(str(file_path)) + if not file_path.exists(): + raise RuntimeError(f"[任务 {self.task_id}] 图片保存失败: {file_path}") + + def _locate_template_on_screen( + self, + template_relative_path: Path, + threshold: float = 0.85, + ) -> TemplateMatchResult: + """ + 在当前物理屏幕中查找模板图,识别失败时抛出异常,成功时返回坐标信息。 + 兼容带有中文的文件路径。 + """ + logger.debug(f"[任务 {self.task_id}] 开始执行系统级截图,匹配谷歌支付弹窗的“继续”按钮") + + is_dev = self.settings.app_env.lower() == "dev" + + screen_image = pyautogui.screenshot() + large_img = cv2.cvtColor(np.array(screen_image), cv2.COLOR_RGB2BGR) + + # 在 dev 环境下保存原始系统截图以供调试 + project_root = Path(__file__).resolve().parents[2] + # if is_dev: + # raw_screen_file = project_root / "tmp" / "debug" / f"{self.task_id}_raw_screen_debug.png" + # self._save_debug_image(raw_screen_file, large_img) + # logger.debug(f"[任务 {self.task_id}] (Dev环境) 支付弹窗原始屏幕截图已保存至: {raw_screen_file}") + + template_path = project_root / template_relative_path + logger.debug(f"[任务 {self.task_id}] 模板图片路径: {template_path}") + if not template_path.exists(): + raise RuntimeError(f"[任务 {self.task_id}] 未找到模板图片: {template_path}") + + # 使用 numpy 和 cv2.imdecode 绕过 cv2.imread 对中文路径的限制 + template = cv2.imdecode(np.fromfile(str(template_path), dtype=np.uint8), cv2.IMREAD_COLOR) + if template is None: + raise RuntimeError(f"[任务 {self.task_id}] 模板图片读取失败(可能损坏): {template_path}") + + height, width = template.shape[:2] + + logger.debug(f"[任务 {self.task_id}] 正在通过图像算法搜寻按钮位置...") + result = cv2.matchTemplate(large_img, template, cv2.TM_CCOEFF_NORMED) + _, max_val, _, max_loc = cv2.minMaxLoc(result) + if max_val < threshold: + logger.warning( + f"[任务 {self.task_id}] 在当前屏幕中未找到目标按钮,最高相似度: {max_val:.2f}" + ) + raise RuntimeError("未识别到 Google Pay 确认按钮") + + top_left = max_loc + bottom_right = (top_left[0] + width, top_left[1] + height) + center_x = top_left[0] + width // 2 + center_y = top_left[1] + height // 2 + + cv2.rectangle(large_img, top_left, bottom_right, (0, 0, 255), 3) + cv2.putText( + large_img, + f"Match: {max_val:.2f}", + (top_left[0], max(top_left[1] - 10, 0)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + (0, 0, 255), + 2, + ) + + # if is_dev: + # result_file = project_root / "tmp" / "debug" / f"{self.task_id}_result_debug.png" + # self._save_debug_image(result_file, large_img) + # logger.info(f"[任务 {self.task_id}] (Dev环境) 支付按钮定位图片已保存至: {result_file}") + + logger.debug(f"[任务 {self.task_id}] 目标识别成功,相似度: {max_val:.2f}") + logger.debug(f"[任务 {self.task_id}] 目标绝对坐标 -> X: {center_x}, Y: {center_y}") + + return { + "center_x": center_x, + "center_y": center_y, + "top_left": top_left, + "bottom_right": bottom_right, + "match_score": max_val, + } + + async def do_pay_of_google(self, page: Page) -> None: + """ + 处理谷歌支付 + """ + logger.debug(f"[任务 {self.task_id}] 处理谷歌支付") + # 点击订单确认 + try: + btn_confirm = page.locator("#edit-submit[value='ご注文内容の確認へ']").first + await btn_confirm.wait_for(state="visible", timeout=5000) + await page.wait_for_timeout(random.randint(1000, 3000)) + await human_scroll(page, mode='random') + await human_scroll(page, mode='bottom') + await human_scroll(page, mode='distance', value=500) + await btn_confirm.click() + logger.debug(f"[任务 {self.task_id}] 点击订单确认成功") + except PlaywrightTimeoutError: + logger.error(f"[任务 {self.task_id}] 未找到'ご注文内容の確認へ'按钮") + raise RuntimeError("未找到确认订单内容的按钮") + + # 关键节点:付款前确认截图 + logger.debug(f"[任务 {self.task_id}] 已到达结账检查点,正在拍摄确认截图...") + await self.take_screenshot(scene="购买前确认", full_page=True) + + # 点击订单确认(谷歌支付) + try: + logger.debug(f"[任务 {self.task_id}] 正在定位谷歌支付按钮") + gpay_btn = page.locator("#next_step_s #gpay-button-online-api-id").first + await gpay_btn.wait_for(state="visible", timeout=10000) + + # 确保按钮可见且处于可点击状态 + iframe = gpay_btn.locator("iframe.gpay-card-info-iframe") + await iframe.wait_for(state="attached", timeout=10000) + + # is_enabled() 在 Playwright 异步 API 中是协程,需要 await + if not await gpay_btn.is_enabled(): + logger.warning(f"[任务 {self.task_id}] Google Pay 按钮目前不可点击(可能正在加载中),尝试等待...") + await page.wait_for_timeout(2000) + if not await gpay_btn.is_enabled(): + raise RuntimeError("Google Pay 按钮可见但不可点击") + + # 模拟人类滚动页面增加迷惑性 + await human_scroll(page, mode='bottom') + await page.wait_for_timeout(random.randint(300, 800)) + await human_scroll(page, mode='top') + await page.wait_for_timeout(random.randint(300, 1500)) + # 点击谷歌支付按钮 + await gpay_btn.click() + logger.info(f"[任务 {self.task_id}] 点击谷歌支付按钮") + except PlaywrightTimeoutError: + logger.error(f"[任务 {self.task_id}] 等待 Google Pay 按钮超时") + raise RuntimeError("未找到谷歌支付按钮") + + # 识别谷歌支付弹窗的“继续”按钮 + await page.wait_for_timeout(3000) + max_retries = 2 + template_match = None + for attempt in range(max_retries + 1): + try: + template_match = self._locate_template_on_screen( + template_relative_path=Path("resources") / "images" / "google_pay_continue_btn.png", + ) + break + except Exception as e: + if attempt < max_retries: + logger.warning( + f"[任务 {self.task_id}] 识别谷歌支付按钮失败,等待 1 秒后重试... ({attempt + 1}/{max_retries}): {e}") + await page.wait_for_timeout(1000) + else: + logger.error(f"[任务 {self.task_id}] 识别谷歌支付按钮失败,已达到最大重试次数: {e}") + raise + + pyautogui.moveTo( + template_match["center_x"], + template_match["center_y"], + duration=0, + ) + + # pyautogui.click(template_match["center_x"], template_match["center_y"]) + # logger.info(f"[任务 {self.task_id}] 点击确认支付按钮") + + async def do_pay_of_paypay(self, page: Page) -> None: + """ + 处理paypay支付 + """ + logger.debug(f"[任务 {self.task_id}] 处理paypay支付") + paypay_opt = { + "phone": self.settings.paypay_phone, + "password": self.settings.paypay_password, + } + if not is_valid_str(paypay_opt["phone"]) or not is_valid_str(paypay_opt["password"]): + raise ValueError(f"[任务 {self.task_id}] PayPay 账号或密码未配置") + + try: + # 点击继续订单确认 + await page.wait_for_timeout(random.randint(800, 2000)) + btn_confirm = page.locator("#edit-submit[value='ご注文内容の確認へ']") + logger.debug(f"[任务 {self.task_id}] 等待[ご注文内容の確認へ]按钮可见") + await btn_confirm.wait_for(state="visible", timeout=5000) + await human_scroll(page, mode='bottom') + await page.wait_for_timeout(random.randint(800, 1000)) + await btn_confirm.click() + logger.debug(f"[任务 {self.task_id}] 点击[ご注文内容の確認へ]按钮") + + # 点击订单已确认 + await page.wait_for_timeout(random.randint(800, 1000)) + btn_confirm2 = page.locator("#edit-submit-confirm-bottom[value='注文確定']") + logger.debug(f"[任务 {self.task_id}] 等待[注文確定]按钮可见") + await btn_confirm2.wait_for(state="visible", timeout=5000) + pay_page_regex = re.compile(r"^https://www\.paypay\.ne\.jp/app/webcashier.*") + async with page.expect_navigation(url=pay_page_regex, wait_until="domcontentloaded", timeout=15000): + await btn_confirm2.click() + logger.debug(f"[任务 {self.task_id}] 点击[注文確定]按钮") + + page_url = page.url + logger.debug(f"[任务 {self.task_id}] 当前的页面地址是:{page_url}") + await self.take_screenshot(scene="paypay支付界面", full_page=True) + + amount_dom = page.locator(".custom-details .orderAmountLarge span.amount") + await amount_dom.wait_for(state="visible", timeout=5000) + amount = (await amount_dom.inner_text()).strip() + logger.debug(f"[任务 {self.task_id}] 支付金额:{amount}") + # 支付订单号(注文番号) + merchant_order = (await page.locator(".detail-item-outer .merchantOrderId .truncate").inner_text()).strip() + logger.debug(f"[任务 {self.task_id}] 支付订单号:{merchant_order}") + # 倒计时 + expiry_timer = (await page.locator(".expiry-timer-wrap .timer .time").inner_text()).strip() + logger.debug(f"[任务 {self.task_id}] 倒计时:{expiry_timer}") + + # 判断是否直接进入了支付环节 + is_submit_btn = False + logger.debug(f"[任务 {self.task_id}] 检测是否直接进入了支付环节") + submit_btn = page.locator(".submit-button-wrap button.submit-button") + try: + await submit_btn.wait_for(state="visible", timeout=2000) + logger.debug(f"[任务 {self.task_id}] 检测到支付按钮可见") + is_submit_btn = True + except PlaywrightTimeoutError as e: + logger.error(f"[任务 {self.task_id}] 等待支付按钮超时,未检测到支付按钮") + + # 未检测到支付按钮,进入登录环节 + if not is_submit_btn: + logger.debug(f"[任务 {self.task_id}] 未检测到支付按钮,进入登录环节") + + logger.debug(f"[任务 {self.task_id}] 检测paypay支付的表单登录按钮...") + show_login_btn = page.locator(".show-login > p > a").first + await show_login_btn.wait_for(state="visible", timeout=5000) + await page.wait_for_timeout(random.randint(800, 2000)) + await show_login_btn.click() + logger.debug(f"[任务 {self.task_id}] 点击paypay支付的登录按钮") + + # 定位到 iframe + logger.debug(f"[任务 {self.task_id}] 定位 iframe") + login_iframe = page.frame_locator("iframe[title='Authentication Box']") + + phone_input = login_iframe.get_by_placeholder("登録済みの携帯電話番号") + password_input = login_iframe.get_by_placeholder("パスワード") + + # 核心稳健点:显式等待输入框可见,确保 iframe 彻底加载完成 + logger.debug(f"[任务 {self.task_id}] 等待 iframe 内部表单渲染并可见...") + await phone_input.wait_for(state="visible", timeout=15000) + + # 填充手机号 + await phone_input.click() + await page.wait_for_timeout(random.randint(200, 500)) + await phone_input.type(paypay_opt["phone"], delay=random.randint(80, 140)) + + # 填充密码 + await password_input.click() + await page.wait_for_timeout(random.randint(200, 500)) + await password_input.type(paypay_opt["password"], delay=random.randint(80, 140)) + + # 点击登录按钮 + login_button = login_iframe.get_by_role("button", name="同意してログイン") + await login_button.click() + logger.debug(f"[任务 {self.task_id}] PayPay 点击登录按钮") + + # 登录提交后同时等待支付按钮或短信验证码页面出现 + login_result = await self._wait_for_paypay_login_result(page, login_iframe, timeout_sec=10) + if login_result == "sms": + # 异步通知人工输入短信验证码 + logger.info(f"[任务 {self.task_id}] 正在后台异步通知人工输入短信验证码...") + asyncio.create_task(self._paypay_sms_verification_notify(paypay_opt["phone"])) + # 等待人工输入短信验证码 + await self._wait_for_manual_sms_verification(page, login_iframe, timeout_sec=180) + logger.success(f"[任务 {self.task_id}] 短信验证码验证完成") + else: + logger.debug(f"[任务 {self.task_id}] 未检测到短信验证码输入框") + + # 再次检测支付按钮是否可见 + logger.debug(f"[任务 {self.task_id}] 再次检测支付按钮是否可见...") + submit_btn = page.locator(".submit-button-wrap button.submit-button") + try: + await submit_btn.wait_for(state="visible", timeout=2000) + except PlaywrightTimeoutError as e: + raise Exception(f"[任务 {self.task_id}] paypay最终支付按钮定位超时,退出任务") + + logger.debug(f"[任务 {self.task_id}] 定位到paypay最终支付按钮") + # 判断支付完成后是否跳回到骏河屋的站点内 www.suruga-ya.jp + page_regex = re.compile(r"^https://www\.suruga-ya\.jp.*") + async with page.expect_navigation(url=page_regex, wait_until="domcontentloaded", timeout=15000): + await submit_btn.click() + logger.debug(f"[任务 {self.task_id}] 点击[注文確定]按钮") + + logger.info(f"[任务 {self.task_id}] paypay支付流程结束,当前页面URL: {page.url}") + except Exception as e: + await self.take_screenshot(scene="paypay支付异常", full_page=True) + logger.error(f"[任务 {self.task_id}] 支付异常:{e}") + raise e + + async def _wait_for_paypay_login_result(self, page: Page, login_iframe, *, timeout_sec: float = 10.0): + """同时等待支付按钮或短信验证码登录页面出现(异步竞赛版)""" + logger.debug(f"[任务 {self.task_id}] 登录提交后同时等待支付按钮或短信验证码登录页面出现,限时 {timeout_sec} 秒") + submit_btn = page.locator(".submit-button-wrap button.submit-button") + sms_prompt = login_iframe.get_by_text("SMSで届いた認証コードを入力してください").first + + # 1. 使用 Python 标准的 asyncio.timeout 控制总时间 + try: + async with asyncio.timeout(timeout_sec): + + # 2. 创建两个并发等待任务 + task_submit = asyncio.create_task(submit_btn.wait_for(state="visible")) + task_sms = asyncio.create_task(sms_prompt.wait_for(state="visible")) + + # 3. 展开速度竞赛 + done, pending = await asyncio.wait( + {task_submit, task_sms}, + return_when=asyncio.FIRST_COMPLETED + ) + + # 4. 谁先到终点,就取消另一个并返回对应结果 + for p_task in pending: + p_task.cancel() + + first_completed = done.pop() + if first_completed == task_submit: + return "submit" + elif first_completed == task_sms: + return "sms" + + except TimeoutError: + # 当 asyncio.timeout 超时,会自动抛出内置的 TimeoutError + raise Exception(f"登录后等待支付按钮或短信验证码超时(限时 {timeout_sec} 秒)") + + async def _wait_for_manual_sms_verification(self, page: Page, login_iframe, *, timeout_sec: float = 180): + """ + 异步等待人工输入短信验证码,通过检查 iframe 消失或 URL 跳转来智能退出。 + 超时后抛出异常。 + + :param timeout_sec: 最大等待时间(秒),默认 180 秒(3分钟) + """ + logger.warning(f"[任务 {self.task_id}] 检测到短信验证码验证,请在 {timeout_sec} 秒内手动输入并提交!") + + # 记录初始的 URL,用来比对用户输入验证码后页面是否发生了重定向跳转 + initial_url = page.url + iframe_selector = "iframe[title='Authentication Box']" + sms_prompt = login_iframe.get_by_text("SMSで届いた認証コードを入力してください").first + try: + # 1. 使用 Python 标准的异步超时管理器控制总时间(秒) + async with asyncio.timeout(timeout_sec): + + while True: + # 检查点 1:防止浏览器中途被用户意外关闭或崩溃导致死循环 + if page.is_closed(): + logger.error(f"[任务 {self.task_id}] 浏览器已意外关闭,停止等待。") + return + + # 检查点 2:高效率探测外层 iframe 是否已经彻底从主页面 DOM 中移除 + # count() == 0 是瞬时返回的,没有任何隐式等待,性能极高 + if await page.locator(iframe_selector).count() == 0: + logger.info(f"[任务 {self.task_id}] 短信验证码验证已完成,登录 iframe 已消失") + break + + # 检查点 3:通过 URL 变更来双重保险判断。如果人工提交成功,页面往往会发生重定向 + if page.url != initial_url: + logger.info(f"[任务 {self.task_id}] 检测到页面 URL 发生变更跳转,判定验证已通过") + break + + # 检查点 4:通过sms_prompt + if not await sms_prompt.is_visible(): + logger.info(f"[任务 {self.task_id}] 短信验证码提示已消失,判定验证已通过") + break + + # 异步等待 1 秒,释放 CPU 算力,让出事件循环控制权 + await page.wait_for_timeout(1000) + + # 2. 顺利跳出循环说明验证成功,优雅地给网页 5 秒缓冲时间让其把跳转后的网络请求加载完 + logger.debug(f"[任务 {self.task_id}] 正在等待最终页面网络加载就绪...") + try: + await page.wait_for_load_state("networkidle", timeout=5000) + except PlaywrightTimeoutError: + logger.debug(f"[任务 {self.task_id}] 网络未完全空闲,但已到达安全就绪状态,继续后续流程") + + except TimeoutError: + # 当 asyncio.timeout 到时间后,会自动抛出标准 TimeoutError + logger.error(f"[任务 {self.task_id}] ❌ 等待人工输入短信验证码超时(限时 {timeout_sec} 秒),结束流程") + raise Exception(f"等待人工输入短信验证码超时({timeout_sec} 秒),结束本次业务流程") + + async def _paypay_sms_verification_notify(self, phone: str): + """ + 通知人工输入短信验证码 + """ + content_lines = [ + "PayPay支付触发短信验证", + "", + f"任务ID: {self.task_id}", + f"Topic: {self.topic}", + f"采购账号: {self.account_id}", + f"手机号: {phone}", + f"发生时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + ] + content_txt = "\n".join(content_lines) + + event = NotificationEvent( + event_type="sms_verification", + title="PayPay支付触发短信验证", + content_text=content_txt, + severity="info", + metadata={}, + ) + await self.notification_service.notify(event) + + async def _check_and_wait_for_paypay_processing( + self, + page: Page, + *, + max_retries: int = 3, + check_interval_sec: float = 30.0 + ) -> bool: + """ + 检查页面中是否存在 PayPay 正在处理中的红色警告提示。 + 如果存在,则循环等待并刷新页面,直到提示消失或达到最大重试次数。 + + :param page: Playwright Page 对象 + :param max_retries: 最大重试次数,默认 3 次 + :param check_interval_sec: 每次重试之间的等待时间(秒),默认 30 秒 + :return: bool -> True: 页面干净/处理完成,可以继续流程; False: 达到重试上限提示仍未消失,判定失败 + """ + alert_danger_str = "PayPay注文処理中です。このメッセージが消えるまで他の商品をカートに入れないでください。支払いを途中でキャンセルされた場合は5分お待ちください。" + logger.debug(f"[任务 {self.task_id}] 开始检查页面中是否存在 PayPay 占用提示...") + + for retry in range(1, max_retries + 1): + # 1. 检测提示语是否存在 + is_processing = await page.get_by_text(alert_danger_str).is_visible() + + # 2. 如果提示不存在,优雅退出并返回 True + if not is_processing: + if retry > 1: + logger.success(f"[任务 {self.task_id}] 第 {retry - 1} 次重试后,PayPay 提示已消失,订单处理完成") + else: + logger.info(f"[任务 {self.task_id}] 页面状态正常,未检测到 PayPay 占用提示") + return True + + # 3. 如果到了最后一次重试,提示依然存在,直接判定失败 + if retry == max_retries: + logger.error(f"[任务 {self.task_id}] ❌ 经过 {max_retries} 次刷新重试,PayPay 订单处理提示仍未消失") + return False + + # 4. 提示存在且未到上限,等待并刷新 + logger.warning( + f"[任务 {self.task_id}] 检测到 PayPay 订单正在处理中(当前第 {retry}/{max_retries} 次检查)," + f"将在 {check_interval_sec} 秒后刷新页面重试..." + ) + + await asyncio.sleep(check_interval_sec) + + try: + # 刷新页面,加入 15 秒超时保护 + await page.reload(wait_until="networkidle", timeout=15000) + except PlaywrightTimeoutError: + logger.warning( + f"[任务 {self.task_id}] 刷新页面时网络空闲超时(networkidle),将在下一轮循环中继续检测状态") + except Exception as e: + logger.error(f"[任务 {self.task_id}] 刷新页面时发生未知异常: {e}") + + return False diff --git a/src/tasks/surugaya_trade_monitor.py b/src/tasks/surugaya_trade_monitor.py new file mode 100644 index 0000000..70ae957 --- /dev/null +++ b/src/tasks/surugaya_trade_monitor.py @@ -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 diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..5569f8e --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1 @@ +# 工具模块包初始化 diff --git a/src/utils/app_utils.py b/src/utils/app_utils.py new file mode 100644 index 0000000..476d675 --- /dev/null +++ b/src/utils/app_utils.py @@ -0,0 +1,4 @@ + +def is_valid_str(s): + """判断字符串是否有效,即非空且非空格。""" + return isinstance(s, str) and len(s.strip()) > 0 \ No newline at end of file diff --git a/src/utils/http_utils.py b/src/utils/http_utils.py new file mode 100644 index 0000000..2d05651 --- /dev/null +++ b/src/utils/http_utils.py @@ -0,0 +1,38 @@ +import json +import time +import hmac +import secrets +import hashlib +from typing import Dict, Any + +def generate_signed_headers(app_id: str, app_secret: str, data: Dict[str, Any], method: str = "POST") -> Dict[str, str]: + timestamp = str(int(time.time() * 1000)) + nonce = secrets.token_hex(16) + + payload_str = json.dumps(data, ensure_ascii=False) + payload_bytes = payload_str.encode("utf-8") + actual_body_hash = hashlib.sha256(payload_bytes).hexdigest() + + string_to_sign = "\n".join([ + method.upper(), + app_id, + timestamp, + nonce, + actual_body_hash + ]) + + signature = hmac.new( + app_secret.encode("utf-8"), + string_to_sign.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + + headers = { + "X-Ca-Appid": app_id, + "X-Ca-Timestamp": timestamp, + "X-Ca-Nonce": nonce, + "X-Ca-Signature": signature, + "Content-Type": "application/json; charset=utf-8" + } + + return headers diff --git a/src/utils/logger.py b/src/utils/logger.py new file mode 100644 index 0000000..c36f433 --- /dev/null +++ b/src/utils/logger.py @@ -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 = ( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level: <5} | " + "{message}" +) + +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="") + logger.level("DEBUG", color="") + + 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()}") diff --git a/src/utils/page_utils.py b/src/utils/page_utils.py new file mode 100644 index 0000000..7f30133 --- /dev/null +++ b/src/utils/page_utils.py @@ -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)) \ No newline at end of file diff --git a/src/utils/video_recorder.py b/src/utils/video_recorder.py new file mode 100644 index 0000000..ce53314 --- /dev/null +++ b/src/utils/video_recorder.py @@ -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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..142779d --- /dev/null +++ b/uv.lock @@ -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" }, +]